Increase password reset token validity to 72 hours and update related email message

This commit is contained in:
Jeppe Bundgaard
2026-06-11 21:45:22 +02:00
parent 5027d0c919
commit 36b934e835
3 changed files with 120 additions and 12 deletions
@@ -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
}
}
}
+2 -1
View File
@@ -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:<br><br><a href='$reset_link'>$reset_link</a><br><br>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:<br><br><a href='$reset_link'>$reset_link</a><br><br>Linket er gyldigt i $valid_hours timer.";
try {
$email->sendEmail($email_address, $user->display_name->value() ?? 'Kunde', $subject, $message, null);
@@ -0,0 +1,105 @@
<?php
use objects\customer_password_reset_keys_o;
app_require('objects/customer_password_reset_keys_o.php');
if (!class_exists('PasswordResetTokenExpiryFakeResult')) {
class PasswordResetTokenExpiryFakeResult
{
public int $num_rows;
public function __construct(private readonly array $rows)
{
$this->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)');
});