"""Black-box security shortcuts to generate JWT tokens and password hashing and verification.""" import hashlib import secrets import time from datetime import UTC, datetime, timedelta import jwt from passlib.context import CryptContext from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.orm import Session from app.core import config from app.models import PasswordResetRequestLog from app.schemas.responses import AccessTokenResponse JWT_ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_SECS = config.settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 REFRESH_TOKEN_EXPIRE_SECS = config.settings.REFRESH_TOKEN_EXPIRE_MINUTES * 60 PWD_CONTEXT = CryptContext( schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=config.settings.SECURITY_BCRYPT_ROUNDS, ) class JWTTokenPayload(BaseModel): sub: str | int refresh: bool issued_at: int expires_at: int RESET_TOKEN_TTL = {"reset": timedelta(hours=1), "invite": timedelta(days=7)} def generate_reset_token() -> tuple[str, str]: """Returns (raw_token_for_email, sha256_hash_for_db).""" raw = secrets.token_urlsafe(32) return raw, hashlib.sha256(raw.encode()).hexdigest() def create_jwt_token(subject: str | int, exp_secs: int, refresh: bool): """Creates jwt access or refresh token for user. Args: subject: anything unique to user, id or email etc. exp_secs: expire time in seconds refresh: if True, this is refresh token """ issued_at = int(time.time()) expires_at = issued_at + exp_secs to_encode: dict[str, int | str | bool] = { "issued_at": issued_at, "expires_at": expires_at, "sub": subject, "refresh": refresh, } encoded_jwt = jwt.encode( to_encode, key=config.settings.SECRET_KEY, algorithm=JWT_ALGORITHM, ) return encoded_jwt, expires_at, issued_at def generate_access_token_response(subject: str | int): """Generate tokens and return AccessTokenResponse""" access_token, expires_at, issued_at = create_jwt_token( subject, ACCESS_TOKEN_EXPIRE_SECS, refresh=False ) refresh_token, refresh_expires_at, refresh_issued_at = create_jwt_token( subject, REFRESH_TOKEN_EXPIRE_SECS, refresh=True ) return AccessTokenResponse( token_type="Bearer", access_token=access_token, expires_at=expires_at, issued_at=issued_at, refresh_token=refresh_token, refresh_token_expires_at=refresh_expires_at, refresh_token_issued_at=refresh_issued_at, ) def verify_password(plain_password: str, hashed_password: str) -> bool: """Verifies plain and hashed password matches Applies passlib context based on bcrypt algorithm on plain password. It takes about 0.3s for default 12 rounds of SECURITY_BCRYPT_DEFAULT_ROUNDS. """ return PWD_CONTEXT.verify(plain_password, hashed_password) def get_password_hash(password: str) -> str: """Creates hash from password Applies passlib context based on bcrypt algorithm on plain password. It takes about 0.3s for default 12 rounds of SECURITY_BCRYPT_DEFAULT_ROUNDS. """ return PWD_CONTEXT.hash(password) def is_reset_rate_limited(session: Session, email: str) -> bool: window_start = datetime.now(UTC) - timedelta(hours=config.settings.RESET_REQUEST_WINDOW_HOUR) count = session.scalar( select(func.count()) .select_from(PasswordResetRequestLog) .where( PasswordResetRequestLog.email == email, PasswordResetRequestLog.requested_at >= window_start, ) ) return count >= config.settings.RESET_REQUEST_LIMIT def log_reset_request(session: Session, email: str) -> None: session.add(PasswordResetRequestLog(email=email)) session.commit()