"""Authentication and token issuance service.""" from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import Any from fastapi import Depends from app.core.auth import AuthContext, Permission, _has_permission from app.core.config import Settings, get_settings from app.core.security import build_audit_event, generate_token, hash_token, mask_secret, token_prefix, verify_password from app.domain.services import normalize_expiry_days from app.domain.models import AuthToken, BotPrincipal, User from app.repositories.factory import get_portfolio_repository_from_settings from app.repositories.portfolio_repository import PortfolioRepository from app.schemas.auth import ( CreateBotPrincipalRequest, CreateBotTokenRequest, CreateClientTokenRequest, CreateSelfBotPrincipalRequest, LoginRequest, ) @dataclass class LoginResult: token: str auth_token: AuthToken user: User @dataclass class IssuedClientToken: token: str auth_token: AuthToken @dataclass class IssuedBotToken: token: str auth_token: AuthToken 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 issue_client_token(self, auth_context: AuthContext, payload: CreateClientTokenRequest) -> IssuedClientToken: if auth_context.bot_principal_id is not None: raise ValueError("봇 토큰으로는 사용자 클라이언트 토큰을 발급할 수 없습니다") if auth_context.user_id is None: raise ValueError("DB 사용자 컨텍스트에서만 클라이언트 토큰을 발급할 수 있습니다") requested = Permission(payload.permission or auth_context.permission.value) if not _has_permission(auth_context.permission, requested): raise ValueError("요청한 토큰 권한이 현재 권한을 초과합니다") raw_token = generate_token() expires_at = self._build_expiration(payload.expires_in_days, self._settings.client_token_default_ttl_days) auth_token = self._repository.create_auth_token( auth_context.user_id, name=payload.name, token_hash=hash_token(raw_token), token_prefix=token_prefix(raw_token), permission=requested.value, token_type="client", expires_at=expires_at, ) return IssuedClientToken(token=raw_token, auth_token=auth_token) def list_tokens(self, auth_context: AuthContext) -> list[AuthToken]: if auth_context.bot_principal_id is not None: 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.bot_principal_id is not None: raise ValueError("봇 토큰으로는 사용자 토큰을 폐기할 수 없습니다") if auth_context.user_id is None: raise ValueError("DB 사용자 컨텍스트에서만 토큰을 폐기할 수 있습니다") self._repository.revoke_auth_token(auth_context.user_id, token_id) def create_bot_principal(self, payload: CreateBotPrincipalRequest) -> BotPrincipal: return self._repository.create_bot_principal( name=payload.name, owner_user_id=payload.owner_user_id, permission=payload.permission, description=payload.description, ) def create_owned_bot_principal(self, auth_context: AuthContext, payload: CreateSelfBotPrincipalRequest) -> BotPrincipal: user_id = self._require_db_user_context(auth_context) return self._repository.create_bot_principal( name=payload.name, owner_user_id=user_id, permission=payload.permission, description=payload.description, ) def list_bot_principals(self, *, is_active: bool | None = None) -> list[BotPrincipal]: return self._repository.list_bot_principals(is_active=is_active) def list_owned_bot_principals(self, auth_context: AuthContext, *, is_active: bool | None = None) -> list[BotPrincipal]: user_id = self._require_db_user_context(auth_context) return self._repository.list_bot_principals(is_active=is_active, owner_user_id=user_id) def get_bot_principal(self, bot_principal_id: str) -> BotPrincipal: return self._repository.get_bot_principal(bot_principal_id) def deactivate_bot_principal(self, bot_principal_id: str) -> BotPrincipal: return self._repository.deactivate_bot_principal(bot_principal_id) def issue_bot_token( self, bot_principal_id: str, payload: CreateBotTokenRequest, *, actor_permission: Permission = Permission.ADMIN, actor_id: str | None = None, ) -> IssuedBotToken: bot = self._repository.get_bot_principal(bot_principal_id) if not bot.is_active: raise ValueError("비활성화된 봇에는 토큰을 발급할 수 없습니다") bot_permission = Permission(bot.permission) requested = Permission(payload.permission or bot.permission) if not _has_permission(bot_permission, requested): raise ValueError("요청한 토큰 권한이 봇 권한을 초과합니다") raw_token = generate_token() expires_at = self._build_bot_expiration( payload.expires_in_days, no_expiry=payload.no_expiry, actor_permission=actor_permission, ) auth_token = self._repository.create_auth_token( bot.id, name=payload.name, token_hash=hash_token(raw_token), token_prefix=token_prefix(raw_token), permission=requested.value, token_type="bot", bot_principal_id=bot.id, expires_at=expires_at, ) try: self._record_audit_event( build_audit_event( action="bot_token.issue", actor_id=actor_id or bot.owner_user_id, bot_id=bot.id, token_id=auth_token.id, token_ref=mask_secret(raw_token, prefix=6, suffix=4), result="success", ) ) except Exception as exc: self._repository.revoke_bot_auth_token(bot.id, auth_token.id) raise ValueError("감사 로그 기록에 실패하여 토큰 발급을 취소했습니다") from exc return IssuedBotToken(token=raw_token, auth_token=auth_token) def issue_owned_bot_token( self, auth_context: AuthContext, bot_principal_id: str, payload: CreateBotTokenRequest, ) -> IssuedBotToken: user_id = self._require_db_user_context(auth_context) bot = self._repository.get_bot_principal(bot_principal_id) if bot.owner_user_id != user_id: raise ValueError("봇 접근 권한이 없습니다") return self.issue_bot_token( bot_principal_id, payload, actor_permission=auth_context.permission, actor_id=user_id, ) def list_bot_tokens(self, bot_principal_id: str) -> list[AuthToken]: self._repository.get_bot_principal(bot_principal_id) return self._repository.list_bot_auth_tokens(bot_principal_id) def list_owned_bot_tokens(self, auth_context: AuthContext, bot_principal_id: str) -> list[AuthToken]: user_id = self._require_db_user_context(auth_context) bot = self._repository.get_bot_principal(bot_principal_id) if bot.owner_user_id != user_id: raise ValueError("봇 접근 권한이 없습니다") return self._repository.list_bot_auth_tokens(bot_principal_id) def revoke_bot_token(self, bot_principal_id: str, token_id: str, *, actor_id: str | None = None) -> datetime: bot = self._repository.get_bot_principal(bot_principal_id) self._repository.revoke_bot_auth_token(bot_principal_id, token_id) revoked_at = datetime.now(timezone.utc) try: self._record_audit_event( build_audit_event( action="bot_token.revoke", actor_id=actor_id or bot.owner_user_id, bot_id=bot.id, token_id=token_id, token_ref=mask_secret(token_id, prefix=6, suffix=4), result="success", ) ) except Exception as exc: raise ValueError("토큰은 폐기되었으나 감사 로그 기록에 실패했습니다") from exc return revoked_at def revoke_owned_bot_token(self, auth_context: AuthContext, bot_principal_id: str, token_id: str) -> None: user_id = self._require_db_user_context(auth_context) bot = self._repository.get_bot_principal(bot_principal_id) if bot.owner_user_id != user_id: raise ValueError("봇 접근 권한이 없습니다") self.revoke_bot_token(bot_principal_id, token_id, actor_id=user_id) @staticmethod def _require_db_user_context(auth_context: AuthContext) -> str: if auth_context.bot_principal_id is not None: raise ValueError("봇 토큰으로는 사용자 전용 봇 관리를 수행할 수 없습니다") if auth_context.user_id is None: raise ValueError("DB 사용자 컨텍스트에서만 사용자 전용 봇 관리를 수행할 수 있습니다") return auth_context.user_id @staticmethod def _build_expiration(expires_in_days: int | None, default_days: int) -> datetime: days = normalize_expiry_days(expires_in_days, default_days=default_days) return datetime.now(timezone.utc) + timedelta(days=days) @staticmethod def _build_bot_expiration( expires_in_days: int | None, *, no_expiry: bool, actor_permission: Permission, ) -> datetime | None: if no_expiry: if expires_in_days is not None: raise ValueError("만료일 옵션과 무기한 옵션은 동시에 사용할 수 없습니다") if actor_permission is not Permission.ADMIN: raise ValueError("비관리자는 --no-expiry를 사용할 수 없습니다") return None days = normalize_expiry_days(expires_in_days, default_days=365) return datetime.now(timezone.utc) + timedelta(days=days) def _record_audit_event(self, payload: dict[str, Any]) -> None: recorder = getattr(self._repository, "record_audit_event", None) if callable(recorder): recorder(payload) def get_auth_service( settings: Settings = Depends(get_settings), repository: PortfolioRepository = Depends(get_auth_service_repository), ) -> AuthService: return AuthService(repository, settings)