mirror of
git://f0xx.org/ac/ac-ms-identity
synced 2026-07-30 02:37:39 +03:00
feat(2fa): cross-device approval via QR scan (WhatsApp-style)
- AuthApproval: mint/approve/consume/poll one-time tokens stored in new auth_approvals table (TTL 300s, SHA-256 IP hash for geo audit) - AuthEmailSchema: add auth_approvals migration; fix missing index on auth_attempts (SQLite path) - AuthTwoFactorPage: QR now encodes /two-factor/approve?token=<T> instead of the hub URL; stores raw_token in session for polling; short URL with ?src=qr tracking preserved - Auth: add completeTotpLoginByApproval(), clearPending2fa clears token, clientIp() helper (X-Forwarded-For aware) - bootstrap: require AuthApproval.php Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
40
src/Auth.php
40
src/Auth.php
@@ -129,7 +129,45 @@ final class Auth {
|
||||
}
|
||||
|
||||
public static function clearPending2fa(): void {
|
||||
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_exp']);
|
||||
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_exp'], $_SESSION['pending_2fa_approval_token']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete login via a cross-device approval token (no TOTP code required).
|
||||
* The approval token must have been approved by the authenticated mobile user.
|
||||
*/
|
||||
public static function completeTotpLoginByApproval(string $rawToken): bool {
|
||||
$uid = self::pending2faUserId();
|
||||
if ($uid <= 0) {
|
||||
return false;
|
||||
}
|
||||
$sessionId = session_id();
|
||||
$consumedUid = AuthApproval::consume($rawToken, $sessionId);
|
||||
if ($consumedUid <= 0 || $consumedUid !== $uid) {
|
||||
return false;
|
||||
}
|
||||
self::clearPending2fa();
|
||||
$stmt = Database::pdo()->prepare('SELECT * FROM users WHERE id = ? LIMIT 1');
|
||||
$stmt->execute([$consumedUid]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) {
|
||||
return false;
|
||||
}
|
||||
$_SESSION['user'] = Rbac::buildSessionUser($row);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Best-effort client IP (respects X-Forwarded-For from trusted proxy). */
|
||||
public static function clientIp(): string {
|
||||
$fwd = (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? '');
|
||||
if ($fwd !== '') {
|
||||
// Take the first (original client) IP from the chain
|
||||
$first = trim(explode(',', $fwd)[0]);
|
||||
if (filter_var($first, FILTER_VALIDATE_IP)) {
|
||||
return $first;
|
||||
}
|
||||
}
|
||||
return (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
|
||||
}
|
||||
|
||||
public static function logout(): void {
|
||||
|
||||
150
src/AuthApproval.php
Normal file
150
src/AuthApproval.php
Normal file
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Cross-device 2FA approval tokens.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Desktop reaches /two-factor: AuthApproval::create() mints a token,
|
||||
* stores it with the desktop session ID and pending user ID.
|
||||
* 2. QR code encodes: <appRoot>/two-factor/approve?token=<raw_token>
|
||||
* 3. Mobile (already logged in) opens that URL, sees a confirm page,
|
||||
* POSTs to /two-factor/approve → AuthApproval::approve() sets status=approved.
|
||||
* 4. Desktop polls /api/two-factor/poll?token=<raw_token> every ~2.5 s.
|
||||
* When status=approved, Auth::completeTotpLoginByApproval() is called,
|
||||
* which validates the approval and completes the session.
|
||||
*
|
||||
* IP hashes are SHA-256(IP) — anonymous geolocation audit trail.
|
||||
*/
|
||||
final class AuthApproval {
|
||||
private const TTL_SECONDS = 300;
|
||||
|
||||
private function __construct() {
|
||||
}
|
||||
|
||||
/** Mint a new approval token tied to the current desktop session and pending user. */
|
||||
public static function create(int $userId, string $desktopSessionId, string $requesterIp): string {
|
||||
AuthEmailSchema::ensure(Database::pdo());
|
||||
$rawToken = bin2hex(random_bytes(32));
|
||||
$tokenHash = hash('sha256', $rawToken);
|
||||
$ipHash = hash('sha256', $requesterIp);
|
||||
$expiresAt = date('Y-m-d H:i:s', time() + self::TTL_SECONDS);
|
||||
$pdo = Database::pdo();
|
||||
// Clean up expired tokens for this session first
|
||||
$pdo->prepare(
|
||||
"DELETE FROM auth_approvals WHERE desktop_session_id = ? AND expires_at < NOW()"
|
||||
)->execute([$desktopSessionId]);
|
||||
$pdo->prepare(
|
||||
"INSERT INTO auth_approvals
|
||||
(token_hash, desktop_session_id, user_id, status, requester_ip_hash, expires_at)
|
||||
VALUES (?, ?, ?, 'pending', ?, ?)"
|
||||
)->execute([$tokenHash, $desktopSessionId, $userId, $ipHash, $expiresAt]);
|
||||
return $rawToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a token (called by the mobile user on POST /two-factor/approve).
|
||||
* Returns true on success, false if token not found / expired / wrong user.
|
||||
*/
|
||||
public static function approve(string $rawToken, int $approvingUserId, string $approverIp): bool {
|
||||
$tokenHash = hash('sha256', $rawToken);
|
||||
$ipHash = hash('sha256', $approverIp);
|
||||
$pdo = Database::pdo();
|
||||
$st = $pdo->prepare(
|
||||
"SELECT id, user_id, status, expires_at FROM auth_approvals WHERE token_hash = ? LIMIT 1"
|
||||
);
|
||||
$st->execute([$tokenHash]);
|
||||
$row = $st->fetch();
|
||||
if (!$row) {
|
||||
return false;
|
||||
}
|
||||
if ($row['status'] !== 'pending') {
|
||||
return false;
|
||||
}
|
||||
if (strtotime((string) $row['expires_at']) < time()) {
|
||||
$pdo->prepare("UPDATE auth_approvals SET status='expired' WHERE id=?")->execute([$row['id']]);
|
||||
return false;
|
||||
}
|
||||
// The approving user must be the same user waiting on desktop
|
||||
if ((int) $row['user_id'] !== $approvingUserId) {
|
||||
return false;
|
||||
}
|
||||
$pdo->prepare(
|
||||
"UPDATE auth_approvals SET status='approved', approver_ip_hash=?, approved_at=NOW() WHERE id=?"
|
||||
)->execute([$ipHash, $row['id']]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll status for a raw token.
|
||||
* Returns 'pending'|'approved'|'expired'|'used'|'not_found'.
|
||||
* Also marks expired rows in the DB.
|
||||
*/
|
||||
public static function pollStatus(string $rawToken): string {
|
||||
$tokenHash = hash('sha256', $rawToken);
|
||||
$pdo = Database::pdo();
|
||||
$st = $pdo->prepare(
|
||||
"SELECT id, status, expires_at, user_id, desktop_session_id
|
||||
FROM auth_approvals WHERE token_hash = ? LIMIT 1"
|
||||
);
|
||||
$st->execute([$tokenHash]);
|
||||
$row = $st->fetch();
|
||||
if (!$row) {
|
||||
return 'not_found';
|
||||
}
|
||||
if ($row['status'] === 'pending' && strtotime((string) $row['expires_at']) < time()) {
|
||||
$pdo->prepare("UPDATE auth_approvals SET status='expired' WHERE id=?")->execute([$row['id']]);
|
||||
return 'expired';
|
||||
}
|
||||
return (string) $row['status'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume an approved token and return the user_id.
|
||||
* Returns 0 if the token is not in 'approved' state or doesn't match the session.
|
||||
*/
|
||||
public static function consume(string $rawToken, string $desktopSessionId): int {
|
||||
$tokenHash = hash('sha256', $rawToken);
|
||||
$pdo = Database::pdo();
|
||||
$st = $pdo->prepare(
|
||||
"SELECT id, user_id, status, desktop_session_id, expires_at
|
||||
FROM auth_approvals WHERE token_hash = ? LIMIT 1"
|
||||
);
|
||||
$st->execute([$tokenHash]);
|
||||
$row = $st->fetch();
|
||||
if (!$row) {
|
||||
return 0;
|
||||
}
|
||||
if ($row['status'] !== 'approved') {
|
||||
return 0;
|
||||
}
|
||||
if ($row['desktop_session_id'] !== $desktopSessionId) {
|
||||
return 0;
|
||||
}
|
||||
if (strtotime((string) $row['expires_at']) < time()) {
|
||||
return 0;
|
||||
}
|
||||
$pdo->prepare(
|
||||
"UPDATE auth_approvals SET status='used', used_at=NOW() WHERE id=?"
|
||||
)->execute([$row['id']]);
|
||||
return (int) $row['user_id'];
|
||||
}
|
||||
|
||||
/** Lookup the user_id and desktop_session_id for a token (for the approve confirmation page). */
|
||||
public static function getForToken(string $rawToken): ?array {
|
||||
$tokenHash = hash('sha256', $rawToken);
|
||||
$st = Database::pdo()->prepare(
|
||||
"SELECT user_id, desktop_session_id, status, expires_at
|
||||
FROM auth_approvals WHERE token_hash = ? LIMIT 1"
|
||||
);
|
||||
$st->execute([$tokenHash]);
|
||||
$row = $st->fetch();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
if ($row['status'] !== 'pending' || strtotime((string) $row['expires_at']) < time()) {
|
||||
return null;
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ final class AuthEmailSchema {
|
||||
self::ensureAuthTokens($pdo);
|
||||
self::ensureAuthFactors($pdo);
|
||||
self::ensureAuthAttempts($pdo);
|
||||
self::ensureAuthApprovals($pdo);
|
||||
});
|
||||
self::$ensured = true;
|
||||
}
|
||||
@@ -136,5 +137,48 @@ final class AuthEmailSchema {
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)"
|
||||
);
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_auth_attempts_ip ON auth_attempts (ip_hash, created_at)');
|
||||
}
|
||||
|
||||
private static function ensureAuthApprovals(PDO $pdo): void {
|
||||
if (Database::tableExists($pdo, 'auth_approvals')) {
|
||||
return;
|
||||
}
|
||||
if (Database::isMysql()) {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE auth_approvals (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
token_hash CHAR(64) NOT NULL UNIQUE,
|
||||
desktop_session_id VARCHAR(128) NOT NULL,
|
||||
user_id INT UNSIGNED NOT NULL,
|
||||
status ENUM('pending','approved','used','expired') NOT NULL DEFAULT 'pending',
|
||||
requester_ip_hash CHAR(64) NULL,
|
||||
approver_ip_hash CHAR(64) NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
approved_at TIMESTAMP NULL,
|
||||
used_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_auth_approvals_token (token_hash),
|
||||
KEY idx_auth_approvals_session (desktop_session_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
return;
|
||||
}
|
||||
$pdo->exec(
|
||||
"CREATE TABLE auth_approvals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
desktop_session_id TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
requester_ip_hash TEXT NULL,
|
||||
approver_ip_hash TEXT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
approved_at TEXT NULL,
|
||||
used_at TEXT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)"
|
||||
);
|
||||
$pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_approvals_token ON auth_approvals (token_hash)');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,56 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** 2FA challenge page helpers (QR short link for mobile handoff). */
|
||||
/** 2FA challenge page helpers — cross-device approval QR. */
|
||||
final class AuthTwoFactorPage {
|
||||
/** @return array{short_url?:string,qr_url?:string,long_url:string}|null */
|
||||
/**
|
||||
* Build the QR payload for the 2FA challenge screen.
|
||||
*
|
||||
* Returns an array with:
|
||||
* long_url – approval URL (fallback if shortener unavailable)
|
||||
* short_url – shortened URL (s.f0xx.org)
|
||||
* qr_url – URL of the QR PNG image (short_url + ?src=qr)
|
||||
* raw_token – the approval token to store in session for polling
|
||||
*
|
||||
* @return array{long_url:string,short_url?:string,qr_url?:string,raw_token:string}|null
|
||||
*/
|
||||
public static function qrPayload(): ?array {
|
||||
$uid = Auth::pending2faUserId();
|
||||
if ($uid <= 0) {
|
||||
return null;
|
||||
}
|
||||
$project = Auth::projectBasePath();
|
||||
$longUrl = 'https://apps.f0xx.org' . $project . '/?ref=2fa&uid=' . $uid;
|
||||
$ip = Auth::clientIp();
|
||||
$rawToken = AuthApproval::create($uid, session_id(), $ip);
|
||||
|
||||
// Store token in session so the poll endpoint can reference it
|
||||
$_SESSION['pending_2fa_approval_token'] = $rawToken;
|
||||
|
||||
$approveUrl = 'https://apps.f0xx.org' . $project . '/two-factor/approve?token=' . urlencode($rawToken);
|
||||
|
||||
if (!UrlShortenerDatabase::enabled() || !UrlShortenerDatabase::ping()) {
|
||||
return ['long_url' => $longUrl];
|
||||
return ['long_url' => $approveUrl, 'raw_token' => $rawToken];
|
||||
}
|
||||
$bearers = ShortLinksRepository::listBearers();
|
||||
if ($bearers === []) {
|
||||
return ['long_url' => $longUrl];
|
||||
return ['long_url' => $approveUrl, 'raw_token' => $rawToken];
|
||||
}
|
||||
$bearerId = (int) ($bearers[0]['id'] ?? 0);
|
||||
if ($bearerId <= 0) {
|
||||
return ['long_url' => $longUrl];
|
||||
return ['long_url' => $approveUrl, 'raw_token' => $rawToken];
|
||||
}
|
||||
$mint = ShortLinksRepository::createLink($bearerId, $longUrl, 900);
|
||||
$mint = ShortLinksRepository::createLink($bearerId, $approveUrl, 900);
|
||||
if (empty($mint['ok'])) {
|
||||
return ['long_url' => $longUrl];
|
||||
return ['long_url' => $approveUrl, 'raw_token' => $rawToken];
|
||||
}
|
||||
$shortUrl = (string) ($mint['short_url'] ?? '');
|
||||
// QR image encodes the short URL with ?src=qr so mobile scans are tracked
|
||||
$qrUrl = $shortUrl . (str_contains($shortUrl, '?') ? '&src=qr' : '?src=qr');
|
||||
return [
|
||||
'long_url' => $longUrl,
|
||||
'short_url' => (string) ($mint['short_url'] ?? ''),
|
||||
'qr_url' => (string) ($mint['qr_url'] ?? ''),
|
||||
'long_url' => $approveUrl,
|
||||
'short_url' => $shortUrl,
|
||||
'qr_url' => $qrUrl,
|
||||
'raw_token' => $rawToken,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user