"""Authentication and token issuance service.""" from __future__ import annotations import uuid from dataclasses import dataclass from datetime import datetime, timedelta, timezone from fastapi import Depends import psycopg.errors from app.core.auth import AuthContext, Permission, _has_permission from app.core.config import Settings, get_settings from app.core.security import generate_token, hash_token, token_prefix, verify_password from app.domain.services import normalize_expiry_days from app.domain.models import AuthToken, User from app.repositories.factory import get_portfolio_repository_from_settings from app.repositories.portfolio_repository import PortfolioRepository from app.schemas.auth import ( LoginRequest, ) @dataclass class LoginResult: token: str auth_token: AuthToken user: User @dataclass class IssuedServiceToken: token: str auth_token: AuthToken @dataclass class RenamedServiceToken: auth_token: AuthToken changed: bool def get_auth_service_repository(settings: Settings = Depends(get_settings)) -> PortfolioRepository: return get_portfolio_repository_from_settings(settings) class AuthService: def __init__(self, repository: PortfolioRepository, settings: Settings) -> None: self._repository = repository self._settings = settings def login(self, payload: LoginRequest) -> LoginResult: user = self._repository.get_user_by_username(payload.username) if user is None or not user.is_active or not verify_password(payload.password, user.password_hash): raise ValueError("잘못된 사용자명 또는 비밀번호입니다") raw_token = generate_token() expires_at = datetime.now(timezone.utc) + timedelta(hours=self._settings.session_token_ttl_hours) auth_token = self._repository.create_auth_token( user.id, name="session", token_hash=hash_token(raw_token), token_prefix=token_prefix(raw_token), permission=user.permission, token_type="session", expires_at=expires_at, ) self._repository.update_user_last_login(user.id) refreshed_user = self._repository.get_user(user.id) return LoginResult(token=raw_token, auth_token=auth_token, user=refreshed_user) def list_tokens(self, auth_context: AuthContext) -> list[AuthToken]: if auth_context.token_type == "service": raise ValueError("서비스 토큰으로는 사용자 토큰 목록을 조회할 수 없습니다") if auth_context.user_id is None: raise ValueError("DB 사용자 컨텍스트에서만 토큰 목록을 조회할 수 있습니다") return self._repository.list_auth_tokens(auth_context.user_id) def revoke_token(self, auth_context: AuthContext, token_id: str) -> None: if auth_context.token_type == "service": raise ValueError("서비스 토큰으로는 사용자 토큰을 폐기할 수 없습니다") if auth_context.user_id is None: raise ValueError("DB 사용자 컨텍스트에서만 토큰을 폐기할 수 있습니다") self._repository.revoke_auth_token(auth_context.user_id, token_id) def issue_service_token( self, name: str, username: str, *, permission: str = "read", expires_in_days: int | None = None, no_expiry: bool = False, daemon_id: str | None = None, ) -> IssuedServiceToken: user = self._repository.get_user_by_username(username) if user is None: raise ValueError(f"사용자를 찾을 수 없습니다: {username}") if daemon_id is not None: daemon = self._repository.get_daemon_by_id(daemon_id) if daemon is None or not daemon.is_active: raise ValueError(f"존재하지 않거나 비활성 데몬입니다: {daemon_id}") if daemon.owner_user_id != user.id: raise ValueError("데몬이 해당 사용자에 속하지 않습니다") expires_at: datetime | None if no_expiry: if expires_in_days is not None: raise ValueError("만료일 옵션과 무기한 옵션은 동시에 사용할 수 없습니다") expires_at = None else: days = normalize_expiry_days(expires_in_days, default_days=365) expires_at = datetime.now(timezone.utc) + timedelta(days=days) raw_token = generate_token() runtime_id = str(uuid.uuid4()) try: auth_token = self._repository.create_auth_token( user.id, name=name, token_hash=hash_token(raw_token), token_prefix=token_prefix(raw_token), permission=permission, token_type="service", expires_at=expires_at, runtime_id=runtime_id, daemon_id=daemon_id, ) except psycopg.errors.UniqueViolation as exc: raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {name}") from exc return IssuedServiceToken(token=raw_token, auth_token=auth_token) def rotate_service_token(self, name: str) -> IssuedServiceToken: raw_token, auth_token = self._repository.rotate_service_token(name) return IssuedServiceToken(token=raw_token, auth_token=auth_token) def rename_service_token(self, name: str, new_name: str) -> RenamedServiceToken: if name == new_name: existing = self._repository.get_service_token_by_name(name) if existing is None: raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}") return RenamedServiceToken(auth_token=existing, changed=False) try: renamed = self._repository.rename_service_token(name, new_name) except psycopg.errors.UniqueViolation as exc: raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {new_name}") from exc return RenamedServiceToken(auth_token=renamed, changed=True) def rebind_service_token(self, name: str, daemon_id: str) -> tuple[str | None, AuthToken]: existing = self._repository.get_service_token_by_name(name) if existing is None: raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}") daemon = self._repository.get_daemon_by_id(daemon_id) if daemon is None or not daemon.is_active: raise ValueError(f"존재하지 않거나 비활성 데몬입니다: {daemon_id}") if daemon.owner_user_id != existing.user_id: raise ValueError("데몬이 해당 토큰 사용자에 속하지 않습니다") previous_daemon_id = existing.daemon_id rebound = self._repository.rebind_service_token(name, daemon_id) return previous_daemon_id, rebound def revoke_service_token(self, name: str) -> datetime: existing = self._repository.get_service_token_by_name(name) if existing is None: raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}") self._repository.revoke_auth_token(existing.user_id, existing.id) return datetime.now(timezone.utc) def revoke_tokens_for_daemon(self, daemon_id: str) -> int: return self._repository.revoke_tokens_by_daemon_id(daemon_id) def list_null_daemon_service_tokens(self) -> list[AuthToken]: return self._repository.list_service_tokens_with_null_daemon() def find_candidate_daemons_for_user(self, user_id: str) -> list: return self._repository.list_daemons_for_user(user_id) def list_service_tokens( self, username: str | None = None, *, include_revoked: bool = False, ) -> list[AuthToken]: user_id: str | None = None if username is not None: user = self._repository.get_user_by_username(username) if user is None: raise ValueError(f"사용자를 찾을 수 없습니다: {username}") user_id = user.id return self._repository.list_service_tokens(user_id, include_revoked=include_revoked) def get_auth_service( settings: Settings = Depends(get_settings), repository: PortfolioRepository = Depends(get_auth_service_repository), ) -> AuthService: return AuthService(repository, settings)