Completed
Push — master ( 6c1856...1699d4 )
by Arnold
03:39
created

Auth::mfa()   A

Complexity

Conditions 5
Paths 7

Size

Total Lines 23
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 5

Importance

Changes 0
Metric Value
eloc 12
c 0
b 0
f 0
dl 0
loc 23
ccs 13
cts 13
cp 1
rs 9.5555
cc 5
nc 7
nop 1
crap 5
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Jasny\Auth;
6
7
use Jasny\Auth\AuthzInterface as Authz;
8
use Jasny\Auth\Confirmation\ConfirmationInterface as Confirmation;
9
use Jasny\Auth\Confirmation\NoConfirmation;
10
use Jasny\Auth\ContextInterface as Context;
11
use Jasny\Auth\Session\PhpSession;
12
use Jasny\Auth\Session\SessionInterface as Session;
13
use Jasny\Auth\StorageInterface as Storage;
14
use Jasny\Auth\User\PartiallyLoggedIn;
15
use Jasny\Auth\UserInterface as User;
16
use Jasny\Immutable;
17
use Psr\EventDispatcher\EventDispatcherInterface as EventDispatcher;
18
use Psr\Log\LoggerInterface as Logger;
19
use Psr\Log\NullLogger;
20
21
/**
22
 * Authentication and authorization.
23
 */
24
class Auth implements Authz
25
{
26
    use Immutable\With;
27
    use Immutable\NoDynamicProperties;
28
29
    /**
30
     * Stateful authz service.
31
     * A new copy will be set if user is logged in or out, or if context changes.
32
     */
33
    protected Authz $authz;
34
35
    /**
36
     * Time when logged in.
37
     */
38
    protected ?\DateTimeInterface $timestamp = null;
39
40
    protected Session $session;
41
    protected Storage $storage;
42
    protected Confirmation $confirmation;
43
44
    protected EventDispatcher $dispatcher;
45
    protected Logger $logger;
46
47
    /** Allow service to be re-initialized */
48
    protected bool $forMultipleRequests = false;
49
50
    /** @var \Closure&callable(User $user, string $code):bool */
51
    protected \Closure $verifyMFA;
52
53
    /**
54
     * Auth constructor.
55
     */
56 45
    public function __construct(Authz $authz, Storage $storage, ?Confirmation $confirmation = null)
57
    {
58 45
        $this->authz = $authz;
59 45
        $this->storage = $storage;
60 45
        $this->confirmation = $confirmation ?? new NoConfirmation();
61
62
        // Set default services
63 45
        $this->dispatcher = self::dummyDispatcher();
64 45
        $this->logger = new NullLogger();
65 45
        $this->verifyMFA = fn() => false;
66 45
    }
67
68
    /**
69
     * Get a copy of the service that allows reinitializing it.
70
     *
71
     * @return static
72
     */
73 1
    public function forMultipleRequests(): self
74
    {
75 1
        return $this->withProperty('forMultipleRequests', true);
76
    }
77
78
    /**
79
     * Get a copy with an event dispatcher.
80
     */
81 45
    public function withEventDispatcher(EventDispatcher $dispatcher): self
82
    {
83 45
        return $this->withProperty('dispatcher', $dispatcher);
84
    }
85
86
    /**
87
     * Get a copy with a logger.
88
     */
89 45
    public function withLogger(Logger $logger): self
90
    {
91 45
        return $this->withProperty('logger', $logger);
92
    }
93
94
    /**
95
     * Get the logger used for this service.
96
     */
97 1
    public function getLogger(): Logger
98
    {
99 1
        return $this->logger;
100
    }
101
102
    /**
103
     * Get a copy of the service with Multi Factor Authentication (MFA) support.
104
     *
105
     * @param callable $verify  Callback to verify MFA.
106
     * @return static
107
     */
108 5
    public function withMFA(callable $verify): self
109
    {
110 5
        return $this->withProperty('verifyMFA', \Closure::fromCallable($verify));
111
    }
112
113
114
    /**
115
     * Initialize the service using session information.
116
     */
117 8
    public function initialize(?Session $session = null): void
118
    {
119 8
        if ($this->isInitialized()) {
120 2
            if (!$this->forMultipleRequests) {
121 1
                throw new \LogicException("Auth service is already initialized");
122
            }
123
124 1
            $this->authz = $this->authz()->forUser(null)->inContextOf(null);
125
        }
126
127 7
        $this->session = $session ?? new PhpSession();
128 7
        ['user' => $user, 'context' => $context, 'timestamp' => $this->timestamp] = $this->getInfoFromSession();
129
130 7
        $this->authz = $this->authz->forUser($user)->inContextOf($context);
131 7
    }
132
133
    /**
134
     * Get user and context from session, loading objects from storage.
135
     *
136
     * @return array{user:User|null,context:Context|null,timestamp:\DateTimeInterface|null}
137
     */
138 7
    protected function getInfoFromSession()
139
    {
140 7
        $partial = false;
141
142 7
        $info = $this->session->getInfo();
143 7
        ['user' => $uid, 'context' => $cid, 'checksum' => $checksum, 'timestamp' => $timestamp] = $info;
144
145 7
        if ($uid === null || $uid instanceof User) {
146 2
            $user = $uid;
147
        } else {
148 5
            if (substr($uid, 0, 9) === '#partial:') {
149 1
                $partial = true;
150 1
                $uid = substr($uid, 9);
151
            }
152 5
            $user = $this->storage->fetchUserById($uid);
153
        }
154
155 7
        if ($user === null) {
156 1
            return ['user' => null, 'context' => null, 'timestamp' => null];
157
        }
158
159 6
        if ($user->getAuthChecksum() !== (string)$checksum) {
160 1
            $authId = $user->getAuthId();
161 1
            $this->logger->notice("Ignoring auth info from session: invalid checksum", ['user' => $authId]);
162
163 1
            return ['user' => null, 'context' => null, 'timestamp' => null];
164
        }
165
166 5
        $context = $cid !== null
167 2
            ? ($cid instanceof Context ? $cid : $this->storage->fetchContext($cid))
168 5
            : (!$partial ? $this->storage->getContextForUser($user) : null);
169
170 5
        if ($partial) {
171 1
            $user = new PartiallyLoggedIn($user);
172
        }
173
174 5
        return ['user' => $user, 'context' => $context, 'timestamp' => $timestamp];
175
    }
176
177
    /**
178
     * Is the service is initialized?
179
     */
180 38
    public function isInitialized(): bool
181
    {
182 38
        return isset($this->session);
183
    }
184
185
    /**
186
     * Throw an exception if the service hasn't been initialized yet.
187
     *
188
     * @throws \LogicException
189
     */
190 30
    protected function assertInitialized(): void
191
    {
192 30
        if (!$this->isInitialized()) {
193 3
            throw new \LogicException("Auth needs to be initialized before use");
194
        }
195 27
    }
196
197
198
    /**
199
     * Get all available authorization roles (for the current context).
200
     *
201
     * @return string[]
202
     */
203 1
    final public function getAvailableRoles(): array
204
    {
205 1
        return $this->authz->getAvailableRoles();
206
    }
207
208
209
    /**
210
     * Check if the current user is logged in.
211
     */
212 1
    final public function isLoggedIn(): bool
213
    {
214 1
        $this->assertInitialized();
215 1
        return $this->authz->isLoggedIn();
216
    }
217
218
    /**
219
     * Check if the current user is partially logged in.
220
     * Typically this means MFA verification is required.
221
     */
222 1
    final public function isPartiallyLoggedIn(): bool
223
    {
224 1
        $this->assertInitialized();
225 1
        return $this->authz->isPartiallyLoggedIn();
226
    }
227
228
    /**
229
     * Check if the current user is not logged in or partially logged in.
230
     */
231 6
    final public function isLoggedOut(): bool
232
    {
233 6
        $this->assertInitialized();
234 6
        return $this->authz->isLoggedOut();
235
    }
236
237
    /**
238
     * Check if the current user is logged in and has specified role.
239
     *
240
     * <code>
241
     *   if (!$auth->is('manager')) {
242
     *     http_response_code(403); // Forbidden
243
     *     echo "You are not allowed to view this page";
244
     *     exit();
245
     *   }
246
     * </code>
247
     */
248 2
    final public function is(string $role): bool
249
    {
250 2
        $this->assertInitialized();
251 1
        return $this->authz->is($role);
252
    }
253
254
    /**
255
     * Get current authenticated user.
256
     *
257
     * @throws AuthException if no user is logged in.
258
     */
259 6
    final public function user(): User
260
    {
261 6
        $this->assertInitialized();
262 5
        return $this->authz->user();
263
    }
264
265
    /**
266
     * Get the current context.
267
     */
268 2
    final public function context(): ?Context
269
    {
270 2
        $this->assertInitialized();
271 1
        return $this->authz->context();
272
    }
273
274
    /**
275
     * Get the login timestamp.
276
     */
277 5
    public function time(): ?\DateTimeInterface
278
    {
279 5
        return $this->timestamp;
280
    }
281
282
283
    /**
284
     * Set the current user.
285
     *
286
     * @throws LoginException
287
     */
288 8
    public function loginAs(User $user): void
289
    {
290 8
        $this->assertInitialized();
291
292 8
        if ($this->authz->isLoggedIn()) {
293 1
            throw new \LogicException("Already logged in");
294
        }
295
296 7
        $this->loginUser($user);
297 5
    }
298
299
    /**
300
     * Login with username and password.
301
     *
302
     * @throws LoginException
303
     */
304 5
    public function login(string $username, string $password): void
305
    {
306 5
        $this->assertInitialized();
307
308 5
        if ($this->authz->isLoggedIn()) {
309 1
            throw new \LogicException("Already logged in");
310
        }
311
312 4
        $user = $this->storage->fetchUserByUsername($username);
313
314 4
        if ($user === null || !$user->verifyPassword($password)) {
315 2
            $this->logger->debug("Login failed: invalid credentials", ['username' => $username]);
316 2
            throw new LoginException('Invalid credentials', LoginException::INVALID_CREDENTIALS);
317
        }
318
319 2
        $this->loginUser($user);
320 2
    }
321
322
    /**
323
     * Set the current user and dispatch login event.
324
     *
325
     * @throws LoginException
326
     * @noinspection PhpDocMissingThrowsInspection
327
     */
328 9
    private function loginUser(User $user): void
329
    {
330 9
        if ($user->requiresMfa()) {
331 4
            $this->partialLoginUser($user);
332 3
            return;
333
        }
334
335 5
        $event = new Event\Login($this, $user);
336 5
        $this->dispatcher->dispatch($event);
337
338 5
        if ($event->isCancelled()) {
339 1
            $this->logger->info("Login failed: " . $event->getCancellationReason(), ['user' => $user->getAuthId()]);
340 1
            throw new LoginException($event->getCancellationReason(), LoginException::CANCELLED);
341
        }
342
343
        // Beware; the `authz` property may have been changed via the login event.
344 4
        $this->authz = $this->authz->forUser($user);
345
346 4
        if ($this->authz->context() === null) {
347 4
            $context = $this->storage->getContextForUser($user);
348 4
            $this->authz = $this->authz->inContextOf($context);
349
        }
350
351 4
        $this->timestamp = new \DateTimeImmutable();
352 4
        $this->updateSession();
353
354 4
        $this->logger->info("Login successful", ['user' => $user->getAuthId()]);
355 4
    }
356
357
    /**
358
     * Set the current user and dispatch login event.
359
     *
360
     * @throws LoginException
361
     * @noinspection PhpDocMissingThrowsInspection
362
     */
363 4
    private function partialLoginUser(User $user): void
364
    {
365 4
        $event = new Event\PartialLogin($this, $user);
366 4
        $this->dispatcher->dispatch($event);
367
368 4
        if ($event->isCancelled()) {
369 1
            $this->logger->info("Login failed: " . $event->getCancellationReason(), ['user' => $user->getAuthId()]);
370 1
            throw new LoginException($event->getCancellationReason(), LoginException::CANCELLED);
371
        }
372
373
        // Beware; the `authz` property may have been changed via the partial login event.
374 3
        $this->authz = $this->authz->forUser(new PartiallyLoggedIn($user));
375
376 3
        $this->timestamp = new \DateTimeImmutable();
377 3
        $this->updateSession();
378
379 3
        $this->logger->info("Partial login", ['user' => $user->getAuthId()]);
380 3
    }
381
382
    /**
383
     * MFA verification.
384
     */
385 5
    public function mfa(string $code): void
386
    {
387 5
        $this->assertInitialized();
388
389 5
        if ($this->isLoggedOut()) {
390 1
            throw new \RuntimeException("Unable to perform MFA verification: No user (partially) logged in");
391
        }
392
393 4
        $authzUser = $this->user();
394 4
        $user = $authzUser instanceof PartiallyLoggedIn ? $authzUser->getUser() : $authzUser;
395
396 4
        $verified = ($this->verifyMFA)($user, $code);
397
398 4
        if (!$verified) {
399 2
            $this->logger->debug("MFA verification failed", ['user' => $user->getAuthId()]);
400 2
            throw new LoginException('Invalid MFA', LoginException::INVALID_CREDENTIALS);
401
        }
402
403 2
        $this->logger->debug("MFA verification successful", ['user' => $user->getAuthId()]);
404
405
        // Fully login partially logged in user.
406 2
        if ($user !== $authzUser) {
407 1
            $this->loginAs($user);
408
        }
409 2
    }
410
411
    /**
412
     * Logout current user.
413
     */
414 2
    public function logout(): void
415
    {
416 2
        $this->assertInitialized();
417
418 2
        if (!$this->authz()->isLoggedIn()) {
419 1
            return;
420
        }
421
422 1
        $user = $this->authz->user();
423
424 1
        $this->authz = $this->authz->forUser(null)->inContextOf(null);
425 1
        $this->updateSession();
426
427 1
        $this->logger->debug("Logout", ['user' => $user->getAuthId()]);
428 1
        $this->dispatcher->dispatch(new Event\Logout($this, $user));
429 1
    }
430
431
    /**
432
     * Set the current context.
433
     */
434 2
    public function setContext(?Context $context): void
435
    {
436 2
        $this->assertInitialized();
437
438 2
        $this->authz = $this->authz->inContextOf($context);
439 2
        $this->updateSession();
440 2
    }
441
442
    /**
443
     * Recalculate authz roles for current user and context.
444
     * Store the current auth information in the session.
445
     *
446
     * @return $this
447
     */
448 2
    public function recalc(): self
449
    {
450 2
        $this->authz = $this->authz->recalc();
451 2
        $this->updateSession();
452
453 2
        return $this;
454
    }
455
456
    /**
457
     * Store the current auth information in the session.
458
     */
459 12
    protected function updateSession(): void
460
    {
461 12
        if ($this->authz->isLoggedOut()) {
462 2
            $this->timestamp = null;
463 2
            $this->session->clear();
464
465 2
            return;
466
        }
467
468 10
        $user = $this->authz->user();
469 10
        $context = $this->authz->context();
470
471 10
        $uid = $user->getAuthId();
472 10
        $cid = $context !== null ? $context->getAuthId() : null;
473 10
        $checksum = $user->getAuthChecksum();
474
475 10
        $this->session->persist($uid, $cid, $checksum, $this->timestamp);
476 10
    }
477
478
479
    /**
480
     * Return read-only service for authorization of the current user and context.
481
     */
482 21
    public function authz(): Authz
483
    {
484 21
        return $this->authz;
485
    }
486
487
    /**
488
     * Return read-only service for authorization of the specified user.
489
     */
490 1
    public function forUser(?User $user): Authz
491
    {
492 1
        return $this->authz->forUser($user);
493
    }
494
495
    /**
496
     * Get an authz service for the given context.
497
     */
498 1
    public function inContextOf(?Context $context): Authz
499
    {
500 1
        return $this->authz->inContextOf($context);
501
    }
502
503
504
    /**
505
     * Get service to create or validate confirmation token.
506
     */
507 1
    public function confirm(string $subject): Confirmation
508
    {
509 1
        return $this->confirmation
510 1
            ->withStorage($this->storage)
511 1
            ->withLogger($this->logger)
512 1
            ->withSubject($subject);
513
    }
514
515
516
    /**
517
     * Create an event dispatcher as null object.
518
     * @codeCoverageIgnore
519
     */
520
    private static function dummyDispatcher(): EventDispatcher
521
    {
522
        return new class () implements EventDispatcher {
523
            /** @inheritDoc */
524
            public function dispatch(object $event): object
525
            {
526
                return $event;
527
            }
528
        };
529
    }
530
}
531