feat(auth): one-time login links, JWT + refresh, roles, service token, tests

This commit is contained in:
apuc committed 2026-09-26 19:52:32 +03:00
1 parent 4a610ba94a
commit 049b679654
12 files changed
+553 -1

No files matched your search

+74
View File
@@ -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, "Недействительный сервисный токен")
+60
View File
@@ -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)
+2 -1
View File
@@ -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)
+1
View File
@@ -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"
+36
View File
@@ -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()
View File
Whitespace-only changes.
+62
View File
@@ -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
View File
Whitespace-only changes.
+173
View File
@@ -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,
)