import os from datetime import timedelta from typing import Tuple from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from app.api.dependencies import get_db from app.core.security import ( create_access_token, generate_verification_code, hash_password, hash_verification_code, verify_password, verify_verification_code, ) from app.models.models import AppUser, EmailVerificationCode, VerificationPurpose, utcnow from app.schemas.auth_schema import ( AuthTokenResponse, LoginCodeSendRequest, LoginRequest, LoginWithCodeRequest, MessageResponse, RegisterCodeSendRequest, RegisterRequest, UserProfileResponse, ) from app.utils.email_utils import send_html_email router = APIRouter() REGISTER_CODE_EXPIRE_MINUTES = int(os.getenv("REGISTER_CODE_EXPIRE_MINUTES", "10")) LOGIN_CODE_EXPIRE_MINUTES = int(os.getenv("LOGIN_CODE_EXPIRE_MINUTES", "10")) def _normalize_email(email: str) -> str: return email.strip().lower() def _build_verification_email(code: str, purpose_text: str, expire_minutes: int) -> str: return f"""

InsightRadar Email Verification

Your {purpose_text} verification code is:

{code}

The code is valid for {expire_minutes} minutes. Do not share it with others.

""" def _invalidate_unused_codes(db: Session, email: str, purpose: VerificationPurpose) -> None: db.query(EmailVerificationCode).filter( EmailVerificationCode.email == email, EmailVerificationCode.purpose == purpose, EmailVerificationCode.is_used.is_(False), ).update({EmailVerificationCode.is_used: True}, synchronize_session=False) db.commit() def _create_code_record( db: Session, *, email: str, purpose: VerificationPurpose, expire_minutes: int, ) -> Tuple[EmailVerificationCode, str]: code = generate_verification_code() now = utcnow() code_record = EmailVerificationCode( email=email, purpose=purpose, code_hash=hash_verification_code(code), expires_at=now + timedelta(minutes=expire_minutes), ) db.add(code_record) db.commit() return code_record, code def _build_auth_response(user: AppUser) -> AuthTokenResponse: token, expires_in = create_access_token(user_id=user.id, email=user.email) return AuthTokenResponse( access_token=token, expires_in=expires_in, user=UserProfileResponse.model_validate(user), ) @router.post("/register/send-code", response_model=MessageResponse) async def send_register_code(payload: RegisterCodeSendRequest, db: Session = Depends(get_db)): email = _normalize_email(payload.email) existing_user = db.query(AppUser).filter(AppUser.email == email).first() if existing_user: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered") _invalidate_unused_codes(db, email, VerificationPurpose.REGISTER) code_record, code = _create_code_record( db, email=email, purpose=VerificationPurpose.REGISTER, expire_minutes=REGISTER_CODE_EXPIRE_MINUTES, ) email_sent = await send_html_email( to_email=email, subject="InsightRadar Registration Code", html_content=_build_verification_email(code, "registration", REGISTER_CODE_EXPIRE_MINUTES), ) if not email_sent: code_record.is_used = True db.add(code_record) db.commit() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to send verification code", ) return MessageResponse(message="Verification code sent") @router.post("/login/send-code", response_model=MessageResponse) async def send_login_code(payload: LoginCodeSendRequest, db: Session = Depends(get_db)): email = _normalize_email(payload.email) user = db.query(AppUser).filter(AppUser.email == email).first() if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Email is not registered") _invalidate_unused_codes(db, email, VerificationPurpose.LOGIN) code_record, code = _create_code_record( db, email=email, purpose=VerificationPurpose.LOGIN, expire_minutes=LOGIN_CODE_EXPIRE_MINUTES, ) email_sent = await send_html_email( to_email=email, subject="InsightRadar Login Code", html_content=_build_verification_email(code, "login", LOGIN_CODE_EXPIRE_MINUTES), ) if not email_sent: code_record.is_used = True db.add(code_record) db.commit() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to send verification code", ) return MessageResponse(message="Verification code sent") @router.post( "/register", response_model=AuthTokenResponse, status_code=status.HTTP_201_CREATED, ) async def register(payload: RegisterRequest, db: Session = Depends(get_db)): email = _normalize_email(payload.email) existing_user = db.query(AppUser).filter(AppUser.email == email).first() if existing_user: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered") now = utcnow() code_record = db.query(EmailVerificationCode).filter( EmailVerificationCode.email == email, EmailVerificationCode.purpose == VerificationPurpose.REGISTER, EmailVerificationCode.is_used.is_(False), EmailVerificationCode.expires_at >= now, ).order_by(EmailVerificationCode.created_at.desc()).first() if not code_record: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code does not exist or expired") if not verify_verification_code(payload.verification_code, code_record.code_hash): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification code") nickname = payload.nickname or email.split("@")[0] user = AppUser( email=email, password_hash=hash_password(payload.password), nickname=nickname, metadata_={"email_verified_at": now.isoformat()}, ) code_record.is_used = True db.add(user) db.add(code_record) db.commit() db.refresh(user) return _build_auth_response(user) @router.post("/login", response_model=AuthTokenResponse) async def login(payload: LoginRequest, db: Session = Depends(get_db)): email = _normalize_email(payload.email) user = db.query(AppUser).filter(AppUser.email == email).first() if not user or not user.password_hash: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password") if not verify_password(payload.password, user.password_hash): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password") return _build_auth_response(user) @router.post("/login/code", response_model=AuthTokenResponse) async def login_with_code(payload: LoginWithCodeRequest, db: Session = Depends(get_db)): email = _normalize_email(payload.email) user = db.query(AppUser).filter(AppUser.email == email).first() if not user: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code") now = utcnow() code_record = db.query(EmailVerificationCode).filter( EmailVerificationCode.email == email, EmailVerificationCode.purpose == VerificationPurpose.LOGIN, EmailVerificationCode.is_used.is_(False), EmailVerificationCode.expires_at >= now, ).order_by(EmailVerificationCode.created_at.desc()).first() if not code_record: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code does not exist or expired") if not verify_verification_code(payload.verification_code, code_record.code_hash): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code") code_record.is_used = True db.add(code_record) db.commit() return _build_auth_response(user)