Auth::getUserModel()
last analyzed

Size

Total Lines 25
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 10
c 1
b 0
f 0
nc 2
nop 1
dl 0
loc 25
ccs 8
cts 8
cp 1

1 Method

Rating   Name   Duplication   Size   Complexity  
A Auth.php$0 ➔ schema() 0 3 1
1
<?php
2
3
/**
4
 * @author Marwan Al-Soltany <[email protected]>
5
 * @copyright Marwan Al-Soltany 2021
6
 * For the full copyright and license information, please view
7
 * the LICENSE file that was distributed with this source code.
8
 */
9
10
declare(strict_types=1);
11
12
namespace MAKS\Velox\Backend;
13
14
use MAKS\Velox\App;
15
use MAKS\Velox\Backend\Exception;
16
use MAKS\Velox\Backend\Event;
17
use MAKS\Velox\Backend\Config;
18
use MAKS\Velox\Backend\Model;
19
use MAKS\Velox\Backend\Globals;
20
use MAKS\Velox\Backend\Session;
21
22
/**
23
 * A class that serves as an authentication system for users.
24
 *
25
 * Example:
26
 * ```
27
 * // register a new user
28
 * $auth = new Auth(); // or Auth::instance();
29
 * $status = $auth->register('username', 'password');
30
 *
31
 * // unregister a user
32
 * $status = Auth::instance()->unregister('username');
33
 *
34
 * // log in a user
35
 * $status = Auth::instance()->login('username', 'password');
36
 *
37
 * // log out a user
38
 * Auth::instance()->logout();
39
 *
40
 * // authenticate a user model
41
 * Auth::authenticate($user);
42
 *
43
 * // check if there is a logged in user
44
 * $status = Auth::check();
45
 *
46
 * // retrieve the current authenticated user
47
 * $user = Auth::user();
48
 *
49
 * // add HTTP basic auth
50
 * Auth::basic(['username' => 'password']);
51
 * ```
52
 *
53
 * @package Velox\Backend
54
 * @since 1.4.0
55
 * @api
56
 */
57
class Auth
58
{
59
    /**
60
     * This event will be dispatched when an auth user is registered.
61
     * This event will be passed the user model object and its listener callback will be bound to the object (the auth class).
62
     * This event is useful if the user model class has additional attributes other than the `username` and `password` that need to be set.
63
     *
64
     * @var string
65
     */
66
    public const ON_REGISTER = 'auth.on.register';
67
68
    /**
69
     * This event will be dispatched after an auth user is registered.
70
     * This event will be passed the user model object and its listener callback will be bound to the object (the auth class instance).
71
     *
72
     * @var string
73
     */
74
    public const AFTER_REGISTER = 'auth.after.register';
75
76
    /**
77
     * This event will be dispatched when an auth user is unregistered.
78
     * This event will be passed the user model object and its listener callback will be bound to the object (the auth class instance).
79
     *
80
     * @var string
81
     */
82
    public const ON_UNREGISTER = 'auth.on.unregister';
83
84
    /**
85
     * This event will be dispatched when an auth user is logged in.
86
     * This event will be passed the user model object and its listener callback will be bound to the object (the auth class instance).
87
     *
88
     * @var string
89
     */
90
    public const ON_LOGIN = 'auth.on.login';
91
92
    /**
93
     * This event will be dispatched when an auth user is logged out.
94
     * This event will be passed the user model object and its listener callback will be bound to the object (the auth class instance).
95
     *
96
     * @var string
97
     */
98
    public const ON_LOGOUT = 'auth.on.logout';
99
100
101
    /**
102
     * The class singleton instance.
103
     */
104
    protected static self $instance;
105
106
107
    /**
108
     * Auth user model.
109
     */
110
    protected Model $user;
111
112
113
    /**
114
     * Class constructor.
115
     *
116
     * @param string $model [optional] The auth user model class to use.
117
     */
118 8
    public function __construct(?string $model = null)
119
    {
120 8
        if (empty(static::$instance)) {
121
            static::$instance = $this;
122
        }
123
124 8
        $this->user = $this->getUserModel($model);
125
126 8
        $this->check();
127
    }
128
129
130
    /**
131
     * Returns the singleton instance of the class.
132
     *
133
     * NOTE: This method returns only the first instance of the class
134
     * which is normally the one that was created during application bootstrap.
135
     *
136
     * @return static
137
     */
138 12
    final public static function instance(): self
139
    {
140 12
        if (empty(static::$instance)) {
141
            static::$instance = new static();
142
        }
143
144 12
        return static::$instance;
145
    }
146
147
    /**
148
     * Registers a new user.
149
     *
150
     * @param string $username Auth user username.
151
     * @param string $password Auth user password.
152
     *
153
     * @return bool True if the user was registered successfully, false if the user is already registered.
154
     */
155 7
    public function register(string $username, string $password): bool
156
    {
157 7
        $user = $this->user->one([
158
            'username' => $username,
159
        ]);
160
161 7
        if ($user instanceof Model) {
162 1
            return false;
163
        }
164
165 7
        $user = $this->user->create([
166
            'username' => $username,
167 7
            'password' => $this->hash($password),
168
        ]);
169
170 7
        Event::dispatch(self::ON_REGISTER, [$user], $this);
171
172 7
        $user->save();
173
174 7
        Event::dispatch(self::AFTER_REGISTER, [$user], $this);
175
176 7
        return true;
177
    }
178
179
    /**
180
     * Unregisters a user.
181
     *
182
     * @param string $username Auth user username.
183
     *
184
     * @return bool True if the user was unregistered successfully, false if the user is not registered.
185
     */
186 3
    public function unregister(string $username): bool
187
    {
188 3
        $user = $this->user->one([
189
            'username' => $username,
190
        ]);
191
192 3
        if (!$user) {
193 1
            return false;
194
        }
195
196 3
        if ($this->check()) {
197 2
            $this->logout();
198
        }
199
200 3
        Event::dispatch(self::ON_UNREGISTER, [$user], $this);
201
202 3
        $user->delete();
203
204 3
        return true;
205
    }
206
207
    /**
208
     * Logs in a user.
209
     *
210
     * @param string $username Auth user username.
211
     * @param string $password Auth user password.
212
     *
213
     * @return bool True if the user was logged in successfully, false if the user is not registered or the password is incorrect.
214
     */
215 6
    public function login(string $username, string $password): bool
216
    {
217 6
        $user = $this->user->one([
218
            'username' => $username,
219
        ]);
220
221
        if (
222 6
            $user instanceof Model &&
223
            (
224 6
                $this->verify($password, $user->getPassword()) ||
0 ignored issues
show
Bug introduced by
The method getPassword() does not exist on MAKS\Velox\Backend\Model. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

224
                $this->verify($password, $user->/** @scrutinizer ignore-call */ getPassword()) ||
Loading history...
225 6
                $password === $user->getPassword() // self::authenticate() will pass a hashed password
226
            )
227
        ) {
228 6
            Session::set('_auth.username', $username);
229 6
            Session::set('_auth.timeout', time() + Config::get('auth.user.timeout', 3600));
230
231 6
            Event::dispatch(self::ON_LOGIN, [$user], $this);
232
233 6
            return true;
234
        }
235
236 2
        return false;
237
    }
238
239
    /**
240
     * Logs out a user.
241
     *
242
     * @return void
243
     */
244 5
    public function logout(): void
245
    {
246 5
        $user = $this->user();
247
248 5
        Session::cut('_auth');
249
250 5
        Event::dispatch(self::ON_LOGOUT, [$user], $this);
251
    }
252
253
    /**
254
     * Authenticates an auth user model.
255
     *
256
     * @param Model $user The auth user model to authenticate.
257
     *
258
     * @return void
259
     *
260
     * @throws \DomainException If the user could not be authenticated or the model is not an auth user model.
261
     */
262 1
    public static function authenticate(Model $user): void
263
    {
264 1
        $instance = static::instance();
265
266 1
        $success  = false;
267
268 1
        Exception::handle(
269 1
            function () use ($instance, $user, &$success) {
270 1
                $success = $instance->login(
271 1
                    $user->getUsername(),
0 ignored issues
show
Bug introduced by
The method getUsername() does not exist on MAKS\Velox\Backend\Model. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

271
                    $user->/** @scrutinizer ignore-call */ 
272
                           getUsername(),
Loading history...
272 1
                    $user->getPassword()
273
                );
274
            },
275
            'AuthenticationFailedException:DomainException',
276
            "Could not authenticate the model, the model may not be a valid auth user model"
277
        );
278
279 1
        if (!$success) {
280 1
            Exception::throw(
281
                'AuthenticationFailedException:DomainException',
282 1
                "Could not authenticate auth user with ID '{$user->getId()}'",
0 ignored issues
show
Bug introduced by
The method getId() does not exist on MAKS\Velox\Backend\Model. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

282
                "Could not authenticate auth user with ID '{$user->/** @scrutinizer ignore-call */ getId()}'",
Loading history...
283
            );
284
        }
285
    }
286
287
    /**
288
     * Checks if a user is logged in and logs the user out if the timeout has expired.
289
     *
290
     * @return bool
291
     */
292 11
    public static function check(): bool
293
    {
294 11
        if (Session::get('_auth.timeout') <= time()) {
295 11
            Session::cut('_auth');
296
        }
297
298 11
        if (Session::has('_auth')) {
299 4
            return true;
300
        }
301
302 11
        return false;
303
    }
304
305
    /**
306
     * Returns the authenticated user model instance.
307
     *
308
     * @return Model|null The authenticated user or null if no user has logged in.
309
     */
310 5
    public static function user(): ?Model
311
    {
312 5
        if ($username = Session::get('_auth.username')) {
313 5
            return static::getUserModel()->findByUsername($username)[0] ?? null;
0 ignored issues
show
Bug introduced by
The method findByUsername() does not exist on anonymous//classes/Backend/Auth.php$0. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

313
            return static::getUserModel()->/** @scrutinizer ignore-call */ findByUsername($username)[0] ?? null;
Loading history...
314
        }
315
316 1
        return null;
317
    }
318
319
    /**
320
     * Serves as an HTTP Basic Authentication guard for the specified logins.
321
     *
322
     * @param array $logins The login data, an associative array where key is the `username` and value is the `password`.
323
     *
324
     * @return void
325
     *
326
     * @throws \InvalidArgumentException If no logins where provided.
327
     *
328
     * @codeCoverageIgnore Can't test methods that send headers.
329
     */
330
    public static function basic(array $logins)
331
    {
332
        if (count($logins) === 0) {
333
            Exception::throw(
334
                'BadLoginCredentialsException:InvalidArgumentException',
335
                'No valid login(s) provided',
336
            );
337
        }
338
339
        $username = Globals::getServer('PHP_AUTH_USER');
340
        $password = Globals::getServer('PHP_AUTH_PW');
341
342
        $isAuthenticated = false;
343
        foreach ($logins as $user => $pass) {
344
            if ($username === $user && $password === $pass) {
345
                $isAuthenticated = true;
346
347
                break;
348
            }
349
        }
350
351
        header('Cache-Control: no-cache, must-revalidate, max-age=0');
352
353
        if (!$isAuthenticated) {
354
            header('HTTP/1.1 401 Authorization Required');
355
            header('WWW-Authenticate: Basic realm="Access denied"');
356
357
            self::fail();
358
        }
359
    }
360
361
    /**
362
     * Renders 401 error page.
363
     *
364
     * @return void
365
     *
366
     * @codeCoverageIgnore Can't test methods that send headers.
367
     */
368
    public static function fail(): void
369
    {
370
        App::log('Responded with 401 to the request for "{uri}". Authentication failed. Client IP address {ip}', [
371
            'uri' => Globals::getServer('REQUEST_URI'),
372
            'ip'  => Globals::getServer('REMOTE_ADDR'),
373
        ], 'system');
374
375
        App::abort(401, null, 'You need to be logged in to view this page!');
376
    }
377
378
    /**
379
     * Hashes a password.
380
     *
381
     * @param string $password
382
     *
383
     * @return string The hashed password.
384
     */
385 7
    protected function hash(string $password): string
386
    {
387 7
        $hashingConfig = Config::get('auth.hashing');
388
389 7
        return password_hash($password, $hashingConfig['algorithm'] ?? PASSWORD_DEFAULT, [
390 7
            'cost' => $hashingConfig['cost'] ?? 10,
391
        ]);
392
    }
393
394
    /**
395
     * Verifies a password.
396
     *
397
     * @param string $password
398
     * @param string $hash
399
     *
400
     * @return bool
401
     */
402 6
    protected function verify(string $password, string $hash): bool
403
    {
404 6
        return password_verify($password, $hash);
405
    }
406
407
    /**
408
     * Returns an instance of the user model class specified in the config or falls back to the default one.
409
     *
410
     * @param string $model [optional] The auth user model class to use.
411
     *
412
     * @return Model
413
     */
414 8
    protected static function getUserModel(?string $model = null): Model
415
    {
416 8
        $model = $model ?? Config::get('auth.user.model');
417
418 8
        $model = class_exists((string)$model)
419 8
            ? new $model()
420 8
            : new class () extends Model {
421
                public static ?string $table = 'users';
422
                public static ?string $primaryKey = 'id';
423
                public static ?array $columns = ['id', 'username', 'password'];
424
                public static function schema(): string
425
                {
426 8
                    return '
427
                        CREATE TABLE IF NOT EXISTS `users` (
428
                            `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
429
                            `username` VARCHAR(255) NOT NULL UNIQUE,
430
                            `password` VARCHAR(255) NOT NULL
431
                        );
432
                    ';
433
                }
434
            };
435
436 8
        Config::set('auth.user.model', get_class($model));
437
438 8
        return $model;
439
    }
440
}
441