1
0
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:
Anton Afanasyeu
2026-06-27 22:49:39 +02:00
parent 835f02153e
commit cefde609cb
4 changed files with 265 additions and 12 deletions

View File

@@ -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
View 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;
}
}

View File

@@ -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)');
}
}

View File

@@ -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,
];
}
}