anonymous//src/Auth.php$0   A
last analyzed

Complexity

Total Complexity 1

Size/Duplication

Total Lines 5
Duplicated Lines 0 %

Test Coverage

Coverage 0%

Importance

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