mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
extract shared OneTimeToken model for single-use tokens + AdminAuth reset API
This commit is contained in:
@@ -165,6 +165,46 @@ class AdminAuth
|
|||||||
$db->setSetting('admin_password_hash', '');
|
$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).
|
* Check whether the current request is authenticated (without redirecting).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -26,6 +26,41 @@ class DatabaseMigrations
|
|||||||
$this->migrateShareLinksLockedYearColumn();
|
$this->migrateShareLinksLockedYearColumn();
|
||||||
$this->migrateThesisLanguagesIndex();
|
$this->migrateThesisLanguagesIndex();
|
||||||
$this->migrateTagsDeletedNameIndex();
|
$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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OneTimeToken — generic single-use, expiring token store.
|
||||||
|
*
|
||||||
|
* Extracted from the file-access email-link flow so the same, secure
|
||||||
|
* token lifecycle can be reused by other features (e.g. an admin
|
||||||
|
* password-reset link). A token is a 256-bit random value; only its
|
||||||
|
* SHA-256 hash is persisted, so a DB leak does not expose live tokens.
|
||||||
|
*
|
||||||
|
* Lifecycle:
|
||||||
|
* issue() → generate token, store hash + expiry, return plaintext (send it)
|
||||||
|
* isValid() → check a token exists, is unused and unexpired
|
||||||
|
* redeem() → mark a token used (one-time) after a successful redemption
|
||||||
|
*
|
||||||
|
* Tokens carry a `purpose` tag so distinct features can share one table
|
||||||
|
* without colliding (e.g. 'password_reset', 'share_link').
|
||||||
|
*/
|
||||||
|
class OneTimeToken
|
||||||
|
{
|
||||||
|
private const TABLE = 'one_time_tokens';
|
||||||
|
|
||||||
|
public function __construct(private PDO $pdo)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new token and return its plaintext value.
|
||||||
|
*
|
||||||
|
* @param string $purpose Feature tag, e.g. 'password_reset'.
|
||||||
|
* @param int $ttlSeconds Lifetime in seconds (default 1 hour).
|
||||||
|
* @param mixed $context Optional JSON-encodable payload (non-secret).
|
||||||
|
*/
|
||||||
|
public function issue(string $purpose, int $ttlSeconds = 3600, mixed $context = null): string
|
||||||
|
{
|
||||||
|
$plain = bin2hex(random_bytes(32)); // 256-bit
|
||||||
|
$hash = self::hash($plain);
|
||||||
|
$expiresAt = date('Y-m-d H:i:s', time() + $ttlSeconds);
|
||||||
|
$contextJson = $context !== null ? json_encode($context) : null;
|
||||||
|
|
||||||
|
$stmt = $this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -281,6 +281,25 @@ CREATE TABLE IF NOT EXISTS file_access_audit (
|
|||||||
FOREIGN KEY (request_id) REFERENCES file_access_requests(id) ON DELETE CASCADE
|
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 (
|
CREATE TABLE IF NOT EXISTS form_help_blocks (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
content TEXT NOT NULL DEFAULT '',
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
|||||||
Reference in New Issue
Block a user