"""Trusted devices: "don't ask for the 2FA code on this device" (SPEC §6.1). Ticking the box at a successful 2FA sign-in stores a random token in Redis (``trustdev:{token}`` -> user id) and hands it to the browser in its own HttpOnly cookie. A later sign-in to the *same* account from that browser may then skip the code — the password is still required. Other accounts signing in on the same browser are unaffected. The trust lasts TRUST_DAYS and is renewed each time it is used, so a device in regular use stays trusted while one left alone for that long has to show a code again. A per-user index (``trustdev-user:{user_id}``) lets everything be revoked at once: on a password reset, when 2FA is turned off, and when an administrator blocks or deletes the account. Signing out does not revoke it — that is the point of the box. """ from __future__ import annotations import secrets import uuid from fastapi import Request, Response from app.redis_client import aw, get_redis TRUST_DAYS = 90 _TTL = TRUST_DAYS * 24 * 3600 _PREFIX = "trustdev:" _INDEX = "trustdev-user:" COOKIE_SECURE = "__Host-trusted-device" COOKIE_INSECURE = "trusted_device" def _cookie_name(request: Request) -> str: # Same split as the session cookie: the __Host- prefix needs HTTPS. return COOKIE_SECURE if request.url.scheme == "https" else COOKIE_INSECURE def read_cookie(request: Request) -> str: return request.cookies.get(COOKIE_SECURE) or request.cookies.get(COOKIE_INSECURE, "") async def trust(request: Request, response: Response, user_id: uuid.UUID) -> None: """Remember this browser for ``user_id`` and set the cookie.""" token = secrets.token_urlsafe(32) redis = get_redis() await redis.set(_PREFIX + token, str(user_id), ex=_TTL) await aw(redis.sadd(_INDEX + str(user_id), token)) await redis.expire(_INDEX + str(user_id), _TTL) response.set_cookie( _cookie_name(request), token, max_age=_TTL, httponly=True, secure=request.url.scheme == "https", samesite="lax", path="/", ) async def is_trusted(request: Request, response: Response, user_id: uuid.UUID) -> bool: """Whether this browser is trusted for ``user_id``; renews the trust if so.""" token = read_cookie(request) if not token: return False redis = get_redis() if await redis.get(_PREFIX + token) != str(user_id): return False await redis.expire(_PREFIX + token, _TTL) await redis.expire(_INDEX + str(user_id), _TTL) response.set_cookie( _cookie_name(request), token, max_age=_TTL, httponly=True, secure=request.url.scheme == "https", samesite="lax", path="/", ) return True async def revoke_all(user_id: uuid.UUID) -> None: """Forget every device trusted for ``user_id``.""" redis = get_redis() index = _INDEX + str(user_id) tokens: set[str] = await aw(redis.smembers(index)) if tokens: await redis.delete(*(_PREFIX + t for t in tokens)) await redis.delete(index)