Files
verify/backend/app/routers/auth.py
Mohamed Mathar Irfan ed6610d5d8 Initial project upload
2026-07-28 17:57:02 +05:30

234 lines
8.2 KiB
Python

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}")