Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.47% covered (warning)
76.47%
39 / 51
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Service
76.47% covered (warning)
76.47%
39 / 51
60.00% covered (warning)
60.00%
6 / 10
19.33
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 booted
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
1.06
 brand
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 servicesStats
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCacheBaseTags
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 generateBearerToken
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 generateBearerPassword
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 incrementStat
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getFromBearerToken
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 authenticateApi
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace App\Models;
4
5use App\Jobs\SetServiceStats;
6use App\Lib\Helper;
7use Illuminate\Database\Eloquent\Factories\HasFactory;
8use Illuminate\Database\Eloquent\Model;
9use Illuminate\Database\Eloquent\ModelNotFoundException;
10use Illuminate\Http\Request;
11use Illuminate\Support\Facades\Crypt;
12use Illuminate\Support\Facades\Hash;
13use Illuminate\Support\Str;
14use Rennokki\QueryCache\Traits\QueryCacheable;
15
16/**
17 * @property integer                         $id                Id.
18 * @property integer                         $brand_id          Foreign key Brand.
19 * @property string                          $name              Nom.
20 * @property string                          $bearer_token
21 * @property string                          $bearer_pwd
22 * @property \Carbon\Carbon                  $bearer_token_date
23 * @property-read \Illuminate\Support\Carbon $created_at        Created date.
24 * @property-read \Illuminate\Support\Carbon $updated_at        Updated date.
25 *
26 * @property-read \App\Models\Brand    $brand Brand BelongsTo relation.
27 * @property-read \Illuminate\Database\Eloquent\Collection<App\Models\ServiceStat>
28 * $servicesStats ServiceStat HasMany relation.
29 */
30class Service extends Model
31{
32    use HasFactory;
33    use QueryCacheable;
34
35    /**
36     * The timeout cache for this model
37     *
38     * @var integer
39     */
40    protected $cacheFor = 0;
41
42    /**
43     * The attributes that are mass assignable.
44     *
45     * @var string[]
46     */
47    protected $fillable = [
48        'name',
49        'brand_id',
50    ];
51
52    /**
53     * The attributes that should be cast to native types.
54     *
55     * @var array
56     */
57    protected $casts = [
58        'bearer_token_date' => 'datetime',
59    ];
60
61    /**
62     * Service.
63     *
64     * @param array $attributes
65     */
66    public function __construct(array $attributes = [])
67    {
68        parent::__construct($attributes);
69        $this->cacheFor = config('api.servicesDBCache') * 60;
70    }
71
72
73    /**
74     * Perform any actions required after the model boots.
75     *
76     * @return void
77     */
78    protected static function booted(): void
79    {
80        static::creating(function (self $model) {
81            $model->generateBearerToken();
82            $model->generateBearerPassword();
83        });
84        static::created(function () {
85            // * Flush Eloquent cache.
86            self::flushQueryCache();
87        });
88        static::updated(function () {
89            // * Flush Eloquent cache.
90            self::flushQueryCache();
91        });
92        static::deleted(function () {
93            // * Flush Eloquent cache.
94            self::flushQueryCache();
95        });
96    }
97
98    // * RELATIONS
99
100    /**
101     * Brand BelongsTo relation.
102     *
103     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
104     */
105    public function brand(): \Illuminate\Database\Eloquent\Relations\BelongsTo
106    {
107        return $this->belongsTo(Brand::class);
108    }
109
110    /**
111     * ServiceStat hasMany relation.
112     *
113     * @return \Illuminate\Database\Eloquent\Relations\HasMany
114     */
115    public function servicesStats(): \Illuminate\Database\Eloquent\Relations\HasMany
116    {
117        return $this->hasMany(ServiceStat::class);
118    }
119
120    // * METHODS
121
122    /**
123     * Set the base cache tags that will be present
124     * on all queries.
125     *
126     * ! This is used to flush caching
127     *
128     * @return array
129     */
130    protected function getCacheBaseTags(): array
131    {
132        return [
133            'crm_services',
134        ];
135    }
136
137    /**
138     * Generates new bearer token.
139     *
140     * @return string
141     */
142    public function generateBearerToken(): string
143    {
144        $this->bearer_token      = hash('ripemd160', Str::uuid());
145        $this->bearer_token_date = now();
146        return $this->bearer_token;
147    }
148
149    /**
150     * Generates new bearer password
151     * It store the crypted password in model
152     * and returns the plain password.
153     *
154     * @return string
155     */
156    public function generateBearerPassword(): string
157    {
158        $pwd              = hash('sha256', Str::uuid());
159        $this->bearer_pwd = Crypt::encryptString($pwd);
160        return $pwd;
161    }
162
163    /**
164     * Increment API CRM Stat.
165     *
166     * @return void
167     */
168    public function incrementStat(): void
169    {
170        SetServiceStats::dispatch($this)->afterResponse()
171            ->onConnection('database')->onQueue('stats');
172    }
173
174    /**
175     * Get Service from bearerToken with its brand.
176     *
177     * @param string $bearerToken
178     * @return \App\Models\Service
179     * @throws \Illuminate\Database\Eloquent\ModelNotFoundException If the token matches no known service.
180     */
181    public static function getFromBearerToken(string $bearerToken): self
182    {
183        $services = static::all();
184        /** @var \App\Models\Service $service */
185        foreach ($services as $service) {
186            $tokenEquals = false;
187            try {
188                $tokenEquals = Hash::check($bearerToken, $service->bearer_token);
189            } catch (\RuntimeException) {
190                // * Keep compatibility with V1.
191                // TODO: Remove this method when no more service will be in V1 (hashed instead of encrypted).
192                $tokenEquals = $bearerToken === $service->bearer_token;
193            }
194            if ($service->bearer_token and $tokenEquals) {
195                return $service->load([
196                    'brand' => function ($query) use ($service) {
197                        return $query->cacheTags(["service:{$service->id}:brand"]);
198                    },
199                ]);
200            }
201        }
202        throw new ModelNotFoundException('Could not find service from Service Token');
203    }
204
205    /**
206     * Authenticate the request for this service
207     *
208     * This will check hash-mac and that the request is wellformed
209     *
210     * The request should have the minimum following parameters
211     *  - timestamp using format Y-m-d H:i:s, and must be UTC (Greenwich Mean Time)
212     *  - bearerToken the public token generated by the crm
213     *  - signature the hashmac signature of the request
214     *
215     * ! Signature must be calculated with every parameter urlencode once only
216     * ! All parameters to calculate the signature must be sorted (natural sort asc)
217     *
218     * ! Signature should look like
219     * * httpVerb\n
220     * * uri\n
221     * * queryString
222     *
223     * ! httpVerb as well as uri must be lower case
224     * ! uri should include scheme, host and path and nothing else, if path is empty a trailing slash should be added
225     * ! eg: https://ldc-crm.fr/customer/1  or  https://ldc-crm.fr/
226     * ! The query string shall be separated with '&', eg param1=value1&param2=value2
227     * ! Key names and values of the query string must be url encoded using RFC 3986
228     * ! http://www.faqs.org/rfcs/rfc3986.html
229     * ! Signature must be hmac sha256 using the private key generatedby the crm
230     *
231     *     * ! They should then be concat in a string separated by \n
232     *
233     * @param \Illuminate\Http\Request $request
234     * @return boolean
235     * @throws \App\Exceptions\Api\V4\CrmException If an error occurs.
236     */
237    public function authenticateApi(Request $request): bool
238    {
239        // ! Hack to prevent Laravel to cast empty strings to null or the opposite.
240        // ! If cannot decode json user legacy all() method.
241        $data = \json_decode($request->getContent(), true);
242        $data = (\is_array($data) and \json_last_error() === \JSON_ERROR_NONE) ?
243            array_replace_recursive($request->all(), $data) : $request->all();
244
245        $calculatedHash = Helper::signRequest(
246            $this->bearer_pwd,
247            $request->getUri(),
248            $request->getMethod(),
249            $data
250        );
251
252        // ! Très important hash_equals https://fr.wikipedia.org/wiki/Attaque_temporelle
253        return \hash_equals($calculatedHash, $request->signature);
254    }
255}