| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122 |
- """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()
|