174 lines
5.9 KiB
Python
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,
|
|
)
|