Arag / app /api /auth_public.py
AuthorBot
Add enterprise growth APIs, billing, admin UI, and test suite.
12930b5
Raw
History Blame Contribute Delete
9.64 kB
"""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."}