Files
plm-lottery/app/auth/security.py
T

82 lines
3.5 KiB
Python
Raw Normal View History

from datetime import datetime, timedelta, timezone
import logging
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from starlette.concurrency import run_in_threadpool
from app.config import settings
logger = logging.getLogger(__name__)
# Shared by registration (app/auth/routes.py) and the self-service password change
# (app/api/routes/users.py) so the two can't enforce different minimums.
MIN_PASSWORD_LENGTH = 8
_hasher = PasswordHasher()
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
"""Any failure to verify reads as "wrong password", never as a server error.
Catching only VerifyMismatchError left the other two cases as unhandled 500s
(B-13): VerificationError covers argon2's other verification failures, and
InvalidHashError fires when the stored hash can't be parsed at all — which is a
data problem worth logging, but from the caller's side it still just means this
password does not open this account.
"""
try:
return _hasher.verify(password_hash, password)
except InvalidHashError:
logger.error("stored password hash is unparseable — password verification cannot succeed")
return False
except VerificationError:
return False
# --- B-55: the two Argon2 calls above must never run on the event loop ----------
# Argon2 is deliberately expensive — tens of milliseconds of CPU per call, by
# design. Called straight from an async handler that stalls the *whole* process
# for that long: every other request, and all six background tasks (scheduler,
# confirmation poller, RBF bumper, listener, both reconcilers). A burst of
# unauthenticated login attempts was therefore a cheap way to delay draws and
# confirmations, not just to slow down logins. The threadpool keeps the cost
# where it belongs — on a worker thread, with the loop free to run everything
# else meanwhile.
#
# The synchronous functions stay: they're what the wrappers call, and what tests
# and scripts (no running loop) use directly. Every async caller must use these.
async def hash_password_async(password: str) -> str:
return await run_in_threadpool(hash_password, password)
async def verify_password_async(password: str, password_hash: str) -> bool:
return await run_in_threadpool(verify_password, password, password_hash)
def create_access_token(user_id: int, token_version: int = 0) -> str:
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
# "tv" lets get_current_user (app/auth/dependencies.py) reject a token issued
# before the account's password was last changed (B-34): change-password and
# the admin reset both bump User.token_version, so every token that still
# carries the old value stops working immediately instead of staying valid
# for up to jwt_expire_minutes after a compromise is supposedly handled.
payload = {"sub": str(user_id), "tv": token_version, "exp": expires_at}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> tuple[int, int]:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
# .get(..., 0) covers tokens issued before "tv" existed (pre-B-34 deploy) —
# they carry no claim at all, and 0 is what a freshly migrated user's
# token_version starts at, so those sessions keep working across the deploy.
return int(payload["sub"]), int(payload.get("tv", 0))