Arag / app /services /auth_service.py
AuthorBot
Production hardening: JWT blacklist, TOTP, Pydantic schemas, Prometheus, SSRF fix, CSP, Redis auth, Celery backup β€” 35 items across P0-P5
131d826
Raw
History Blame Contribute Delete
8 kB
"""Author RAG Chatbot SaaS β€” Authentication Service.
Handles: registration, login with lockout, JWT pair issuance,
token refresh with rotation, logout, and password reset flows.
RULE: All password hashing and token creation via dedicated modules only.
"""
from datetime import datetime, timedelta, timezone
import bcrypt
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.access.token_crypto import create_access_token, create_refresh_token, decode_jwt
from app.exceptions.auth import (
AccountLockedError,
ExpiredTokenError,
InvalidCredentialsError,
InvalidTokenError,
)
from app.models.user import User
from app.repositories.user_repo import UserRepository
from app.services.email_service import EmailService
logger = structlog.get_logger(__name__)
cfg = get_settings()
class AuthService:
"""Handles all authentication workflows."""
def __init__(self, db: AsyncSession) -> None:
"""Initialize with an active database session.
Args:
db: SQLAlchemy async session.
"""
self._repo = UserRepository(db)
self._email = EmailService()
async def register(self, email: str, password: str, full_name: str) -> dict:
"""Register a new author account.
Args:
email: Author's email address.
password: Plain-text password (will be hashed).
full_name: Author's display name.
Returns:
Dict with access_token and user data.
Raises:
ValueError: If email is already registered or validation fails.
"""
# R-008: Validate email format
import re
email = email.lower().strip()
if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email):
raise ValueError("Invalid email format")
# R-009: Enforce password complexity
if len(password) < 8:
raise ValueError("Password must be at least 8 characters")
if not any(c.isupper() for c in password):
raise ValueError("Password must contain at least one uppercase letter")
if not any(c.islower() for c in password):
raise ValueError("Password must contain at least one lowercase letter")
if not any(c.isdigit() for c in password):
raise ValueError("Password must contain at least one digit")
existing = await self._repo.get_by_email(email)
if existing:
raise ValueError("An account with this email already exists")
password_hash = _hash_password(password)
user = await self._repo.create({
"email": email.lower().strip(),
"password_hash": password_hash,
"full_name": full_name,
"role": "author",
})
try:
self._email.send_welcome(user.email, user.full_name or "Author")
except Exception as e:
logger.warning("Failed to send welcome email", error=str(e))
logger.info("New author registered", user_id=user.id)
tokens = _create_token_pair(user.id)
return {
**tokens,
"is_superadmin": False,
"role": "author",
"author_slug": user.id,
}
async def login(self, email: str, password: str) -> dict:
"""Authenticate an author and issue token pair.
Args:
email: Author's email address.
password: Plain-text password.
Returns:
Dict with access_token and refresh_token.
Raises:
AccountLockedError: If account is locked.
InvalidCredentialsError: If credentials are wrong.
"""
user = await self._repo.get_by_email(email)
if not user:
# Constant-time fail β€” don't reveal that email doesn't exist
_verify_password("dummy", "$2b$12$LJ3m4ys3Lg7Eo8hVMnSEMuRCYJJn6wOuXqmZPjeDFEL2B3CJnYIjO")
raise InvalidCredentialsError()
# Check lock status
if user.locked_until:
lock_expiry = datetime.fromisoformat(user.locked_until)
if datetime.now(timezone.utc) < lock_expiry.replace(tzinfo=timezone.utc):
remaining = int((lock_expiry.replace(tzinfo=timezone.utc) - datetime.now(timezone.utc)).total_seconds() / 60)
raise AccountLockedError(minutes=remaining)
else:
await self._repo.reset_login_attempts(user.id)
if not _verify_password(password, user.password_hash):
attempts = await self._repo.increment_failed_login(user.id)
logger.warning("Failed login attempt", user_id=user.id, attempts=attempts)
if attempts >= cfg.MAX_LOGIN_ATTEMPTS:
lock_until = datetime.now(timezone.utc) + timedelta(minutes=cfg.LOCKOUT_DURATION_MINUTES)
await self._repo.lock_account(user.id, lock_until)
try:
self._email.send_account_locked(user.email)
except Exception:
pass
raise AccountLockedError(minutes=cfg.LOCKOUT_DURATION_MINUTES)
raise InvalidCredentialsError()
await self._repo.reset_login_attempts(user.id)
tokens = _create_token_pair(user.id, extra={"role": user.role})
logger.info("User logged in", user_id=user.id)
return {
**tokens,
"is_superadmin": user.role == "superadmin",
"role": user.role,
"author_slug": getattr(user, "slug", None) or user.id,
}
async def refresh_tokens(self, refresh_token: str) -> dict:
"""Rotate refresh token and issue new access token.
Args:
refresh_token: Current valid refresh token.
Returns:
Dict with new access_token and refresh_token.
Raises:
InvalidTokenError: If token is invalid or not a refresh type.
ExpiredTokenError: If refresh token is expired.
"""
payload = decode_jwt(refresh_token)
if payload.get("type") != "refresh":
raise InvalidTokenError("Not a refresh token")
user_id = payload.get("sub")
user = await self._repo.get_by_id(user_id)
if not user or not user.is_active:
raise InvalidTokenError("User not found")
tokens = _create_token_pair(user_id, extra={"role": user.role})
logger.debug("Tokens refreshed", user_id=user_id)
return {
**tokens,
"is_superadmin": user.role == "superadmin",
"role": user.role,
"author_slug": user.id,
}
# ─── Private Helpers ──────────────────────────────────────────────────────────
def _hash_password(plain: str) -> str:
"""Hash a plain-text password with bcrypt.
Args:
plain: Raw password string.
Returns:
Bcrypt hash string.
"""
salt = bcrypt.gensalt(rounds=12)
return bcrypt.hashpw(plain.encode(), salt).decode()
def _verify_password(plain: str, hashed: str) -> bool:
"""Verify a plain-text password against a bcrypt hash.
Args:
plain: Raw password string.
hashed: Stored bcrypt hash.
Returns:
True if password matches, False otherwise.
"""
try:
return bcrypt.checkpw(plain.encode(), hashed.encode())
except Exception:
return False
def _create_token_pair(user_id: str, extra: dict | None = None) -> dict:
"""Create access + refresh token pair.
Args:
user_id: User UUID string.
extra: Optional extra claims for access token.
Returns:
Dict with access_token and refresh_token.
"""
return {
"access_token": create_access_token(user_id, extra_claims=extra),
"refresh_token": create_refresh_token(user_id),
"token_type": "bearer",
}