diff --git a/services/nginx/app/objects/customer_password_reset_keys_o.php b/services/nginx/app/objects/customer_password_reset_keys_o.php index c2b413c9..c937b0a2 100644 --- a/services/nginx/app/objects/customer_password_reset_keys_o.php +++ b/services/nginx/app/objects/customer_password_reset_keys_o.php @@ -18,13 +18,18 @@ class customer_password_reset_keys_o extends db public object_property $updated_at; public object_property $deleted_at; const TOKEN_LENGTH = 32; - const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour + const TOKEN_EXPIRY_SECONDS = 72 * 60 * 60; // 72 hours public function structure(): void { $this->setTable('customer_password_reset_keys'); } + private function validTokenWhereClause(): string + { + return "deleted_at IS NULL AND created_at >= DATE_SUB(NOW(), INTERVAL " . self::TOKEN_EXPIRY_SECONDS . " SECOND)"; + } + /** * Add a new customer reset key @@ -65,9 +70,8 @@ class customer_password_reset_keys_o extends db if (strlen($token) !== self::TOKEN_LENGTH) { return null; } - // Query the database for a valid token - $current_time = date('Y-m-d H:i:s'); - $sql = "SELECT id FROM $this->table WHERE token = '" . $db->escape_string($token) . "' AND deleted_at IS NULL AND created_at >= DATE_SUB('$current_time', INTERVAL " . self::TOKEN_EXPIRY_SECONDS . " SECOND) LIMIT 1"; + // Query the database for a valid token using the same clock that writes created_at. + $sql = "SELECT id FROM $this->table WHERE token = '" . $db->escape_string($token) . "' AND " . $this->validTokenWhereClause() . " LIMIT 1"; $result = $db->query($sql); if ($result->num_rows === 0) { return null; @@ -85,13 +89,11 @@ class customer_password_reset_keys_o extends db */ public function isValidToken(): bool { + global $db; self::requireSelected(); - $created_at = strtotime($this->created_at->value()); - $current_time = time(); - return ( - ($current_time - $created_at) <= self::TOKEN_EXPIRY_SECONDS) && - ($this->deleted_at->value() === null - ); + $sql = "SELECT id FROM $this->table WHERE id = " . (int)$this->id . " AND " . $this->validTokenWhereClause() . " LIMIT 1"; + $result = $db->query($sql); + return $result->num_rows > 0; } /** @@ -140,4 +142,4 @@ class customer_password_reset_keys_o extends db { //TODO: Add cache invalidation } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/authRoute.php b/services/nginx/app/routes/authRoute.php index f6713372..8a6cd608 100644 --- a/services/nginx/app/routes/authRoute.php +++ b/services/nginx/app/routes/authRoute.php @@ -570,7 +570,8 @@ class authRoute $reset_link = "https://truckwash.io/auth/password-reset/" . $token; $subject = 'Adgangskode nulstilling'; - $message = "Du har anmodet om at nulstille din adgangskode. Klik på linket herunder for at fortsætte:

$reset_link

Linket er gyldigt i 1 time."; + $valid_hours = (int)(customer_password_reset_keys_o::TOKEN_EXPIRY_SECONDS / 3600); + $message = "Du har anmodet om at nulstille din adgangskode. Klik på linket herunder for at fortsætte:

$reset_link

Linket er gyldigt i $valid_hours timer."; try { $email->sendEmail($email_address, $user->display_name->value() ?? 'Kunde', $subject, $message, null); diff --git a/services/nginx/app/tests/Unit/Auth/PasswordResetTokenExpiryTest.php b/services/nginx/app/tests/Unit/Auth/PasswordResetTokenExpiryTest.php new file mode 100644 index 00000000..8f4434fd --- /dev/null +++ b/services/nginx/app/tests/Unit/Auth/PasswordResetTokenExpiryTest.php @@ -0,0 +1,105 @@ +num_rows = count($rows); + } + + public function fetch_assoc(): ?array + { + return $this->rows[0] ?? null; + } + } +} + +if (!class_exists('PasswordResetTokenExpiryFakeDb')) { + class PasswordResetTokenExpiryFakeDb + { + public array $queries = []; + + public function __construct(private readonly array $results) + { + } + + public function escape_string(string $string): string + { + return addslashes($string); + } + + public function query(string $sql): PasswordResetTokenExpiryFakeResult + { + $this->queries[] = $sql; + + return $this->results[count($this->queries) - 1] ?? new PasswordResetTokenExpiryFakeResult([]); + } + } +} + +if (!class_exists('PasswordResetTokenExpiryProbe')) { + class PasswordResetTokenExpiryProbe extends customer_password_reset_keys_o + { + public function getObjectProperties(): void + { + } + + public function forceSelectedId(int $id): void + { + $this->id = $id; + } + } +} + +beforeEach(function (): void { + $this->previousDb = $GLOBALS['db'] ?? null; +}); + +afterEach(function (): void { + if ($this->previousDb !== null) { + $GLOBALS['db'] = $this->previousDb; + return; + } + + unset($GLOBALS['db']); +}); + +it('keeps password reset tokens valid for 72 hours', function (): void { + expect(customer_password_reset_keys_o::TOKEN_EXPIRY_SECONDS)->toBe(72 * 60 * 60); +}); + +it('looks up reset tokens using the database 72 hour validity window', function (): void { + $GLOBALS['db'] = new PasswordResetTokenExpiryFakeDb([ + new PasswordResetTokenExpiryFakeResult([['id' => 42]]), + ]); + + $token = str_repeat('a', customer_password_reset_keys_o::TOKEN_LENGTH); + $probe = new PasswordResetTokenExpiryProbe(); + + $found = $probe->findValidByToken($token); + + expect($found)->toBe($probe) + ->and($probe->id)->toBe(42) + ->and($GLOBALS['db']->queries[0])->toContain('created_at >= DATE_SUB(NOW(), INTERVAL 259200 SECOND)') + ->and($GLOBALS['db']->queries[0])->not->toContain("DATE_SUB('"); +}); + +it('uses the same database 72 hour window for the selected token guard', function (): void { + $GLOBALS['db'] = new PasswordResetTokenExpiryFakeDb([ + new PasswordResetTokenExpiryFakeResult([['id' => 42]]), + ]); + + $probe = new PasswordResetTokenExpiryProbe(); + $probe->forceSelectedId(42); + + expect($probe->isValidToken())->toBeTrue() + ->and($GLOBALS['db']->queries[0])->toContain('id = 42') + ->and($GLOBALS['db']->queries[0])->toContain('created_at >= DATE_SUB(NOW(), INTERVAL 259200 SECOND)'); +});