Lines
100.00%
82 / 82
Methods
100.00%
21 / 21
Classes
100.00%
1 / 1
| 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 | ||
| 26 | class 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 | } |