feat(auth): one-time login links, JWT + refresh, roles, service token, tests
This commit is contained in:
1 parent
4a610ba94a
commit
049b679654
12 files changed
+553
-1
No files matched your search
@@ -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, "Недействительный сервисный токен")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
Whitespace-only changes.
@@ -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
|
||||
Whitespace-only changes.
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user