Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.datetime_utils import ensure_utc, utc_now
|
||||
from app.core.redis import check_rate_limit, client_ip
|
||||
from app.modules.auth.schemas import (
|
||||
ForgotPasswordIn,
|
||||
LoginIn,
|
||||
LoginOut,
|
||||
RegisterIn,
|
||||
ResendVerificationIn,
|
||||
ResetPasswordIn,
|
||||
VerifyEmailIn,
|
||||
)
|
||||
from app.modules.auth.service import (
|
||||
forgot_password,
|
||||
login,
|
||||
logout,
|
||||
refresh,
|
||||
register,
|
||||
resend_verification,
|
||||
reset_password,
|
||||
verify_email_token,
|
||||
)
|
||||
from app.modules.users import repository
|
||||
|
||||
router = APIRouter()
|
||||
optional_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def _allowed_origins() -> set[str]:
|
||||
origins: set[str] = set()
|
||||
for raw in (settings.frontend_url, settings.public_base_url, *settings.cors_origins):
|
||||
if not raw:
|
||||
continue
|
||||
normalized = raw.rstrip("/")
|
||||
origins.add(normalized)
|
||||
if "://localhost" in normalized:
|
||||
origins.add(normalized.replace("://localhost", "://127.0.0.1"))
|
||||
if "://127.0.0.1" in normalized:
|
||||
origins.add(normalized.replace("://127.0.0.1", "://localhost"))
|
||||
return origins
|
||||
|
||||
|
||||
def _enforce_origin(request: Request, *, require_header: bool = False) -> None:
|
||||
allowed = _allowed_origins()
|
||||
origin = (request.headers.get("Origin") or "").rstrip("/")
|
||||
referer = request.headers.get("Referer") or ""
|
||||
if require_header and not origin and not referer:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN")
|
||||
if origin and origin not in allowed:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN")
|
||||
if not origin and referer:
|
||||
if not any(referer.startswith(base) for base in allowed):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN")
|
||||
|
||||
|
||||
def _set_refresh_cookie(response: Response, refresh_token: str) -> None:
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=refresh_token,
|
||||
httponly=True,
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
path="/api/v1/auth",
|
||||
max_age=settings.jwt_refresh_ttl_days * 24 * 60 * 60,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register_route(payload: RegisterIn, request: Request):
|
||||
_enforce_origin(request)
|
||||
check_rate_limit(f"register:{client_ip(request)}", limit=3, window_seconds=3600)
|
||||
user = register(payload.email, payload.password)
|
||||
_ = user
|
||||
return {"message": "If email is valid, verification has been sent."}
|
||||
|
||||
|
||||
@router.post("/verify-email")
|
||||
async def verify_email_route(payload: VerifyEmailIn):
|
||||
try:
|
||||
user = verify_email_token(payload.token)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "TOKEN_EXPIRED":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOKEN_EXPIRED")
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="INVALID_TOKEN")
|
||||
_ = user
|
||||
return {"status": "active"}
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginOut)
|
||||
async def login_route(payload: LoginIn, request: Request, response: Response):
|
||||
_enforce_origin(request)
|
||||
user = repository.get_user_by_email(payload.email)
|
||||
if user:
|
||||
locked_until = ensure_utc(user.locked_until)
|
||||
if locked_until and locked_until > utc_now():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="ACCOUNT_TEMPORARILY_LOCKED",
|
||||
)
|
||||
check_rate_limit(
|
||||
f"login:{client_ip(request)}:{payload.email.lower()}",
|
||||
limit=5,
|
||||
window_seconds=60,
|
||||
)
|
||||
try:
|
||||
access_token, refresh_token, user = login(payload.email, payload.password)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_CREDENTIALS")
|
||||
except PermissionError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "EMAIL_NOT_VERIFIED":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED")
|
||||
if detail == "ACCOUNT_TEMPORARILY_LOCKED":
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="ACCOUNT_TEMPORARILY_LOCKED")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED")
|
||||
|
||||
_set_refresh_cookie(response, refresh_token)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"expires_in": settings.jwt_access_ttl_min * 60,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"is_superuser": user.is_superuser,
|
||||
"status": user.status,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=LoginOut)
|
||||
async def refresh_route(request: Request, response: Response):
|
||||
refresh_token = request.cookies.get("refresh_token")
|
||||
if not refresh_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="REFRESH_MISSING")
|
||||
_enforce_origin(request, require_header=True)
|
||||
check_rate_limit(f"refresh:{client_ip(request)}", limit=30, window_seconds=60)
|
||||
try:
|
||||
access_token, new_refresh, user = refresh(refresh_token)
|
||||
except PermissionError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "EMAIL_NOT_VERIFIED":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED")
|
||||
if detail == "ACCOUNT_BLOCKED":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_REFRESH")
|
||||
_set_refresh_cookie(response, new_refresh)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"expires_in": settings.jwt_access_ttl_min * 60,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"is_superuser": user.is_superuser,
|
||||
"status": user.status,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout_route(
|
||||
request: Request,
|
||||
response: Response,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
|
||||
):
|
||||
_enforce_origin(request, require_header=True)
|
||||
refresh_token = request.cookies.get("refresh_token")
|
||||
access_token = credentials.credentials if credentials else None
|
||||
logout(refresh_token, access_token)
|
||||
response.delete_cookie(
|
||||
"refresh_token",
|
||||
path="/api/v1/auth",
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
)
|
||||
return {"message": "logged_out"}
|
||||
|
||||
|
||||
@router.post("/forgot-password")
|
||||
async def forgot_password_route(payload: ForgotPasswordIn, request: Request):
|
||||
_enforce_origin(request)
|
||||
check_rate_limit(
|
||||
f"forgot:{client_ip(request)}:{payload.email.lower()}",
|
||||
limit=3,
|
||||
window_seconds=3600,
|
||||
)
|
||||
forgot_password(payload.email)
|
||||
return {"message": "If email is registered, reset instructions have been sent."}
|
||||
|
||||
|
||||
@router.post("/reset-password")
|
||||
async def reset_password_route(payload: ResetPasswordIn):
|
||||
try:
|
||||
reset_password(payload.token, payload.new_password)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "TOKEN_EXPIRED":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOKEN_EXPIRED")
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="INVALID_TOKEN")
|
||||
return {"message": "password_updated"}
|
||||
|
||||
|
||||
@router.post("/resend-verification")
|
||||
async def resend_verification_route(payload: ResendVerificationIn, request: Request):
|
||||
_enforce_origin(request)
|
||||
check_rate_limit(
|
||||
f"resend:{client_ip(request)}:{payload.email.lower()}",
|
||||
limit=3,
|
||||
window_seconds=3600,
|
||||
)
|
||||
resend_verification(payload.email)
|
||||
return {"message": "If email is registered, verification has been sent."}
|
||||
Reference in New Issue
Block a user