Passed
Push — master ( c797f8...2e75f9 )
by Arnold
08:27
created

Auth::outOfContext()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 1
c 0
b 0
f 0
dl 0
loc 3
ccs 2
cts 2
cp 1
rs 10
cc 1
nc 1
nop 0
crap 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 47
    public function __construct(Authz $authz, Storage $storage, ?Confirmation $confirmation = null)
57
    {
58 47
        $this->authz = $authz;
59 47
        $this->storage = $storage;
60 47
        $this->confirmation = $confirmation ?? new NoConfirmation();
61
62
        // Set default services
63 47
        $this->dispatcher = self::dummyDispatcher();
64 47
        $this->logger = new NullLogger();
65 47
        $this->verifyMFA = fn() => false;
66 47
    }
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 47
    public function withEventDispatcher(EventDispatcher $dispatcher): self
82
    {
83 47
        return $this->withProperty('dispatcher', $dispatcher);
84
    }
85
86
    /**
87
     * Get a copy with a logger.
88
     */
89 47
    public function withLogger(Logger $logger): self
90
    {
91 47
        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()
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 39
    public function isInitialized(): bool
181
    {
182 39
        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 31
    protected function assertInitialized(): void
191
    {
192 31
        if (!$this->isInitialized()) {
193 3
            throw new \LogicException("Auth needs to be initialized before use");
194
        }
195 28
    }
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 7
    public function time(): ?\DateTimeInterface
278
    {
279 7
        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
     * @noinspection PhpDocMissingThrowsInspection
337
     */
338 6
    private function loginUser(User $user): void
339
    {
340 6
        $event = new Event\Login($this, $user);
341 6
        $this->dispatcher->dispatch($event);
342
343 6
        if ($event->isCancelled()) {
344 2
            if ($this->isPartiallyLoggedIn()) {
345 1
                $this->authz = $this->authz->forUser(null)->inContextOf(null);
346 1
                $this->timestamp = null;
347 1
                $this->updateSession();
348
            }
349
350 2
            $this->logger->info("Login failed: " . $event->getCancellationReason(), ['user' => $user->getAuthId()]);
351 2
            throw new LoginException($event->getCancellationReason(), LoginException::CANCELLED);
352
        }
353
354
        // Beware; the `authz` property may have been changed via the login event.
355 4
        $this->authz = $this->authz->forUser($user);
356
357 4
        if ($this->authz->context() === null) {
358 4
            $context = $this->storage->getContextForUser($user);
359 4
            $this->authz = $this->authz->inContextOf($context);
360
        }
361
362 4
        $this->timestamp = new \DateTimeImmutable();
363 4
        $this->updateSession();
364
365 4
        $this->logger->info("Login successful", ['user' => $user->getAuthId()]);
366 4
    }
367
368
    /**
369
     * Set the current user and dispatch login event.
370
     *
371
     * @throws LoginException
372
     * @noinspection PhpDocMissingThrowsInspection
373
     */
374 4
    private function partialLoginUser(User $user): void
375
    {
376 4
        $event = new Event\PartialLogin($this, $user);
377 4
        $this->dispatcher->dispatch($event);
378
379 4
        if ($event->isCancelled()) {
380 1
            $this->logger->info("Login failed: " . $event->getCancellationReason(), ['user' => $user->getAuthId()]);
381 1
            throw new LoginException($event->getCancellationReason(), LoginException::CANCELLED);
382
        }
383
384
        // Beware; the `authz` property may have been changed via the partial login event.
385 3
        $this->authz = $this->authz->forUser(new PartiallyLoggedIn($user));
386 3
        $this->timestamp = new \DateTimeImmutable();
387 3
        $this->updateSession();
388
389 3
        $this->logger->info("Partial login", ['user' => $user->getAuthId()]);
390 3
    }
391
392
    /**
393
     * MFA verification.
394
     */
395 6
    public function mfa(string $code): void
396
    {
397 6
        $this->assertInitialized();
398
399 6
        if ($this->isLoggedOut()) {
400 1
            throw new \RuntimeException("Unable to perform MFA verification: No user (partially) logged in");
401
        }
402
403 5
        $authzUser = $this->user();
404 5
        $user = $authzUser instanceof PartiallyLoggedIn ? $authzUser->getUser() : $authzUser;
405
406 5
        $verified = ($this->verifyMFA)($user, $code);
407
408 5
        if (!$verified) {
409 2
            $this->logger->debug("MFA verification failed", ['user' => $user->getAuthId()]);
410 2
            throw new LoginException('Invalid MFA', LoginException::INVALID_CREDENTIALS);
411
        }
412
413 3
        $this->logger->debug("MFA verification successful", ['user' => $user->getAuthId()]);
414
415
        // Fully login partially logged in user.
416 3
        if ($user !== $authzUser) {
417 2
            $this->loginUser($user);
418
        }
419 2
    }
420
421
    /**
422
     * Logout current user.
423
     */
424 2
    public function logout(): void
425
    {
426 2
        $this->assertInitialized();
427
428 2
        if (!$this->authz()->isLoggedIn()) {
429 1
            return;
430
        }
431
432 1
        $user = $this->authz->user();
433
434 1
        $this->authz = $this->authz->forUser(null)->inContextOf(null);
435 1
        $this->timestamp = null;
436
437 1
        $this->updateSession();
438
439 1
        $this->logger->debug("Logout", ['user' => $user->getAuthId()]);
440 1
        $this->dispatcher->dispatch(new Event\Logout($this, $user));
441 1
    }
442
443
    /**
444
     * Set the current context.
445
     */
446 2
    public function setContext(?Context $context): void
447
    {
448 2
        $this->assertInitialized();
449
450 2
        $this->authz = $this->authz->inContextOf($context);
451 2
        $this->updateSession();
452 2
    }
453
454
    /**
455
     * Recalculate authz roles for current user and context.
456
     * Store the current auth information in the session.
457
     *
458
     * @return $this
459
     */
460 2
    public function recalc(): self
461
    {
462 2
        $this->authz = $this->authz->recalc();
463 2
        $this->updateSession();
464
465 2
        return $this;
466
    }
467
468
    /**
469
     * Store the current auth information in the session.
470
     */
471 13
    protected function updateSession(): void
472
    {
473 13
        if ($this->authz->isLoggedOut()) {
474 3
            $this->timestamp = null;
475 3
            $this->session->clear();
476
477 3
            return;
478
        }
479
480 10
        $user = $this->authz->user();
481 10
        $context = $this->authz->context();
482
483 10
        $uid = $user->getAuthId();
484 10
        $cid = $context !== null ? $context->getAuthId() : null;
485 10
        $checksum = $user->getAuthChecksum();
486
487 10
        $this->session->persist($uid, $cid, $checksum, $this->timestamp);
488 10
    }
489
490
491
    /**
492
     * Return read-only service for authorization of the current user and context.
493
     */
494 23
    public function authz(): Authz
495
    {
496 23
        return $this->authz;
497
    }
498
499
    /**
500
     * Return read-only service for authorization of the specified user.
501
     */
502 1
    public function forUser(?User $user): Authz
503
    {
504 1
        return $this->authz->forUser($user);
505
    }
506
507
    /**
508
     * Get an authz service for the given context.
509
     */
510 2
    public function inContextOf(?Context $context): Authz
511
    {
512 2
        return $this->authz->inContextOf($context);
513
    }
514
515
    /**
516
     * Alias of `inContextOf(null)`.
517
     */
518 1
    final public function outOfContext(): Authz
519
    {
520 1
        return $this->inContextOf(null);
521
    }
522
523
524
    /**
525
     * Get service to create or validate confirmation token.
526
     */
527 1
    public function confirm(string $subject): Confirmation
528
    {
529 1
        return $this->confirmation
530 1
            ->withStorage($this->storage)
531 1
            ->withLogger($this->logger)
532 1
            ->withSubject($subject);
533
    }
534
535
536
    /**
537
     * Create an event dispatcher as null object.
538
     * @codeCoverageIgnore
539
     */
540
    private static function dummyDispatcher(): EventDispatcher
541
    {
542
        return new class () implements EventDispatcher {
543
            /** @inheritDoc */
544
            public function dispatch(object $event): object
545
            {
546
                return $event;
547
            }
548
        };
549
    }
550
}
551