Files
casadoc/public/userportal/api/_codes.php
T
2026-07-27 21:04:07 +03:00

84 lines
3.0 KiB
PHP

<?php
// Shared rules for the 6-digit codes used by e-mail verification and password reset.
// Both tables (auth_email_verifications, auth_password_resets) behave identically:
// sha256 of the code is stored, it lives 15 minutes, allows 5 attempts and may be
// re-sent at most once per 60 seconds.
const CODE_TTL_MINUTES = 15;
const CODE_MAX_ATTEMPTS = 5;
const CODE_RESEND_SECONDS = 60;
function generate_code(): string
{
return (string) random_int(100000, 999999);
}
/**
* Stores a fresh code, resetting attempts. Returns the plain code, or null when the
* previous one was issued less than CODE_RESEND_SECONDS ago (caller stays silent).
*/
function issue_code(PDO $pdo, string $table, string $email): ?string
{
$stmt = $pdo->prepare("SELECT TIMESTAMPDIFF(SECOND, created_at, NOW()) FROM {$table} WHERE email = ? LIMIT 1");
$stmt->execute([$email]);
$age = $stmt->fetchColumn();
if ($age !== false && (int) $age < CODE_RESEND_SECONDS) {
return null;
}
$code = generate_code();
$pdo->prepare("DELETE FROM {$table} WHERE email = ?")->execute([$email]);
$pdo->prepare("INSERT INTO {$table} (email, token, attempts, created_at) VALUES (?, ?, 0, NOW())")
->execute([$email, hash('sha256', $code)]);
return $code;
}
/**
* Checks a code and consumes an attempt. Returns 'ok', 'code_invalid' or 'code_expired'.
* A spent or exhausted record is deleted, so the caller must request a new code.
*/
function check_code(PDO $pdo, string $table, string $email, string $code): string
{
$stmt = $pdo->prepare(
"SELECT token, attempts, TIMESTAMPDIFF(MINUTE, created_at, NOW()) AS age
FROM {$table} WHERE email = ? LIMIT 1"
);
$stmt->execute([$email]);
$row = $stmt->fetch();
if (!$row) {
return 'code_invalid';
}
if ((int) $row['age'] >= CODE_TTL_MINUTES) {
$pdo->prepare("DELETE FROM {$table} WHERE email = ?")->execute([$email]);
return 'code_expired';
}
if (!hash_equals((string) $row['token'], hash('sha256', $code))) {
$attempts = (int) $row['attempts'] + 1;
if ($attempts >= CODE_MAX_ATTEMPTS) {
$pdo->prepare("DELETE FROM {$table} WHERE email = ?")->execute([$email]);
return 'code_expired';
}
$pdo->prepare("UPDATE {$table} SET attempts = ? WHERE email = ?")->execute([$attempts, $email]);
return 'code_invalid';
}
$pdo->prepare("DELETE FROM {$table} WHERE email = ?")->execute([$email]);
return 'ok';
}
/** Issues a bearer token exactly like login.php does. */
function issue_token(PDO $pdo, int $userId, string $deviceName): string
{
$config = require __DIR__ . '/config.php';
$plain = bin2hex(random_bytes(32));
$expires = (new DateTimeImmutable("+{$config['token_ttl_days']} days"))->format('Y-m-d H:i:s');
$pdo->prepare(
'INSERT INTO api_tokens (user_id, name, token, expires_at, created_at) VALUES (?, ?, ?, ?, NOW())'
)->execute([$userId, $deviceName, hash('sha256', $plain), $expires]);
return $plain;
}