mirror of
git://f0xx.org/ac/ac-ms-identity
synced 2026-07-30 03:38:12 +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 {
|
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 {
|
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::ensureAuthTokens($pdo);
|
||||||
self::ensureAuthFactors($pdo);
|
self::ensureAuthFactors($pdo);
|
||||||
self::ensureAuthAttempts($pdo);
|
self::ensureAuthAttempts($pdo);
|
||||||
|
self::ensureAuthApprovals($pdo);
|
||||||
});
|
});
|
||||||
self::$ensured = true;
|
self::$ensured = true;
|
||||||
}
|
}
|
||||||
@@ -136,5 +137,48 @@ final class AuthEmailSchema {
|
|||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
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
|
<?php
|
||||||
declare(strict_types=1);
|
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 {
|
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 {
|
public static function qrPayload(): ?array {
|
||||||
$uid = Auth::pending2faUserId();
|
$uid = Auth::pending2faUserId();
|
||||||
if ($uid <= 0) {
|
if ($uid <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$project = Auth::projectBasePath();
|
$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()) {
|
if (!UrlShortenerDatabase::enabled() || !UrlShortenerDatabase::ping()) {
|
||||||
return ['long_url' => $longUrl];
|
return ['long_url' => $approveUrl, 'raw_token' => $rawToken];
|
||||||
}
|
}
|
||||||
$bearers = ShortLinksRepository::listBearers();
|
$bearers = ShortLinksRepository::listBearers();
|
||||||
if ($bearers === []) {
|
if ($bearers === []) {
|
||||||
return ['long_url' => $longUrl];
|
return ['long_url' => $approveUrl, 'raw_token' => $rawToken];
|
||||||
}
|
}
|
||||||
$bearerId = (int) ($bearers[0]['id'] ?? 0);
|
$bearerId = (int) ($bearers[0]['id'] ?? 0);
|
||||||
if ($bearerId <= 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'])) {
|
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 [
|
return [
|
||||||
'long_url' => $longUrl,
|
'long_url' => $approveUrl,
|
||||||
'short_url' => (string) ($mint['short_url'] ?? ''),
|
'short_url' => $shortUrl,
|
||||||
'qr_url' => (string) ($mint['qr_url'] ?? ''),
|
'qr_url' => $qrUrl,
|
||||||
|
'raw_token' => $rawToken,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user