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