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
@@ -25,3 +25,5 @@ Thumbs.db
|
|||||||
|
|
||||||
# Data
|
# Data
|
||||||
*.log
|
*.log
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
@@ -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."""
|
"""Корневой роутер API v1."""
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1 import health
|
from app.api.v1 import auth, health
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(health.router, tags=["system"])
|
api_router.include_router(health.router, tags=["system"])
|
||||||
|
api_router.include_router(auth.router)
|
||||||
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
cors_origins: list[str] = ["https://opora.my-dpr.ru"]
|
cors_origins: list[str] = ["https://opora.my-dpr.ru"]
|
||||||
|
frontend_base_url: str = "https://opora.my-dpr.ru"
|
||||||
|
|
||||||
# MinIO / S3
|
# MinIO / S3
|
||||||
s3_endpoint: str = "http://minio:9000"
|
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