Passed
Push — master ( 05e56c...668476 )
by Arnold
02:29
created

Auth   A

Complexity

Total Complexity 2

Size/Duplication

Total Lines 525
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
wmc 2
eloc 162
c 3
b 0
f 0
dl 0
loc 525
ccs 189
cts 189
cp 1
rs 10

35 Methods

Rating   Name   Duplication   Size   Complexity  
A time() 0 3 1
A isLoggedOut() 0 4 1
A getAvailableRoles() 0 3 1
A withEventDispatcher() 0 3 1
A isLoggedIn() 0 4 1
A is() 0 4 1
B getInfoFromSession() 0 37 10
A withLogger() 0 3 1
A __construct() 0 10 1
A getLogger() 0 3 1
A forMultipleRequests() 0 3 1
A context() 0 4 1
A isPartiallyLoggedIn() 0 4 1
A initialize() 0 14 3
A user() 0 4 1
A isInitialized() 0 3 1
A assertInitialized() 0 4 2
A loginAs() 0 14 3
A login() 0 21 5
A withMfa() 0 3 1
A hp$0 ➔ dispatch() 0 3 1
dummyDispatcher() 0 7 ?
A hp$0 ➔ dummyDispatcher() 0 7 1
A confirm() 0 6 1
A mfa() 0 23 5
A outOfContext() 0 3 1
A authz() 0 3 1
A logout() 0 20 4
A recalc() 0 6 1
A forUser() 0 3 1
A updateSession() 0 17 3
A loginUser() 0 28 4
A partialLoginUser() 0 16 2
A inContextOf() 0 3 1
A setContext() 0 6 1
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 48
    public function __construct(Authz $authz, Storage $storage, ?Confirmation $confirmation = null)
57
    {
58 48
        $this->authz = $authz;
59 48
        $this->storage = $storage;
60 48
        $this->confirmation = $confirmation ?? new NoConfirmation();
61
62
        // Set default services
63 48
        $this->dispatcher = self::dummyDispatcher();
64 48
        $this->logger = new NullLogger();
65 48
        $this->verifyMfa = fn() => false;
66 48
    }
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 48
    public function withEventDispatcher(EventDispatcher $dispatcher): self
82
    {
83 48
        return $this->withProperty('dispatcher', $dispatcher);
84
    }
85
86
    /**
87
     * Get a copy with a logger.
88
     */
89 48
    public function withLogger(Logger $logger): self
90
    {
91 48
        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 6
    public function withMfa(callable $verify): self
109
    {
110 6
        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(): array
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 40
    public function isInitialized(): bool
181
    {
182 40
        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 32
    protected function assertInitialized(): void
191
    {
192 32
        if (!$this->isInitialized()) {
193 3
            throw new \LogicException("Auth needs to be initialized before use");
194
        }
195 29
    }
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 3
    final public function isPartiallyLoggedIn(): bool
223
    {
224 3
        $this->assertInitialized();
225 3
        return $this->authz->isPartiallyLoggedIn();
226
    }
227
228
    /**
229
     * Check if the current user is not logged in or partially logged in.
230
     */
231 7
    final public function isLoggedOut(): bool
232
    {
233 7
        $this->assertInitialized();
234 7
        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 7
    final public function user(): User
260
    {
261 7
        $this->assertInitialized();
262 6
        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 8
    public function time(): ?\DateTimeInterface
278
    {
279 8
        return $this->timestamp;
280
    }
281
282
283
    /**
284
     * Set the current user.
285
     *
286
     * @throws LoginException
287
     */
288 7
    public function loginAs(User $user): void
289
    {
290 7
        $this->assertInitialized();
291
292 7
        if ($this->authz->isLoggedIn()) {
293 1
            throw new \LogicException("Already logged in");
294
        }
295
296 6
        if ($user->requiresMfa()) {
297 3
            $this->partialLoginUser($user);
298 2
            return;
299
        }
300
301 3
        $this->loginUser($user);
302 2
    }
303
304
    /**
305
     * Login with username and password.
306
     *
307
     * @throws LoginException
308
     */
309 5
    public function login(string $username, string $password): void
310
    {
311 5
        $this->assertInitialized();
312
313 5
        if ($this->authz->isLoggedIn()) {
314 1
            throw new \LogicException("Already logged in");
315
        }
316
317 4
        $user = $this->storage->fetchUserByUsername($username);
318
319 4
        if ($user === null || !$user->verifyPassword($password)) {
320 2
            $this->logger->debug("Login failed: invalid credentials", ['username' => $username]);
321 2
            throw new LoginException('Invalid credentials', LoginException::INVALID_CREDENTIALS);
322
        }
323
324 2
        if ($user->requiresMfa()) {
325 1
            $this->partialLoginUser($user);
326 1
            return;
327
        }
328
329 1
        $this->loginUser($user);
330 1
    }
331
332
    /**
333
     * Set the current user and dispatch login event.
334
     *
335
     * @throws LoginException
336
     */
337 6
    private function loginUser(User $user): void
338
    {
339 6
        $event = new Event\Login($this, $user);
340 6
        $this->dispatcher->dispatch($event);
341
342 6
        if ($event->isCancelled()) {
343 2
            if ($this->isPartiallyLoggedIn()) {
344 1
                $this->authz = $this->authz->forUser(null)->inContextOf(null);
345 1
                $this->timestamp = null;
346 1
                $this->updateSession();
347
            }
348
349 2
            $this->logger->info("Login failed: " . $event->getCancellationReason(), ['user' => $user->getAuthId()]);
350 2
            throw new LoginException($event->getCancellationReason(), LoginException::CANCELLED);
351
        }
352
353
        // Beware; the `authz` property may have been changed via the login event.
354 4
        $this->authz = $this->authz->forUser($user);
355
356 4
        if ($this->authz->context() === null) {
357 4
            $context = $this->storage->getContextForUser($user);
358 4
            $this->authz = $this->authz->inContextOf($context);
359
        }
360
361 4
        $this->timestamp = new \DateTimeImmutable();
362 4
        $this->updateSession();
363
364 4
        $this->logger->info("Login successful", ['user' => $user->getAuthId()]);
365 4
    }
366
367
    /**
368
     * Set the current user and dispatch login event.
369
     *
370
     * @throws LoginException
371
     */
372 4
    private function partialLoginUser(User $user): void
373
    {
374 4
        $event = new Event\PartialLogin($this, $user);
375 4
        $this->dispatcher->dispatch($event);
376
377 4
        if ($event->isCancelled()) {
378 1
            $this->logger->info("Login failed: " . $event->getCancellationReason(), ['user' => $user->getAuthId()]);
379 1
            throw new LoginException($event->getCancellationReason(), LoginException::CANCELLED);
380
        }
381
382
        // Beware; the `authz` property may have been changed via the partial login event.
383 3
        $this->authz = $this->authz->forUser(new PartiallyLoggedIn($user));
384 3
        $this->timestamp = new \DateTimeImmutable();
385 3
        $this->updateSession();
386
387 3
        $this->logger->info("Partial login", ['user' => $user->getAuthId()]);
388 3
    }
389
390
    /**
391
     * MFA verification.
392
     */
393 6
    public function mfa(string $code): void
394
    {
395 6
        $this->assertInitialized();
396
397 6
        if ($this->isLoggedOut()) {
398 1
            throw new \RuntimeException("Unable to perform MFA verification: No user (partially) logged in");
399
        }
400
401 5
        $authzUser = $this->user();
402 5
        $user = $authzUser instanceof PartiallyLoggedIn ? $authzUser->getUser() : $authzUser;
403
404 5
        $verified = ($this->verifyMfa)($user, $code);
405
406 5
        if (!$verified) {
407 2
            $this->logger->debug("MFA verification failed", ['user' => $user->getAuthId()]);
408 2
            throw new LoginException('Invalid MFA', LoginException::INVALID_CREDENTIALS);
409
        }
410
411 3
        $this->logger->debug("MFA verification successful", ['user' => $user->getAuthId()]);
412
413
        // Fully login partially logged in user.
414 3
        if ($user !== $authzUser) {
415 2
            $this->loginUser($user);
416
        }
417 2
    }
418
419
    /**
420
     * Logout current user.
421
     */
422 3
    public function logout(): void
423
    {
424 3
        $this->assertInitialized();
425
426 3
        $authz = $this->authz();
427
428 3
        if (!$authz->isLoggedIn() && !$authz->isPartiallyLoggedIn()) {
429 1
            return;
430
        }
431
432 2
        $this->authz = $this->authz->forUser(null)->inContextOf(null);
433 2
        $this->timestamp = null;
434
435 2
        $this->updateSession();
436
437 2
        if ($authz->isPartiallyLoggedIn()) {
438 1
            $this->logger->debug("Abort partial login", ['user' => $authz->user()->getAuthId()]);
439
        } else {
440 1
            $this->logger->debug("Logout", ['user' => $authz->user()->getAuthId()]);
441 1
            $this->dispatcher->dispatch(new Event\Logout($this, $authz->user()));
442
        }
443 2
    }
444
445
    /**
446
     * Set the current context.
447
     */
448 2
    public function setContext(?Context $context): void
449
    {
450 2
        $this->assertInitialized();
451
452 2
        $this->authz = $this->authz->inContextOf($context);
453 2
        $this->updateSession();
454 2
    }
455
456
    /**
457
     * Recalculate authz roles for current user and context.
458
     * Store the current auth information in the session.
459
     *
460
     * @return $this
461
     */
462 2
    public function recalc(): self
463
    {
464 2
        $this->authz = $this->authz->recalc();
465 2
        $this->updateSession();
466
467 2
        return $this;
468
    }
469
470
    /**
471
     * Store the current auth information in the session.
472
     */
473 14
    protected function updateSession(): void
474
    {
475 14
        if ($this->authz->isLoggedOut()) {
476 4
            $this->timestamp = null;
477 4
            $this->session->clear();
478
479 4
            return;
480
        }
481
482 10
        $user = $this->authz->user();
483 10
        $context = $this->authz->context();
484
485 10
        $uid = $user->getAuthId();
486 10
        $cid = $context !== null ? $context->getAuthId() : null;
487 10
        $checksum = $user->getAuthChecksum();
488
489 10
        $this->session->persist($uid, $cid, $checksum, $this->timestamp);
490 10
    }
491
492
493
    /**
494
     * Return read-only service for authorization of the current user and context.
495
     */
496 24
    public function authz(): Authz
497
    {
498 24
        return $this->authz;
499
    }
500
501
    /**
502
     * Return read-only service for authorization of the specified user.
503
     */
504 1
    public function forUser(?User $user): Authz
505
    {
506 1
        return $this->authz->forUser($user);
507
    }
508
509
    /**
510
     * Get an authz service for the given context.
511
     */
512 2
    public function inContextOf(?Context $context): Authz
513
    {
514 2
        return $this->authz->inContextOf($context);
515
    }
516
517
    /**
518
     * Alias of `inContextOf(null)`.
519
     */
520 1
    final public function outOfContext(): Authz
521
    {
522 1
        return $this->inContextOf(null);
523
    }
524
525
526
    /**
527
     * Get service to create or validate confirmation token.
528
     */
529 1
    public function confirm(string $subject): Confirmation
530
    {
531 1
        return $this->confirmation
532 1
            ->withStorage($this->storage)
533 1
            ->withLogger($this->logger)
534 1
            ->withSubject($subject);
535
    }
536
537
538
    /**
539
     * Create an event dispatcher as null object.
540
     * @codeCoverageIgnore
541
     */
542
    private static function dummyDispatcher(): EventDispatcher
543
    {
544
        return new class () implements EventDispatcher {
545
            /** @inheritDoc */
546
            public function dispatch(object $event): object
547
            {
548
                return $event;
549
            }
550
        };
551
    }
552
}
553