Initial project upload

This commit is contained in:
Mohamed Mathar Irfan
2026-07-28 17:57:02 +05:30
commit ed6610d5d8
23919 changed files with 3003316 additions and 0 deletions

233
backend/app/routers/auth.py Normal file
View File

@@ -0,0 +1,233 @@
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}")