Spaces:
Running
Running
| """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) ββββββββββββββββββββββββββββββββββββ | |
| 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 βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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."} | |