Lines 100.00% 82 / 82
Methods 100.00% 21 / 21
Classes 100.00% 1 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 __serialize 100.00% 6 / 6 100.00% 1 / 1 1
 __unserialize 100.00% 6 / 6 100.00% 1 / 1 3
 getPage 100.00% 1 / 1 100.00% 1 / 1 1
 setPage 100.00% 2 / 2 100.00% 1 / 1 1
 getExpiry 100.00% 1 / 1 100.00% 1 / 1 1
 setExpiry 100.00% 2 / 2 100.00% 1 / 1 1
 getSessionToken 100.00% 1 / 1 100.00% 1 / 1 1
 setSessionToken 100.00% 2 / 2 100.00% 1 / 1 1
 getCookieToken 100.00% 1 / 1 100.00% 1 / 1 1
 setCookieToken 100.00% 2 / 2 100.00% 1 / 1 1
 getInstance 100.00% 3 / 3 100.00% 1 / 1 2
 getTokenInput 100.00% 7 / 7 100.00% 1 / 1 1
 getTokenString 100.00% 2 / 2 100.00% 1 / 1 1
 verifyToken 100.00% 15 / 15 100.00% 1 / 1 9
 removeToken 100.00% 3 / 3 100.00% 1 / 1 1
 getSession 100.00% 7 / 7 100.00% 1 / 1 3
 getCookie 100.00% 1 / 1 100.00% 1 / 1 1
 setSession 100.00% 17 / 17 100.00% 1 / 1 1
 getCookieName 100.00% 1 / 1 100.00% 1 / 1 1
 resetInstanceForTests 100.00% 1 / 1 100.00% 1 / 1 1
26class Token
27{
28    final public const string PMF_SESSION_NAME = 'pmf-csrf-token';
29
30    private const int PMF_SESSION_EXPIRY = PMF_AUTH_TIMEOUT * 60;
31
32    private string $page;
33
34    private int $expiry = 0;
35
36    private ?string $sessionToken = null;
37
38    private ?string $cookieToken = null;
39
40    private static ?Token $token = null;
41
42    /**
43     * Constructor.
44     */
45    final private function __construct(
46        private readonly SessionInterface $session,
47    ) {
48    }
49
50    /**
51     * Only the token data is persisted in the session. The SessionInterface
52     * reference is deliberately excluded: serialising it (e.g. when PHP
53     * re-serialises $_SESSION during session_regenerate_id()) would drag in the
54     * whole session object and could corrupt the stored token, dropping the
55     * CSRF token. A persisted token never needs its own session back-reference.
56     *
57     * @return array<string, mixed>
58     */
59    public function __serialize(): array
60    {
61        return [
62            'page' => $this->page ?? '',
63            'expiry' => $this->expiry,
64            'sessionToken' => $this->sessionToken,
65            'cookieToken' => $this->cookieToken,
66        ];
67    }
68
69    /**
70     * @param array<string, mixed> $data
71     */
72    public function __unserialize(array $data): void
73    {
74        $this->page = (string) ($data['page'] ?? '');
75        $this->expiry = (int) ($data['expiry'] ?? 0);
76        $sessionToken = $data['sessionToken'] ?? null;
77        $this->sessionToken = is_string($sessionToken) ? $sessionToken : null;
78        $cookieToken = $data['cookieToken'] ?? null;
79        $this->cookieToken = is_string($cookieToken) ? $cookieToken : null;
80    }
81
82    public function getPage(): string
83    {
84        return $this->page;
85    }
86
87    public function setPage(string $page): Token
88    {
89        $this->page = $page;
90        return $this;
91    }
92
93    public function getExpiry(): int
94    {
95        return $this->expiry;
96    }
97
98    public function setExpiry(int $expiry): Token
99    {
100        $this->expiry = $expiry;
101        return $this;
102    }
103
104    public function getSessionToken(): string
105    {
106        return $this->sessionToken ?? '';
107    }
108
109    public function setSessionToken(#[\SensitiveParameter] string $sessionToken): Token
110    {
111        $this->sessionToken = $sessionToken;
112        return $this;
113    }
114
115    public function getCookieToken(): ?string
116    {
117        return $this->cookieToken;
118    }
119
120    public function setCookieToken(#[\SensitiveParameter] string $cookieToken): Token
121    {
122        $this->cookieToken = $cookieToken;
123        return $this;
124    }
125
126    /**
127     * @throws Exception
128     */
129    public static function getInstance(SessionInterface $session): Token
130    {
131        if (!self::$token instanceof Token) {
132            self::$token = new self($session);
133        }
134
135        return self::$token;
136    }
137
138    /**
139     * @throws Exception
140     */
141    public function getTokenInput(string $page, int $expiry = self::PMF_SESSION_EXPIRY): string
142    {
143        $token = $this->getSession($page) ?? $this->setSession($page, $expiry);
144
145        return sprintf(
146            '<input type="hidden" id="%s" name="%s" value="%s">',
147            self::PMF_SESSION_NAME,
148            self::PMF_SESSION_NAME,
149            $token->sessionToken,
150        );
151    }
152
153    /**
154     * @throws Exception
155     */
156    public function getTokenString(string $page, int $expiry = self::PMF_SESSION_EXPIRY): string
157    {
158        $token = $this->getSession($page) ?? $this->setSession($page, $expiry);
159
160        return $token->sessionToken ?? '';
161    }
162
163    public function verifyToken(
164        string $page,
165        #[\SensitiveParameter]
166        ?string $requestToken = null,
167        #[\SensitiveParameter]
168        bool $removeToken = false,
169    ): bool {
170        if ($requestToken === null) {
171            $postedToken = Request::createFromGlobals()->request->get(self::PMF_SESSION_NAME);
172            $requestToken = $postedToken === null ? null : (string) $postedToken;
173        }
174
175        if (is_null($requestToken)) {
176            return false;
177        }
178
179        $token = $this->getSession($page);
180
181        // if the time is greater than the expiry form submission window
182        if (!$token instanceof Token || time() > $token->getExpiry()) {
183            $this->removeToken($page);
184            return false;
185        }
186
187        // check the hash matches the Session / Cookie
188        $sessionConfirm = hash_equals($token->getSessionToken(), $requestToken);
189        // A token without a cookie counterpart is malformed state - fail closed
190        $storedCookieToken = $token->getCookieToken();
191        $cookieConfirm = $storedCookieToken !== null && hash_equals($storedCookieToken, $this->getCookie($page));
192
193        // remove the token
194        if ($removeToken) {
195            $this->removeToken($page);
196        }
197
198        // both session and cookie match
199        return $sessionConfirm && $cookieConfirm;
200    }
201
202    public function removeToken(string $page): bool
203    {
204        Request::createFromGlobals()->cookies->remove($this->getCookieName($page));
205        $this->session->remove(sprintf('%s.%s', self::PMF_SESSION_NAME, $page));
206
207        return true;
208    }
209
210    private function getSession(string $page): ?Token
211    {
212        $token = $this->session->get(sprintf('%s.%s', self::PMF_SESSION_NAME, $page));
213
214        // Treat a missing or corrupted (non-Token) value as absent so callers
215        // regenerate a fresh token. A stale value that fails to deserialize
216        // cleanly would otherwise be returned and never replaced, permanently
217        // breaking CSRF verification for that page.
218        if (!$token instanceof self) {
219            return null;
220        }
221
222        // Treat an expired token as absent so callers regenerate a fresh one
223        // instead of rendering a dead token that would fail verification.
224        if (time() > $token->getExpiry()) {
225            $this->removeToken($page);
226
227            return null;
228        }
229
230        return $token;
231    }
232
233    private function getCookie(string $page): string
234    {
235        return Request::createFromGlobals()->cookies->get($this->getCookieName($page), '');
236    }
237
238    /**
239     * @throws Exception
240     */
241    private function setSession(string $page, int $expiry): Token
242    {
243        $request = Request::createFromGlobals();
244        $randomToken = bin2hex(random_bytes(32));
245        $token = new self($this->session);
246        $token
247            ->setPage($page)
248            ->setExpiry(time() + $expiry)
249            ->setSessionToken($randomToken)
250            ->setCookieToken($randomToken);
251
252        setcookie($token->getCookieName($page), (string) $token->getCookieToken(), [
253            'expires' => $token->getExpiry(),
254            'path' => dirname((string) $request->server->get('SCRIPT_NAME')),
255            'samesite' => 'strict',
256            'secure' => $request->isSecure(),
257            'httponly' => true,
258        ]);
259
260        $this->session->set(sprintf('%s.%s', self::PMF_SESSION_NAME, $page), $token);
261
262        return $token;
263    }
264
265    private function getCookieName(string $page): string
266    {
267        return sprintf('%s-%s', self::PMF_SESSION_NAME, substr(string: md5($page), offset: 0, length: 10));
268    }
269
270    public static function resetInstanceForTests(): void
271    {
272        self::$token = null;
273    }
274}