Passed
Push — master ( 57cfb9...a2aa4e )
by Marwan
10:09
created

Auth::register()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 22
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 11
c 1
b 0
f 0
nc 2
nop 2
dl 0
loc 22
ccs 12
cts 12
cp 1
cc 2
crap 2
rs 9.9
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\Event;
16
use MAKS\Velox\Backend\Config;
17
use MAKS\Velox\Backend\Model;
18
use MAKS\Velox\Backend\Globals;
19
use MAKS\Velox\Backend\Session;
20
use MAKS\Velox\Frontend\View;
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
 * @since 1.4.0
54
 * @api
55
 */
56
class Auth
57
{
58
    /**
59
     * This event will be dispatched when an auth user is registered.
60
     * This event will be passed the user model object and its listener callback will be bound to the object (the auth class).
61
     * This event is useful if the user model class has additional attributes other than the `username` and `password` that need to be set.
62
     *
63
     * @var string
64
     */
65
    public const ON_REGISTER = 'auth.on.register';
66
67
    /**
68
     * This event will be dispatched after an auth user is registered.
69
     * This event will be passed the user model object and its listener callback will be bound to the object (the auth class instance).
70
     *
71
     * @var string
72
     */
73
    public const AFTER_REGISTER = 'auth.after.register';
74
75
    /**
76
     * This event will be dispatched when an auth user is unregistered.
77
     * This event will be passed the user model object and its listener callback will be bound to the object (the auth class instance).
78
     *
79
     * @var string
80
     */
81
    public const ON_UNREGISTER = 'auth.on.unregister';
82
83
    /**
84
     * This event will be dispatched when an auth user is logged in.
85
     * This event will be passed the user model object and its listener callback will be bound to the object (the auth class instance).
86
     *
87
     * @var string
88
     */
89
    public const ON_LOGIN = 'auth.on.login';
90
91
    /**
92
     * This event will be dispatched when an auth user is logged out.
93
     * This event will be passed the user model object and its listener callback will be bound to the object (the auth class instance).
94
     *
95
     * @var string
96
     */
97
    public const ON_LOGOUT = 'auth.on.logout';
98
99
100
    /**
101
     * The class singleton instance.
102
     */
103
    protected static self $instance;
104
105
106
    /**
107
     * Auth user model.
108
     */
109
    protected Model $user;
110
111
112
    /**
113
     * Class constructor.
114
     *
115
     * @param string $model [optional] The auth user model class to use.
116
     */
117 8
    public function __construct(?string $model = null)
118
    {
119 8
        if (empty(static::$instance)) {
120
            static::$instance = $this;
121
        }
122
123 8
        $this->user = $this->getUserModel($model);
124
125 8
        $this->check();
126 8
    }
127
128
129
    /**
130
     * Returns the singleton instance of the class.
131
     *
132
     * NOTE: This method returns only the first instance of the class
133
     * which is normally the one that was created during application bootstrap.
134
     *
135
     * @return static
136
     */
137 12
    final public static function instance(): self
138
    {
139 12
        if (empty(static::$instance)) {
140
            static::$instance = new static();
141
        }
142
143 12
        return static::$instance;
144
    }
145
146
    /**
147
     * Registers a new user.
148
     *
149
     * @param string $username Auth user username.
150
     * @param string $password Auth user password.
151
     *
152
     * @return bool True if the user was registered successfully, false if the user is already registered.
153
     */
154 7
    public function register(string $username, string $password): bool
155
    {
156 7
        $user = $this->user->one([
157 7
            'username' => $username,
158
        ]);
159
160 7
        if ($user instanceof Model) {
161 1
            return false;
162
        }
163
164 7
        $user = $this->user->create([
165 7
            'username' => $username,
166 7
            'password' => $this->hash($password),
167
        ]);
168
169 7
        Event::dispatch(self::ON_REGISTER, [$user], $this);
170
171 7
        $user->save();
172
173 7
        Event::dispatch(self::AFTER_REGISTER, [$user], $this);
174
175 7
        return true;
176
    }
177
178
    /**
179
     * Unregisters a user.
180
     *
181
     * @param string $username Auth user username.
182
     *
183
     * @return bool True if the user was unregistered successfully, false if the user is not registered.
184
     */
185 3
    public function unregister(string $username): bool
186
    {
187 3
        $user = $this->user->one([
188 3
            'username' => $username,
189
        ]);
190
191 3
        if (!$user) {
192 1
            return false;
193
        }
194
195 3
        if ($this->check()) {
196 2
            $this->logout();
197
        }
198
199 3
        Event::dispatch(self::ON_UNREGISTER, [$user], $this);
200
201 3
        $user->delete();
202
203 3
        return true;
204
    }
205
206
    /**
207
     * Logs in a user.
208
     *
209
     * @param string $username Auth user username.
210
     * @param string $password Auth user password.
211
     *
212
     * @return bool True if the user was logged in successfully, false if the user is not registered or the password is incorrect.
213
     */
214 6
    public function login(string $username, string $password): bool
215
    {
216 6
        $user = $this->user->one([
217 6
            'username' => $username,
218
        ]);
219
220
        if (
221 6
            $user instanceof Model &&
222
            (
223 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

223
                $this->verify($password, $user->/** @scrutinizer ignore-call */ getPassword()) ||
Loading history...
224 6
                $password === $user->getPassword() // self::authenticate() will pass a hashed password
225
            )
226
        ) {
227 6
            Session::set('_auth.username', $username);
228 6
            Session::set('_auth.timeout', time() + Config::get('auth.user.timeout', 3600));
229
230 6
            Event::dispatch(self::ON_LOGIN, [$user], $this);
231
232 6
            return true;
233
        }
234
235 2
        return false;
236
    }
237
238
    /**
239
     * Logs out a user.
240
     *
241
     * @return void
242
     */
243 5
    public function logout(): void
244
    {
245 5
        $user = $this->user();
246
247 5
        Session::cut('_auth');
248
249 5
        Event::dispatch(self::ON_LOGOUT, [$user], $this);
250 5
    }
251
252
    /**
253
     * Authenticates an auth user model.
254
     *
255
     * @param Model $user The auth user model to authenticate.
256
     *
257
     * @return void
258
     *
259
     * @throws \Exception If the user could not be authenticated.
260
     */
261 1
    public static function authenticate(Model $user): void
262
    {
263 1
        $success = static::instance()->login(
264 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

264
            $user->/** @scrutinizer ignore-call */ 
265
                   getUsername(),
Loading history...
265 1
            $user->getPassword()
266
        );
267
268 1
        if (!$success) {
269 1
            throw new \Exception("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

269
            throw new \Exception("Could not authenticate auth user with ID '{$user->/** @scrutinizer ignore-call */ getId()}'");
Loading history...
270
        }
271 1
    }
272
273
    /**
274
     * Checks if a user is logged in and logs the user out if the timeout has expired.
275
     *
276
     * @return bool
277
     */
278 10
    public static function check(): bool
279
    {
280 10
        if (Session::get('_auth.timeout') <= time()) {
281 10
            Session::cut('_auth');
282
        }
283
284 10
        if (Session::has('_auth')) {
285 4
            return true;
286
        }
287
288 10
        return false;
289
    }
290
291
    /**
292
     * Returns the authenticated user model instance.
293
     *
294
     * @return Model|null The authenticated user or null if no user has logged in.
295
     */
296 5
    public static function user(): ?Model
297
    {
298 5
        if ($username = Session::get('_auth.username')) {
299 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

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