Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
10 / 10
CRAP
100.00% covered (success)
100.00%
1 / 1
SecurityUser
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
10 / 10
11
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fromDomain
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 userId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 preferredCurrency
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserIdentifier
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getPassword
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isAdmin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isSuspended
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRoles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 eraseCredentials
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace App\Identity\Infrastructure\Security;
6
7use App\Identity\Domain\User;
8use App\Identity\Domain\ValueObject\UserStatus;
9use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
10use Symfony\Component\Security\Core\User\UserInterface;
11
12use function assert;
13
14/**
15 * Security adapter over the Identity domain User aggregate. Keeps the Symfony
16 * Security contracts out of the domain: the aggregate stays framework-agnostic
17 * and this object is built from it only at the authentication boundary.
18 */
19final class SecurityUser implements UserInterface, PasswordAuthenticatedUserInterface
20{
21    public function __construct(
22        private readonly string $userId,
23        private readonly string $email,
24        private readonly ?string $hashedPassword,
25        private readonly string $preferredCurrency = 'EUR',
26        private readonly bool $admin = false,
27        private readonly bool $suspended = false,
28    ) {
29    }
30
31    public static function fromDomain(User $user): self
32    {
33        return new self(
34            $user->id()->value(),
35            $user->email()->value(),
36            $user->password()?->value(),
37            $user->preferredCurrency()->code(),
38            $user->isAdmin(),
39            $user->status() === UserStatus::SUSPENDED,
40        );
41    }
42
43    public function userId(): string
44    {
45        return $this->userId;
46    }
47
48    public function preferredCurrency(): string
49    {
50        return $this->preferredCurrency;
51    }
52
53    public function getUserIdentifier(): string
54    {
55        // Email is a validated value object, never empty.
56        assert($this->email !== '');
57
58        return $this->email;
59    }
60
61    /** Null for a provider-only account: Symfony refuses password login on it. */
62    public function getPassword(): ?string
63    {
64        return $this->hashedPassword;
65    }
66
67    public function isAdmin(): bool
68    {
69        return $this->admin;
70    }
71
72    public function isSuspended(): bool
73    {
74        return $this->suspended;
75    }
76
77    /**
78     * The only place the admin bit becomes a role. The firewall is stateless, so the
79     * provider reloads the user on every request and lexik re-reads these roles into
80     * the JWT — a revoked admin loses ROLE_ADMIN on their next request, not at expiry.
81     *
82     * @return list<string>
83     */
84    public function getRoles(): array
85    {
86        return $this->admin ? ['ROLE_USER', 'ROLE_ADMIN'] : ['ROLE_USER'];
87    }
88
89    public function eraseCredentials(): void
90    {
91        // No plaintext credentials held.
92    }
93}