Files
the-other-dude/backend/app/services/auth.py
Jason Staack b840047e19 feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:30:44 -05:00

155 lines
4.2 KiB
Python

"""
JWT authentication service.
Handles password hashing, JWT token creation, token verification,
and token revocation via Redis.
"""
import time
import uuid
from datetime import UTC, datetime, timedelta
from typing import Optional
import bcrypt
from fastapi import HTTPException, status
from jose import JWTError, jwt
from redis.asyncio import Redis
from app.config import settings
TOKEN_REVOCATION_PREFIX = "token_revoked:"
def hash_password(password: str) -> str:
"""Hash a plaintext password using bcrypt.
DEPRECATED: Used only by password reset (temporary bcrypt hash for
upgrade flow) and bootstrap_first_admin. Remove post-v6.0.
"""
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plaintext password against a bcrypt hash.
DEPRECATED: Used only by the one-time SRP upgrade flow (login with
must_upgrade_auth=True) and anti-enumeration dummy calls. Remove post-v6.0.
"""
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
def create_access_token(
user_id: uuid.UUID,
tenant_id: Optional[uuid.UUID],
role: str,
) -> str:
"""
Create a short-lived JWT access token.
Claims:
sub: user UUID (subject)
tenant_id: tenant UUID or None for super_admin
role: user's role string
type: "access" (to distinguish from refresh tokens)
exp: expiry timestamp
"""
now = datetime.now(UTC)
expire = now + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {
"sub": str(user_id),
"tenant_id": str(tenant_id) if tenant_id else None,
"role": role,
"type": "access",
"iat": now,
"exp": expire,
}
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
def create_refresh_token(user_id: uuid.UUID) -> str:
"""
Create a long-lived JWT refresh token.
Claims:
sub: user UUID (subject)
type: "refresh" (to distinguish from access tokens)
exp: expiry timestamp (7 days)
"""
now = datetime.now(UTC)
expire = now + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
payload = {
"sub": str(user_id),
"type": "refresh",
"iat": now,
"exp": expire,
}
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
def verify_token(token: str, expected_type: str = "access") -> dict:
"""
Decode and validate a JWT token.
Args:
token: JWT string to validate
expected_type: "access" or "refresh"
Returns:
dict: Decoded payload (sub, tenant_id, role, type, exp, iat)
Raises:
HTTPException 401: If token is invalid, expired, or wrong type
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token,
settings.JWT_SECRET_KEY,
algorithms=[settings.JWT_ALGORITHM],
)
except JWTError:
raise credentials_exception
# Validate token type
token_type = payload.get("type")
if token_type != expected_type:
raise credentials_exception
# Validate subject exists
sub = payload.get("sub")
if not sub:
raise credentials_exception
return payload
async def revoke_user_tokens(redis: Redis, user_id: str) -> None:
"""Mark all tokens for a user as revoked by storing current timestamp.
Any refresh token issued before this timestamp will be rejected.
TTL matches maximum refresh token lifetime (7 days).
"""
key = f"{TOKEN_REVOCATION_PREFIX}{user_id}"
await redis.set(key, str(time.time()), ex=7 * 24 * 3600)
async def is_token_revoked(redis: Redis, user_id: str, issued_at: float) -> bool:
"""Check if a token was issued before the user's revocation timestamp.
Returns True if the token should be rejected.
"""
key = f"{TOKEN_REVOCATION_PREFIX}{user_id}"
revoked_at = await redis.get(key)
if revoked_at is None:
return False
return issued_at < float(revoked_at)