- FastAPI backend: admin bootstrap with forced credential change, admin-created users with one-time generated passwords, roles as permission sets - Games and versions with content-addressed blob storage; forks copy only the manifest, so mods share assets with the original - Worker: archive/RPA extraction, unrpyc for .rpyc, own .rpy parser, Python subset translated via ast, media converted to WebP/Opus/WebM - React panel (library search, game page, upload, users, roles) and a framework-free web player with saves, history, skip/auto, transitions - docker compose: postgres, backend, worker, nginx on a single HTTP port Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
79 lines
2.9 KiB
Python
79 lines
2.9 KiB
Python
from fastapi import APIRouter, HTTPException, 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.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, response: Response, db: DB) -> User:
|
|
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):
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Неверный логин или пароль")
|
|
_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
|