Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
82 / 82
100.00% covered (success)
100.00%
21 / 21
CRAP
100.00% covered (success)
100.00%
1 / 1
Token
100.00% covered (success)
100.00%
82 / 82
100.00% covered (success)
100.00%
21 / 21
34
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
 __serialize
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 __unserialize
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setPage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getExpiry
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setExpiry
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getSessionToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSessionToken
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getCookieToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCookieToken
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getInstance
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getTokenInput
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getTokenString
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 verifyToken
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
9
 removeToken
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getSession
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getCookie
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSession
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 getCookieName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resetInstanceForTests
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Token class for CSRF (Cross Site Request Forgery) protection.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2023-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2023-02-19
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Session;
21
22use Exception;
23use Symfony\Component\HttpFoundation\Request;
24use Symfony\Component\HttpFoundation\Session\SessionInterface;
25
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}