Spaces:
Running
Running
AuthorBot
Production hardening: JWT blacklist, TOTP, Pydantic schemas, Prometheus, SSRF fix, CSP, Redis auth, Celery backup β 35 items across P0-P5
131d826 | """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", | |
| } | |