"""Author RAG — Public Auth API Endpoints. Extends the existing schemas_router with billing-gated account activation and password reset flows. New routes (add to /api/auth/* prefix): POST /api/auth/activate → create account from Stripe session (card required) POST /api/auth/forgot-password → send password reset email POST /api/auth/reset-password → consume token + set new password """ import hashlib import hmac import secrets from datetime import datetime, timedelta, timezone import bcrypt import structlog from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, EmailStr, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings from app.dependencies import get_db from app.models.email_verification import EmailVerification from app.models.user import User from app.repositories.user_repo import UserRepository from app.services import billing_service as bs from app.services.auth_service import _create_token_pair, _hash_password from app.services.email_service import EmailService logger = structlog.get_logger(__name__) cfg = get_settings() router = APIRouter(prefix="/auth", tags=["Auth – Public"]) # ── Schemas ─────────────────────────────────────────────────────────────────── class ActivateRequest(BaseModel): stripe_session_id: str = Field(..., description="The cs_xxx from Stripe's success redirect") password: str = Field(..., min_length=8, description="Author's chosen password") full_name: str | None = Field(None, max_length=255) class ForgotPasswordRequest(BaseModel): email: EmailStr class ResetPasswordRequest(BaseModel): token: str = Field(..., description="Reset token from the emailed link") new_password: str = Field(..., min_length=8) # ── Helpers ─────────────────────────────────────────────────────────────────── def _hash_token(raw: str) -> str: """HMAC-SHA256 hash of a reset token using the app SECRET_KEY.""" return hmac.new( cfg.SECRET_KEY.encode(), raw.encode(), hashlib.sha256, ).hexdigest() def _validate_password_strength(password: str) -> None: """Enforce consistent password policy across all auth flows.""" if len(password) < 8: raise HTTPException(400, "Password must be at least 8 characters") if not any(c.isupper() for c in password): raise HTTPException(400, "Password must contain at least one uppercase letter") if not any(c.islower() for c in password): raise HTTPException(400, "Password must contain at least one lowercase letter") if not any(c.isdigit() for c in password): raise HTTPException(400, "Password must contain at least one digit") # ── Activate Account (Post-Stripe Payment) ──────────────────────────────────── @router.post("/activate", status_code=201) async def activate_account( body: ActivateRequest, request: Request, db: AsyncSession = Depends(get_db), ): """Create an author account from a verified Stripe Checkout session. Called by the Next.js /success page after Stripe redirects back. This is the ONLY way to create an author account — card required upfront. Flow: 1. Verify the Stripe session_id is real and payment_status == "paid" 2. Extract email from Stripe session 3. Create User + ClientAccess records 4. Return JWT tokens so the author is immediately logged in """ _validate_password_strength(body.password) password_hash = _hash_password(body.password) svc = bs.BillingService(db) try: result = await svc.activate_from_session( stripe_session_id=body.stripe_session_id, password_hash=password_hash, ) except ValueError as e: raise HTTPException(400, str(e)) except Exception as e: logger.error("Account activation failed", error=str(e)) raise HTTPException(502, "Failed to verify payment. Please contact support.") if result.get("already_activated"): # Session already used — just log them in user_repo = UserRepository(db) user = await user_repo.get_by_id(result["user_id"]) if not user: raise HTTPException(404, "Account not found. Please contact support.") tokens = _create_token_pair(user.id, extra={"role": user.role}) return { **tokens, "email": user.email, "author_slug": user.id, "plan_id": user.stripe_plan_id, "already_existed": True, } # Send welcome email (non-blocking) try: email_svc = EmailService() email_svc.send_welcome(result["email"], body.full_name or "Author") except Exception as e: logger.warning("Welcome email failed", error=str(e)) tokens = _create_token_pair(result["user_id"], extra={"role": "author"}) return { **tokens, "email": result["email"], "author_slug": result["user_id"], "plan_id": result["plan_id"], "already_existed": False, } # ── Forgot Password ─────────────────────────────────────────────────────────── @router.post("/forgot-password", status_code=200) async def forgot_password( body: ForgotPasswordRequest, request: Request, db: AsyncSession = Depends(get_db), ): """Send a password reset email. Always returns 200 regardless of whether the email exists (prevents email enumeration attacks). """ user_repo = UserRepository(db) user = await user_repo.get_by_email(body.email.lower().strip()) if user: # Generate a cryptographically secure reset token raw_token = secrets.token_urlsafe(32) token_hash = _hash_token(raw_token) now = datetime.now(timezone.utc) # Invalidate any previous unused tokens for this user old_tokens_result = await db.execute( select(EmailVerification).where( EmailVerification.user_id == user.id, EmailVerification.purpose == "password_reset", EmailVerification.used_at.is_(None), ) ) for old in old_tokens_result.scalars(): old.used_at = now # Mark as used (invalidated) # Create new token verification = EmailVerification( user_id=user.id, token_hash=token_hash, purpose="password_reset", created_at=now, expires_at=now + timedelta(minutes=cfg.PASSWORD_RESET_EXPIRE_MINUTES), requested_from_ip=request.client.host if request.client else None, ) db.add(verification) await db.commit() # Send reset email reset_url = f"{cfg.FRONTEND_URL}/reset-password?token={raw_token}" try: email_svc = EmailService() email_svc.send_password_reset(user.email, reset_url) except Exception as e: logger.error("Password reset email failed", user_id=user.id, error=str(e)) logger.info("Password reset requested", user_id=user.id) else: logger.info("Password reset for unknown email (silently ignored)", email=body.email) # Always return the same response — prevents email enumeration return {"message": "If an account exists, a reset link has been sent."} # ── Reset Password ──────────────────────────────────────────────────────────── @router.post("/reset-password", status_code=200) async def reset_password( body: ResetPasswordRequest, db: AsyncSession = Depends(get_db), ): """Consume a reset token and set a new password. Token is single-use and expires after PASSWORD_RESET_EXPIRE_MINUTES. """ _validate_password_strength(body.new_password) token_hash = _hash_token(body.token) # Find the verification record result = await db.execute( select(EmailVerification).where( EmailVerification.token_hash == token_hash, EmailVerification.purpose == "password_reset", ) ) verification = result.scalar_one_or_none() if not verification: raise HTTPException(400, "Invalid or expired reset link. Please request a new one.") if not verification.is_valid: if verification.is_expired: raise HTTPException(400, "Reset link has expired. Please request a new one.") raise HTTPException(400, "Reset link has already been used.") # Get the user user = await db.get(User, verification.user_id) if not user or not user.is_active: raise HTTPException(400, "Account not found or inactive.") # Update password user.password_hash = _hash_password(body.new_password) user.failed_login_attempts = 0 user.locked_until = None # Mark token as used (single-use enforcement) verification.used_at = datetime.now(timezone.utc) await db.commit() logger.info("Password reset successfully", user_id=user.id) return {"message": "Password updated successfully. You can now log in."}