92 lines
3.0 KiB
Python
92 lines
3.0 KiB
Python
"""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)
|