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:
@@ -246,6 +246,7 @@ final class Auth {
|
|||||||
|
|
||||||
public static function logout(): void {
|
public static function logout(): void {
|
||||||
self::clearPending2fa();
|
self::clearPending2fa();
|
||||||
|
AuthRemember::clear();
|
||||||
unset($_SESSION['user']);
|
unset($_SESSION['user']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
127
src/AuthRemember.php
Normal file
127
src/AuthRemember.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user