import secrets from datetime import timedelta, timezone from typing import Optional from fastapi import APIRouter, Body, HTTPException, status, Request from fastapi.responses import RedirectResponse from app.core.config import settings from app.core.security import ( hash_password, verify_password, create_access_token, create_token, decode_token, ) from app.models import User, AuditLog from app.models.documents import now from app.schemas.auth import ( SignupIn, LoginIn, TokenOut, ForgotIn, ResetIn, MessageOut, ) from app.services.email import send_verification_email, send_reset_email,send_email router = APIRouter(prefix="/auth", tags=["auth"]) def _new_code() -> str: return f"{secrets.randbelow(1000000):06d}" async def _issue_verification(user: User): """Generate a 6-digit code + signed link token and email both.""" code = _new_code() user.verify_code = code user.verify_code_expires = now() + timedelta(minutes=30) await user.save() token = create_token(str(user.id), "verify", 60 * 24) await send_verification_email(user.email, user.name, token, code) @router.post("/signup", response_model=TokenOut, status_code=201) async def signup(body: SignupIn): if await User.find_one(User.email == body.email): raise HTTPException(status.HTTP_409_CONFLICT, "Email already registered") user = User(name=body.name, email=body.email, hashed_password=hash_password(body.password)) await user.insert() await _issue_verification(user) access = create_access_token(str(user.id), {"v": user.email_verified}) return TokenOut(access_token=access, email_verified=user.email_verified, name=user.name, email=user.email) @router.post("/verify-code", response_model=MessageOut) async def verify_code(body: dict = Body(...)): """Verify email using the 6-digit code sent on signup.""" email = (body.get("email") or "").strip().lower() code = (body.get("code") or "").strip() user = await User.find_one(User.email == email) if not user or not user.verify_code: raise HTTPException(status.HTTP_400_BAD_REQUEST, "No pending verification for this email") exp = user.verify_code_expires if exp and exp.tzinfo is None: exp = exp.replace(tzinfo=timezone.utc) if exp and exp < now(): raise HTTPException(status.HTTP_400_BAD_REQUEST, "Code expired — request a new one") if code != user.verify_code: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Incorrect code") user.email_verified = True user.verify_code = None user.verify_code_expires = None await user.save() return MessageOut(message="Email verified") @router.post("/resend-code", response_model=MessageOut) async def resend_code(body: dict = Body(...)): email = (body.get("email") or "").strip().lower() user = await User.find_one(User.email == email) if user and not user.email_verified: await _issue_verification(user) return MessageOut(message="If the email is pending verification, a new code was sent") @router.post("/login") async def login(body: LoginIn): user = await User.find_one(User.email == body.email) if not user or not user.hashed_password: raise HTTPException(401, "Invalid credentials") if not verify_password(body.password, user.hashed_password): raise HTTPException(401, "Invalid credentials") if user.disabled: raise HTTPException(403, "Account disabled") otp = _new_code() user.login_otp = otp user.login_otp_expires = now() + timedelta(minutes=10) await user.save() await send_email( user.email, "Verify Login", f"Your login OTP is: {otp}", "login_otp", ) return { "message": "OTP sent", "email": user.email, } @router.post("/verify-login-otp", response_model=TokenOut) async def verify_login_otp(body: dict = Body(...)): email = body.get("email") otp = body.get("otp") user = await User.find_one(User.email == email) if not user: raise HTTPException(404, "User not found") if user.login_otp != otp: raise HTTPException(400, "Invalid OTP") exp = user.login_otp_expires if exp and exp.tzinfo is None: exp = exp.replace(tzinfo=timezone.utc) if exp and exp < now(): raise HTTPException(status.HTTP_400_BAD_REQUEST, "OTP expired") user.login_otp = None user.login_otp_expires = None user.last_login = now() await user.save() token = create_access_token( str(user.id), {"v": user.email_verified}, ) return TokenOut( access_token=token, email_verified=user.email_verified, name=user.name, email=user.email, ) @router.get("/verify-email", response_model=MessageOut) async def verify_email(token: str): payload = decode_token(token) if not payload or payload.get("type") != "verify": raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid or expired token") user = await User.get(payload["sub"]) if not user: raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") user.email_verified = True await user.save() return MessageOut(message="Email verified") @router.post("/forgot-password", response_model=MessageOut) async def forgot_password(body: ForgotIn): user = await User.find_one(User.email == body.email) if user: token = create_token(str(user.id), "reset", 60) await send_reset_email(user.email, user.name, token) # Always return success to avoid account enumeration return MessageOut(message="If the email exists, a reset link was sent") @router.post("/reset-password", response_model=MessageOut) async def reset_password(body: ResetIn): payload = decode_token(body.token) if not payload or payload.get("type") != "reset": raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid or expired token") user = await User.get(payload["sub"]) if not user: raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") user.hashed_password = hash_password(body.password) await user.save() return MessageOut(message="Password reset") # ---------- Google OAuth (env-driven; stubbed if not configured) ---------- @router.get("/google") async def google_oauth_start(): if not settings.google_configured: raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, "Google OAuth not configured (set GOOGLE_CLIENT_ID/SECRET)") url = ( "https://accounts.google.com/o/oauth2/v2/auth" f"?client_id={settings.google_client_id}" f"&redirect_uri={settings.google_redirect_uri}" "&response_type=code&scope=openid%20email%20profile" ) return RedirectResponse(url) @router.get("/google/callback") async def google_oauth_callback(code: Optional[str] = None): if not settings.google_configured: raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, "Google OAuth not configured") import httpx async with httpx.AsyncClient() as c: tok = await c.post("https://oauth2.googleapis.com/token", data={ "code": code, "client_id": settings.google_client_id, "client_secret": settings.google_client_secret, "redirect_uri": settings.google_redirect_uri, "grant_type": "authorization_code", }) if tok.status_code != 200: print("Google Token Error:") print(tok.status_code) print(tok.text) raise HTTPException(400, tok.text) access = tok.json()["access_token"] info = await c.get("https://www.googleapis.com/oauth2/v2/userinfo", headers={"Authorization": f"Bearer {access}"}) profile = info.json() user = await User.find_one(User.email == profile["email"]) if not user: user = User(name=profile.get("name", profile["email"]), email=profile["email"], google_id=profile["id"], avatar_url=profile.get("picture"), email_verified=True) await user.insert() user.last_login = now() await user.save() jwt = create_access_token(str(user.id), {"v": True}) return RedirectResponse(f"{settings.frontend_url}/auth/callback?token={jwt}")