diff --git a/app/src/AdminAuth.php b/app/src/AdminAuth.php index 2ea380c..7443995 100644 --- a/app/src/AdminAuth.php +++ b/app/src/AdminAuth.php @@ -165,6 +165,46 @@ class AdminAuth $db->setSetting('admin_password_hash', ''); } + /** + * Issue a single-use password-reset token (plaintext, to be emailed). + * + * Uses the shared OneTimeToken model (purpose 'password_reset'). + * + * @param int $ttlSeconds Lifetime in seconds (default 30 minutes). + * @return string The plaintext token to send to the admin. + */ + public static function issuePasswordResetToken(int $ttlSeconds = 1800): string + { + require_once APP_ROOT . '/src/OneTimeToken.php'; + $db = new Database(); + $ot = new OneTimeToken($db->getPDO()); + return $ot->issue('password_reset', $ttlSeconds); + } + + /** + * Redeem a password-reset token and return the new password hash. + * + * On success, the token is consumed (one-time) and a fresh bcrypt hash + * is generated for the given new password — but NOT yet persisted; the + * caller decides when to store it (so the reset flow can validate before + * committing). + * + * @return string|null bcrypt hash, or null when the token is invalid. + */ + public static function redeemPasswordResetToken(string $token, string $newPassword): ?string + { + require_once APP_ROOT . '/src/OneTimeToken.php'; + $db = new Database(); + $ot = new OneTimeToken($db->getPDO()); + + $context = $ot->redeem('password_reset', $token); + if ($context === null) { + return null; + } + + return password_hash($newPassword, PASSWORD_BCRYPT, ['cost' => 12]); + } + /** * Check whether the current request is authenticated (without redirecting). */ diff --git a/app/src/DatabaseMigrations.php b/app/src/DatabaseMigrations.php index 9df878e..2072ae8 100644 --- a/app/src/DatabaseMigrations.php +++ b/app/src/DatabaseMigrations.php @@ -26,6 +26,41 @@ class DatabaseMigrations $this->migrateShareLinksLockedYearColumn(); $this->migrateThesisLanguagesIndex(); $this->migrateTagsDeletedNameIndex(); + $this->migrateOneTimeTokensTable(); + } + + /** + * Create the generic one_time_tokens table (idempotent). + * + * Backs the OneTimeToken model shared by password-reset and other + * single-use token features. Mirrors app/storage/schema.sql. + */ + private function migrateOneTimeTokensTable(): void + { + try { + $this->pdo->exec( + 'CREATE TABLE IF NOT EXISTS one_time_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + purpose TEXT NOT NULL, + token_hash TEXT NOT NULL, + context TEXT, + expires_at DATETIME NOT NULL, + is_valid INTEGER NOT NULL DEFAULT 1, + used_at DATETIME DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )' + ); + $this->pdo->exec( + 'CREATE INDEX IF NOT EXISTS idx_one_time_tokens_purpose_hash + ON one_time_tokens(purpose, token_hash)' + ); + $this->pdo->exec( + 'CREATE INDEX IF NOT EXISTS idx_one_time_tokens_expires_at + ON one_time_tokens(expires_at)' + ); + } catch (\PDOException $e) { + // best-effort on boot; ignore if already present + } } /** diff --git a/app/src/OneTimeToken.php b/app/src/OneTimeToken.php new file mode 100644 index 0000000..eac946e --- /dev/null +++ b/app/src/OneTimeToken.php @@ -0,0 +1,110 @@ +pdo->prepare( + 'INSERT INTO ' . self::TABLE . ' (purpose, token_hash, context, expires_at, is_valid, created_at) + VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP)' + ); + $stmt->execute([$purpose, $hash, $contextJson, $expiresAt]); + + return $plain; + } + + /** + * Whether a token is currently redeemable (unused, unexpired). + */ + public function isValid(string $purpose, string $token): bool + { + $row = $this->findValid($purpose, $token); + return $row !== null; + } + + /** + * Redeem a token, marking it used. Returns the stored context (or null). + * Returns null when the token is unknown, expired, or already used. + * + * @return mixed|null + */ + public function redeem(string $purpose, string $token): mixed + { + $row = $this->findValid($purpose, $token); + if ($row === null) { + return null; + } + + $stmt = $this->pdo->prepare( + 'UPDATE ' . self::TABLE . ' SET used_at = CURRENT_TIMESTAMP, is_valid = 0 WHERE id = ?' + ); + $stmt->execute([(int) $row['id']]); + + if ($row['context'] === null || $row['context'] === '') { + return null; + } + return json_decode($row['context'], true); + } + + /** + * Look up a valid (unused, unexpired) token row by purpose + hash. + */ + private function findValid(string $purpose, string $token): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT id, token_hash, context, expires_at + FROM ' . self::TABLE . ' + WHERE purpose = ? + AND token_hash = ? + AND is_valid = 1 + AND used_at IS NULL + AND expires_at > CURRENT_TIMESTAMP + LIMIT 1' + ); + $stmt->execute([$purpose, self::hash($token)]); + $row = $stmt->fetch(); + return $row !== false ? $row : null; + } + + /** + * Constant-time-comparable token hash (SHA-256 hex). + */ + private static function hash(string $token): string + { + return hash('sha256', $token); + } +} diff --git a/app/storage/schema.sql b/app/storage/schema.sql index 56f7f1d..ad6b458 100644 --- a/app/storage/schema.sql +++ b/app/storage/schema.sql @@ -281,6 +281,25 @@ CREATE TABLE IF NOT EXISTS file_access_audit ( FOREIGN KEY (request_id) REFERENCES file_access_requests(id) ON DELETE CASCADE ); +-- Generic single-use, expiring tokens (password reset, etc.). +-- Only the SHA-256 hash of a token is stored — never the plaintext. +CREATE TABLE IF NOT EXISTS one_time_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + purpose TEXT NOT NULL, + token_hash TEXT NOT NULL, + context TEXT, + expires_at DATETIME NOT NULL, + is_valid INTEGER NOT NULL DEFAULT 1, + used_at DATETIME DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_one_time_tokens_purpose_hash + ON one_time_tokens(purpose, token_hash); + +CREATE INDEX IF NOT EXISTS idx_one_time_tokens_expires_at + ON one_time_tokens(expires_at); + CREATE TABLE IF NOT EXISTS form_help_blocks ( key TEXT PRIMARY KEY, content TEXT NOT NULL DEFAULT '',