1
0
mirror of git://f0xx.org/ac/ac-ms-identity synced 2026-08-12 18:12:01 +03:00

Add remember-me auth token cookie support.

Persistent 30-day HttpOnly login via auth_tokens table; Auth integrates tryRestore for session recovery.
This commit is contained in:
Anton Afanasyeu
2026-08-12 14:08:00 +02:00
parent 9b13cf2152
commit cf22fe921e
2 changed files with 128 additions and 0 deletions

View File

@@ -246,6 +246,7 @@ final class Auth {
public static function logout(): void {
self::clearPending2fa();
AuthRemember::clear();
unset($_SESSION['user']);
}

127
src/AuthRemember.php Normal file
View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
/** Persistent login via HttpOnly cookie backed by auth_tokens (purpose login_magic). */
final class AuthRemember {
private const COOKIE = 'ac_remember';
private const TTL_SECONDS = 2592000; // 30 days
private function __construct() {
}
public static function tryRestore(): void {
if (PHP_SAPI === 'cli' || !empty($_SESSION['user'])) {
return;
}
$raw = trim((string) ($_COOKIE[self::COOKIE] ?? ''));
if ($raw === '' || !preg_match('/^[a-f0-9]{64}$/', $raw)) {
return;
}
AuthEmailSchema::ensure(Database::pdo());
$hash = hash('sha256', $raw);
$stmt = Database::pdo()->prepare(
"SELECT t.user_id, u.* FROM auth_tokens t
INNER JOIN users u ON u.id = t.user_id
WHERE t.token_hash = ? AND t.purpose = 'login_magic'
AND t.used_at IS NULL AND t.expires_at > ?
LIMIT 1"
);
$now = gmdate('Y-m-d H:i:s');
$stmt->execute([$hash, $now]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row || empty($row['id'])) {
self::clearCookie();
return;
}
$status = (string) ($row['status'] ?? 'active');
if ($status !== 'active') {
self::clearCookie();
return;
}
if (AuthFactors::hasTotp((int) $row['id'])) {
// Remember-me skips password but not 2FA.
return;
}
Database::ensureRbacSchema();
Rbac::seedMembershipsForUser((int) $row['id'], (string) ($row['role'] ?? 'viewer'));
$_SESSION['user'] = Rbac::buildSessionUser($row);
Auth::touchSession();
}
public static function issue(int $userId): void {
if ($userId <= 0) {
return;
}
AuthEmailSchema::ensure(Database::pdo());
$pdo = Database::pdo();
$pdo->prepare(
"DELETE FROM auth_tokens WHERE user_id = ? AND purpose = 'login_magic' AND used_at IS NULL"
)->execute([$userId]);
$raw = bin2hex(random_bytes(32));
$hash = hash('sha256', $raw);
$expires = gmdate('Y-m-d H:i:s', time() + self::TTL_SECONDS);
$pdo->prepare(
'INSERT INTO auth_tokens (user_id, purpose, token_hash, expires_at, created_at)
VALUES (?, ?, ?, ?, ?)'
)->execute([$userId, 'login_magic', $hash, $expires, gmdate('Y-m-d H:i:s')]);
self::setCookie($raw, self::TTL_SECONDS);
}
public static function clear(): void {
$raw = trim((string) ($_COOKIE[self::COOKIE] ?? ''));
if ($raw !== '' && preg_match('/^[a-f0-9]{64}$/', $raw)) {
AuthEmailSchema::ensure(Database::pdo());
$hash = hash('sha256', $raw);
Database::pdo()->prepare(
"UPDATE auth_tokens SET used_at = ? WHERE token_hash = ? AND purpose = 'login_magic' AND used_at IS NULL"
)->execute([gmdate('Y-m-d H:i:s'), $hash]);
}
self::clearCookie();
}
public static function stashPendingFromRequest(): void {
if (!empty($_POST['remember_me'])) {
$_SESSION['remember_me_after_login'] = 1;
} else {
unset($_SESSION['remember_me_after_login']);
}
}
public static function applyPending(int $userId): void {
if ($userId <= 0 || empty($_SESSION['remember_me_after_login'])) {
unset($_SESSION['remember_me_after_login']);
return;
}
unset($_SESSION['remember_me_after_login']);
self::issue($userId);
}
private static function cookiePath(): string {
$path = trim((string) cfg('session_cookie_path', '/app/androidcast_project'));
return $path !== '' ? rtrim($path, '/') : '/';
}
private static function setCookie(string $value, int $maxAge): void {
$secure = function_exists('platform_is_https_request') && platform_is_https_request();
setcookie(self::COOKIE, $value, [
'expires' => time() + $maxAge,
'path' => self::cookiePath(),
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
$_COOKIE[self::COOKIE] = $value;
}
private static function clearCookie(): void {
$secure = function_exists('platform_is_https_request') && platform_is_https_request();
setcookie(self::COOKIE, '', [
'expires' => time() - 3600,
'path' => self::cookiePath(),
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
unset($_COOKIE[self::COOKIE]);
}
}