- Rate-limit failed logins (8 per 15 min per IP or username, 429 + Retry-After) - Add ruff/mypy config and GitHub Actions workflow (ruff, pytest, tsc, tests, build) - Apply ruff autofixes (import order, pyupgrade) - Extract rpy_helpers.py, engine-util.ts, TranslationParts.tsx and translation-types.ts from oversized modules - Add dependency-free React UI tests - Refresh README and ARCHITECTURE (Naninovel/Tomie, status and plans) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
89 lines
3.4 KiB
Python
89 lines
3.4 KiB
Python
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||
from sqlalchemy import select
|
||
|
||
from app.config import get_settings
|
||
from app.deps import DB, SessionUser
|
||
from app.models import User
|
||
from app.ratelimit import client_ip, login_limiter
|
||
from app.schemas import ChangeCredentialsIn, LoginIn, MeOut
|
||
from app.security import (
|
||
COOKIE_NAME,
|
||
create_token,
|
||
hash_password,
|
||
validate_new_password,
|
||
validate_username,
|
||
verify_password,
|
||
)
|
||
|
||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||
|
||
|
||
def _set_cookie(response: Response, user: User) -> None:
|
||
settings = get_settings()
|
||
response.set_cookie(
|
||
COOKIE_NAME,
|
||
create_token(user.id, user.token_version),
|
||
max_age=settings.session_ttl_hours * 3600,
|
||
httponly=True,
|
||
samesite="lax",
|
||
secure=settings.cookie_secure,
|
||
)
|
||
|
||
|
||
@router.post("/login", response_model=MeOut)
|
||
async def login(body: LoginIn, request: Request, response: Response, db: DB) -> User:
|
||
keys = (f"ip:{client_ip(request)}", f"user:{body.username.lower()}")
|
||
if wait := login_limiter.retry_after(*keys):
|
||
raise HTTPException(
|
||
status.HTTP_429_TOO_MANY_REQUESTS,
|
||
f"Слишком много неудачных попыток входа. Повторите через {wait} с",
|
||
headers={"Retry-After": str(wait)},
|
||
)
|
||
user = await db.scalar(select(User).where(User.username == body.username))
|
||
if not user or not user.is_active or not verify_password(user.password_hash, body.password):
|
||
login_limiter.fail(*keys)
|
||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Неверный логин или пароль")
|
||
login_limiter.reset(*keys)
|
||
_set_cookie(response, user)
|
||
return user
|
||
|
||
|
||
@router.post("/logout", status_code=204)
|
||
async def logout(response: Response) -> None:
|
||
response.delete_cookie(COOKIE_NAME)
|
||
|
||
|
||
@router.get("/me", response_model=MeOut)
|
||
async def me(user: SessionUser) -> User:
|
||
return user
|
||
|
||
|
||
@router.post("/change-credentials", response_model=MeOut)
|
||
async def change_credentials(
|
||
body: ChangeCredentialsIn, response: Response, user: SessionUser, db: DB
|
||
) -> User:
|
||
if not verify_password(user.password_hash, body.current_password):
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Текущий пароль неверен")
|
||
if body.new_password == body.current_password:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Новый пароль совпадает со старым")
|
||
if err := validate_new_password(body.new_password):
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, err)
|
||
|
||
new_username = (body.new_username or "").strip()
|
||
if user.must_change_username and (not new_username or new_username == user.username):
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Необходимо задать новый логин")
|
||
if new_username and new_username != user.username:
|
||
if err := validate_username(new_username):
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, err)
|
||
if await db.scalar(select(User.id).where(User.username == new_username)):
|
||
raise HTTPException(status.HTTP_409_CONFLICT, "Логин уже занят")
|
||
user.username = new_username
|
||
|
||
user.password_hash = hash_password(body.new_password)
|
||
user.must_change_password = False
|
||
user.must_change_username = False
|
||
user.token_version += 1
|
||
await db.commit()
|
||
_set_cookie(response, user)
|
||
return user
|