86 lines
2.8 KiB
Python
86 lines
2.8 KiB
Python
|
|
"""Security: JWT creation/verification and password hashing."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import uuid
|
||
|
|
from datetime import datetime, timedelta, timezone
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from jose import JWTError, jwt
|
||
|
|
from passlib.context import CryptContext
|
||
|
|
|
||
|
|
from app.config import get_settings
|
||
|
|
|
||
|
|
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||
|
|
|
||
|
|
|
||
|
|
def hash_password(plain: str) -> str:
|
||
|
|
"""Hash a password using bcrypt."""
|
||
|
|
return _pwd_context.hash(plain)
|
||
|
|
|
||
|
|
|
||
|
|
def verify_password(plain: str, hashed: str) -> bool:
|
||
|
|
"""Verify a password against its bcrypt hash."""
|
||
|
|
try:
|
||
|
|
return _pwd_context.verify(plain, hashed)
|
||
|
|
except (ValueError, TypeError):
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
def create_access_token(
|
||
|
|
subject: str | uuid.UUID,
|
||
|
|
extra_claims: dict[str, Any] | None = None,
|
||
|
|
expires_in_minutes: int | None = None,
|
||
|
|
) -> str:
|
||
|
|
"""Create a signed JWT access token."""
|
||
|
|
cfg = get_settings()
|
||
|
|
minutes = expires_in_minutes or cfg.access_token_expire_minutes
|
||
|
|
now = datetime.now(timezone.utc)
|
||
|
|
payload: dict[str, Any] = {
|
||
|
|
"sub": str(subject),
|
||
|
|
"iat": int(now.timestamp()),
|
||
|
|
"exp": int((now + timedelta(minutes=minutes)).timestamp()),
|
||
|
|
"type": "access",
|
||
|
|
}
|
||
|
|
if extra_claims:
|
||
|
|
payload.update(extra_claims)
|
||
|
|
return jwt.encode(payload, cfg.secret_key, algorithm=cfg.jwt_algorithm)
|
||
|
|
|
||
|
|
|
||
|
|
def create_refresh_token(
|
||
|
|
subject: str | uuid.UUID, expires_in_minutes: int | None = None
|
||
|
|
) -> str:
|
||
|
|
"""Create a signed JWT refresh token."""
|
||
|
|
cfg = get_settings()
|
||
|
|
minutes = expires_in_minutes or cfg.refresh_token_expire_minutes
|
||
|
|
now = datetime.now(timezone.utc)
|
||
|
|
payload = {
|
||
|
|
"sub": str(subject),
|
||
|
|
"iat": int(now.timestamp()),
|
||
|
|
"exp": int((now + timedelta(minutes=minutes)).timestamp()),
|
||
|
|
"type": "refresh",
|
||
|
|
}
|
||
|
|
return jwt.encode(payload, cfg.secret_key, algorithm=cfg.jwt_algorithm)
|
||
|
|
|
||
|
|
|
||
|
|
def decode_token(token: str) -> dict[str, Any]:
|
||
|
|
"""Decode and verify a JWT. Raises JWTError on failure."""
|
||
|
|
cfg = get_settings()
|
||
|
|
return jwt.decode(token, cfg.secret_key, algorithms=[cfg.jwt_algorithm])
|
||
|
|
|
||
|
|
|
||
|
|
def validate_password_strength(password: str) -> list[str]:
|
||
|
|
"""Return a list of validation errors (empty list = valid password)."""
|
||
|
|
errors: list[str] = []
|
||
|
|
if len(password) < 8:
|
||
|
|
errors.append("Password must be at least 8 characters long")
|
||
|
|
if not any(c.isalpha() for c in password):
|
||
|
|
errors.append("Password must contain at least one letter")
|
||
|
|
if not any(c.isdigit() for c in password):
|
||
|
|
errors.append("Password must contain at least one digit")
|
||
|
|
# Tiny blacklist of trivial passwords
|
||
|
|
blacklist = {"password", "12345678", "qwerty12", "password1", "abcdefgh"}
|
||
|
|
if password.lower() in blacklist:
|
||
|
|
errors.append("Password is too common")
|
||
|
|
return errors
|