Files
mrleo1nidandClaude Sonnet 5.5 bbbcc6a5d8 chore: add CI, lint config, login rate limit and split large modules
- 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>
2026-10-25 11:23:22 +03:00

89 lines
3.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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