From 049b679654206a2c9ba9b969a9dc0a2ef5493832 Mon Sep 17 00:00:00 2001 From: Kavalar Date: Sat, 26 Sep 2026 19:52:32 +0300 Subject: [PATCH] feat(auth): one-time login links, JWT + refresh, roles, service token, tests --- .gitignore | 2 + backend/app/api/deps.py | 74 +++++++++++++ backend/app/api/v1/auth.py | 60 +++++++++++ backend/app/api/v1/router.py | 3 +- backend/app/core/config.py | 1 + backend/app/core/security.py | 36 +++++++ backend/app/schemas/__init__.py | 0 backend/app/schemas/auth.py | 62 +++++++++++ backend/app/services/__init__.py | 0 backend/app/services/auth.py | 173 +++++++++++++++++++++++++++++++ backend/tests/conftest.py | 35 +++++++ backend/tests/test_auth.py | 108 +++++++++++++++++++ 12 files changed, 553 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/deps.py create mode 100644 backend/app/api/v1/auth.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/auth.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_auth.py diff --git a/.gitignore b/.gitignore index 59a8e11..c4e4fe5 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ Thumbs.db # Data *.log +*.db +*.sqlite3 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..d30baf0 --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,74 @@ +"""Зависимости FastAPI: аутентификация и авторизация.""" +from fastapi import Depends, Header, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select +from sqlalchemy.orm import Session as DBSession + +from app.core.config import settings +from app.core.security import decode_access_token +from app.db.session import get_db +from app.models.enums import RoleCode +from app.models.user import Role, User, UserRole +from app.models.user import Session as UserSession + +bearer = HTTPBearer(auto_error=False) + + +def get_current_session( + credentials: HTTPAuthorizationCredentials | None = Depends(bearer), + db: DBSession = Depends(get_db), +) -> UserSession: + """Текущая сессия по access-токену.""" + if not credentials: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Требуется авторизация") + try: + payload = decode_access_token(credentials.credentials) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Недействительный токен") from exc + + session = db.get(UserSession, payload.get("sid")) + if not session or session.revoked_at: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Сессия завершена") + return session + + +def get_current_user( + session: UserSession = Depends(get_current_session), + db: DBSession = Depends(get_db), +) -> User: + """Текущий пользователь.""" + user = db.get(User, session.user_id) + if not user: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Пользователь не найден") + return user + + +def get_user_role_codes(db: DBSession, user_id: int) -> set[RoleCode]: + """Множество ролей пользователя.""" + rows = db.scalars(select(UserRole).where(UserRole.user_id == user_id)).all() + codes: set[RoleCode] = set() + for row in rows: + role = db.get(Role, row.role_id) + if role: + codes.add(role.code) + return codes + + +def require_roles(*codes: RoleCode): + """Фабрика зависимости: доступ только для указанных ролей.""" + + def checker( + user: User = Depends(get_current_user), + db: DBSession = Depends(get_db), + ) -> User: + if not get_user_role_codes(db, user.id).intersection(set(codes)): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Недостаточно прав") + return user + + return checker + + +def require_service_token(x_service_token: str | None = Header(default=None)) -> None: + """Проверка сервисного токена бота.""" + if not settings.bot_service_token or x_service_token != settings.bot_service_token: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Недействительный сервисный токен") diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..e861f32 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -0,0 +1,60 @@ +"""Эндпоинты аутентификации.""" +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session as DBSession + +from app.api.deps import get_current_session, get_current_user, require_roles +from app.db.session import get_db +from app.models.enums import RoleCode +from app.models.user import User +from app.models.user import Session as UserSession +from app.schemas.auth import ( + LoginRequest, + LoginResponse, + LoginTokenCreate, + LoginTokenOut, + RefreshRequest, + TokenPair, + UserOut, +) +from app.services import auth as auth_service + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/login-token", response_model=LoginTokenOut, status_code=201) +def create_login_token( + data: LoginTokenCreate, + user: User = Depends(require_roles(RoleCode.COORDINATOR, RoleCode.ADMIN)), + db: DBSession = Depends(get_db), +) -> LoginTokenOut: + lt, url = auth_service.create_login_token(db, data, issued_by=user.id) + return LoginTokenOut(id=lt.id, url=url, expires_at=lt.expires_at) + + +@router.post("/login", response_model=LoginResponse) +def login(data: LoginRequest, db: DBSession = Depends(get_db)) -> LoginResponse: + tokens, user = auth_service.login(db, data) + return LoginResponse( + **tokens.model_dump(), user=auth_service.build_user_out(db, user) + ) + + +@router.post("/refresh", response_model=TokenPair) +def refresh(data: RefreshRequest, db: DBSession = Depends(get_db)) -> TokenPair: + return auth_service.refresh(db, data.refresh_token) + + +@router.post("/logout", status_code=204) +def logout( + session: UserSession = Depends(get_current_session), + db: DBSession = Depends(get_db), +) -> None: + auth_service.logout(db, session.id) + + +@router.get("/me", response_model=UserOut) +def me( + user: User = Depends(get_current_user), + db: DBSession = Depends(get_db), +) -> UserOut: + return auth_service.build_user_out(db, user) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index efa32ff..9343f19 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,7 +1,8 @@ """Корневой роутер API v1.""" from fastapi import APIRouter -from app.api.v1 import health +from app.api.v1 import auth, health api_router = APIRouter() api_router.include_router(health.router, tags=["system"]) +api_router.include_router(auth.router) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 377a54d..fb5afae 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -26,6 +26,7 @@ class Settings(BaseSettings): # CORS cors_origins: list[str] = ["https://opora.my-dpr.ru"] + frontend_base_url: str = "https://opora.my-dpr.ru" # MinIO / S3 s3_endpoint: str = "http://minio:9000" diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..53fba7e --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,36 @@ +"""Утилиты безопасности: JWT и хеширование токенов.""" +import hashlib +import secrets +from datetime import datetime, timedelta, timezone + +from jose import jwt + +from app.core.config import settings + + +def create_access_token(user_id: int, session_id: str) -> str: + """Создать access-токен (JWT).""" + now = datetime.now(timezone.utc) + payload = { + "sub": str(user_id), + "sid": session_id, + "type": "access", + "iat": int(now.timestamp()), + "exp": int((now + timedelta(days=settings.access_token_ttl_days)).timestamp()), + } + return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) + + +def decode_access_token(token: str) -> dict: + """Декодировать и проверить access-токен.""" + return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]) + + +def generate_opaque_token() -> str: + """Сгенерировать случайный opaque-токен (refresh, одноразовая ссылка).""" + return secrets.token_urlsafe(48) + + +def hash_token(token: str) -> str: + """SHA-256 хеш токена (для хранения в БД).""" + return hashlib.sha256(token.encode()).hexdigest() diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..8a7ea7f --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,62 @@ +"""Pydantic-схемы аутентификации.""" +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from app.models.enums import MessengerType + + +class LoginTokenCreate(BaseModel): + region_id: int | None = None + user_id: int | None = None + ttl_minutes: int = 60 + + +class LoginTokenOut(BaseModel): + id: str + url: str + expires_at: datetime + + +class LoginRequest(BaseModel): + token: str + messenger_type: MessengerType + messenger_id: str + full_name: str | None = None + + +class RefreshRequest(BaseModel): + refresh_token: str + + +class TokenPair(BaseModel): + access_token: str + refresh_token: str + expires_at: datetime + + +class RegionOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + is_primary: bool + + +class RoleOut(BaseModel): + code: str + region_id: int | None = None + + +class UserOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + full_name: str | None = None + member_type: str | None = None + regions: list[RegionOut] = [] + roles: list[RoleOut] = [] + + +class LoginResponse(TokenPair): + user: UserOut diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..6d98eff --- /dev/null +++ b/backend/app/services/auth.py @@ -0,0 +1,173 @@ +"""Сервис аутентификации: одноразовые ссылки, вход, refresh, выход.""" +from datetime import datetime, timedelta, timezone +from uuid import uuid4 + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.orm import Session as DBSession + +from app.core.config import settings +from app.core.security import create_access_token, generate_opaque_token, hash_token +from app.models.enums import LoginTokenStatus +from app.models.user import LoginToken, Role, User, UserRegion, UserRole +from app.models.user import Session as UserSession +from app.schemas.auth import ( + LoginRequest, + LoginTokenCreate, + RegionOut, + RoleOut, + TokenPair, + UserOut, +) + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _as_aware(dt: datetime | None) -> datetime | None: + """Привести naive datetime (например, из SQLite) к aware UTC.""" + if dt is not None and dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt + + +def create_login_token( + db: DBSession, data: LoginTokenCreate, issued_by: int | None +) -> tuple[LoginToken, str]: + """Создать одноразовую ссылку входа.""" + token = generate_opaque_token() + expires_at = _now() + timedelta(minutes=data.ttl_minutes) + lt = LoginToken( + id=str(uuid4()), + token=token, + user_id=data.user_id, + region_id=data.region_id, + issued_by=issued_by, + expires_at=expires_at, + ) + db.add(lt) + db.commit() + db.refresh(lt) + url = f"{settings.frontend_base_url}/login?token={token}" + return lt, url + + +def _issue_tokens(db: DBSession, user: User, messenger_type) -> TokenPair: + refresh = generate_opaque_token() + session = UserSession( + id=str(uuid4()), + user_id=user.id, + token_hash=hash_token(refresh), + messenger_type=messenger_type, + expires_at=_now() + timedelta(days=settings.refresh_token_ttl_days), + ) + db.add(session) + db.commit() + access = create_access_token(user.id, session.id) + access_exp = _now() + timedelta(days=settings.access_token_ttl_days) + return TokenPair(access_token=access, refresh_token=refresh, expires_at=access_exp) + + +def login(db: DBSession, data: LoginRequest) -> tuple[TokenPair, User]: + """Вход по одноразовой ссылке.""" + lt = db.scalar(select(LoginToken).where(LoginToken.token == data.token)) + if not lt: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Ссылка не найдена") + + now = _now() + if lt.status == LoginTokenStatus.USED: + raise HTTPException( + status.HTTP_409_CONFLICT, + "Ссылка уже была использована. Запросите новую у координатора", + ) + if lt.status != LoginTokenStatus.ACTIVE or _as_aware(lt.expires_at) < now: + lt.status = LoginTokenStatus.EXPIRED + db.commit() + raise HTTPException(status.HTTP_410_GONE, "Ссылка истекла") + + user = db.scalar( + select(User).where( + User.messenger_type == data.messenger_type, + User.messenger_id == data.messenger_id, + ) + ) + if not user: + user = User( + messenger_type=data.messenger_type, + messenger_id=data.messenger_id, + full_name=data.full_name, + ) + db.add(user) + db.flush() + elif data.full_name and not user.full_name: + user.full_name = data.full_name + + if lt.region_id: + linked = db.scalar( + select(UserRegion).where( + UserRegion.user_id == user.id, UserRegion.region_id == lt.region_id + ) + ) + if not linked: + db.add(UserRegion(user_id=user.id, region_id=lt.region_id, is_primary=True)) + + lt.status = LoginTokenStatus.USED + lt.used_at = now + db.commit() + + tokens = _issue_tokens(db, user, data.messenger_type) + return tokens, user + + +def refresh(db: DBSession, refresh_token: str) -> TokenPair: + """Обновить access-токен (с ротацией refresh).""" + session = db.scalar( + select(UserSession).where(UserSession.token_hash == hash_token(refresh_token)) + ) + now = _now() + if not session or session.revoked_at or _as_aware(session.expires_at) < now: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Недействительный refresh-токен") + + new_refresh = generate_opaque_token() + session.token_hash = hash_token(new_refresh) + db.commit() + + access = create_access_token(session.user_id, session.id) + access_exp = now + timedelta(days=settings.access_token_ttl_days) + return TokenPair(access_token=access, refresh_token=new_refresh, expires_at=access_exp) + + +def logout(db: DBSession, session_id: str) -> None: + """Завершить сессию.""" + session = db.get(UserSession, session_id) + if session and not session.revoked_at: + session.revoked_at = _now() + db.commit() + + +def build_user_out(db: DBSession, user: User) -> UserOut: + """Собрать профиль пользователя с регионами и ролями.""" + from app.models.region import Region + + links = db.scalars(select(UserRegion).where(UserRegion.user_id == user.id)).all() + regions = [] + for link in links: + region = db.get(Region, link.region_id) + if region: + regions.append(RegionOut(id=region.id, name=region.name, is_primary=link.is_primary)) + + role_rows = db.scalars(select(UserRole).where(UserRole.user_id == user.id)).all() + roles = [] + for r in role_rows: + role = db.get(Role, r.role_id) + if role: + roles.append(RoleOut(code=role.code.value, region_id=r.region_id)) + + return UserOut( + id=user.id, + full_name=user.full_name, + member_type=user.member_type.value if user.member_type else None, + regions=regions, + roles=roles, + ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..627b610 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,35 @@ +"""Общие фикстуры для тестов.""" +import os + +os.environ.setdefault("DATABASE_URL", "sqlite:///./_test.db") +os.environ.setdefault("JWT_SECRET", "test-secret") +os.environ.setdefault("BOT_SERVICE_TOKEN", "svc-token") + +import pytest # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +import app.models # noqa: E402,F401 +from app.db.session import Base, SessionLocal, engine # noqa: E402 +from app.main import app # noqa: E402 + + +@pytest.fixture(scope="session", autouse=True) +def _setup_db(): + Base.metadata.drop_all(engine) + Base.metadata.create_all(engine) + yield + Base.metadata.drop_all(engine) + + +@pytest.fixture() +def db(): + session = SessionLocal() + try: + yield session + finally: + session.close() + + +@pytest.fixture() +def client(): + return TestClient(app) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..afdad7e --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,108 @@ +"""Тесты аутентификации.""" +from datetime import datetime, timedelta, timezone +from uuid import uuid4 + +from app.models.enums import MessengerType, RoleCode +from app.models.user import LoginToken, Role, User, UserRole + + +def _seed_admin(db) -> User: + for code, name in [(RoleCode.ADMIN, "Админ"), (RoleCode.COORDINATOR, "Координатор")]: + if not db.query(Role).filter(Role.code == code).first(): + db.add(Role(code=code, name=name)) + db.commit() + + admin = ( + db.query(User) + .filter(User.messenger_type == MessengerType.TELEGRAM, User.messenger_id == "1") + .first() + ) + if not admin: + admin = User(messenger_type=MessengerType.TELEGRAM, messenger_id="1", full_name="Админ") + db.add(admin) + db.flush() + role = db.query(Role).filter(Role.code == RoleCode.ADMIN).first() + if not db.query(UserRole).filter( + UserRole.user_id == admin.id, UserRole.role_id == role.id + ).first(): + db.add(UserRole(user_id=admin.id, role_id=role.id)) + db.commit() + return admin + + +def _make_login_token(db, value: str) -> None: + db.add( + LoginToken( + id=str(uuid4()), + token=value, + expires_at=datetime.now(timezone.utc) + timedelta(minutes=30), + ) + ) + db.commit() + + +def test_login_flow(client, db): + _seed_admin(db) + _make_login_token(db, "tok-1") + + # вход + r = client.post( + "/api/v1/auth/login", + json={"token": "tok-1", "messenger_type": "telegram", "messenger_id": "1"}, + ) + assert r.status_code == 200, r.text + data = r.json() + assert data["user"]["roles"][0]["code"] == "admin" + access, refresh = data["access_token"], data["refresh_token"] + + # повторное использование ссылки + r2 = client.post( + "/api/v1/auth/login", + json={"token": "tok-1", "messenger_type": "telegram", "messenger_id": "1"}, + ) + assert r2.status_code == 409 + + # профиль + r3 = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {access}"}) + assert r3.status_code == 200 + + # выдача новой ссылки (как админ) + r4 = client.post( + "/api/v1/auth/login-token", + json={"ttl_minutes": 15}, + headers={"Authorization": f"Bearer {access}"}, + ) + assert r4.status_code == 201, r4.text + assert "token=" in r4.json()["url"] + + # refresh + r5 = client.post("/api/v1/auth/refresh", json={"refresh_token": refresh}) + assert r5.status_code == 200, r5.text + new_access = r5.json()["access_token"] + + # logout + r6 = client.post("/api/v1/auth/logout", headers={"Authorization": f"Bearer {new_access}"}) + assert r6.status_code == 204 + + # после logout + r7 = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {new_access}"}) + assert r7.status_code == 401 + + +def test_login_token_requires_role(client, db): + _seed_admin(db) + _make_login_token(db, "tok-2") + r = client.post( + "/api/v1/auth/login", + json={"token": "tok-2", "messenger_type": "telegram", "messenger_id": "999"}, + ) + assert r.status_code == 200 + access = r.json()["access_token"] + + # обычный пользователь без роли не может выдавать ссылки + r2 = client.post( + "/api/v1/auth/login-token", + json={"ttl_minutes": 15}, + headers={"Authorization": f"Bearer {access}"}, + ) + assert r2.status_code == 403