"""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", }