Безопасность довёл до ума — Cursor-генерацию переписал руками. IDOR закрыл, CSRF задушил, refresh rotation теперь как надо. HSTS на staging, ENABLE_DOCS=false, install.env recovery протестил. Backend: - jwt_denylist + auth_epoch: мгновенный revoke access JWT (logout/block/reset) - auth/admin/users: bump epoch, logout с Bearer, forgot_password skip для blocked - install_secrets: путь всегда apps/api/data/secrets/ (bootstrap из корня не ломает Docker) - seed: SEED_DEMO_USERS=false на prod/staging - тесты: jwt revoke, integration, coverage gate 90% Frontend: - logout шлёт Bearer, обработка TOKEN_REVOKED - guards TypeScript fix - E2E: blocked user → 401 сразу после block Infra: - staging/prod compose, TLS nginx, deploy-скрипты - k6 §17.2, backup/health/smoke scripts Docs: - docs/ на русском: project, security, deploy, release (старые md слили) - README короткий + план ТЗ + стандартные логины dev Код готов к плаванию. Капитан может идти писать фронт.
218 lines
7.8 KiB
Python
218 lines
7.8 KiB
Python
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."}
|