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)');
+});