Files
opora/backend/app/services/auth.py
T

174 lines
5.9 KiB
Python

"""Сервис аутентификации: одноразовые ссылки, вход, 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,
)