234 lines
8.2 KiB
Python
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}")
|