feat(auth): add DB-backed login, token management, and idempotent auth migration

This commit is contained in:
2026-04-03 12:55:34 +00:00
parent 5168e52e4b
commit 06b1805b92
28 changed files with 1609 additions and 140 deletions
+114
View File
@@ -0,0 +1,114 @@
"""Authentication API routes."""
from __future__ import annotations
from typing import NoReturn
from fastapi import APIRouter, Depends, HTTPException, Response, status
from app.core.auth import AuthContext, require_read
from app.domain.models import AuthToken, User
from app.schemas.auth import AuthTokenResponse, CreateClientTokenRequest, IssuedClientTokenResponse, LoginRequest, TokenResponse
from app.schemas.user import UserResponse
from app.services.auth_service import AuthService, IssuedClientToken, LoginResult, get_auth_service
router = APIRouter(prefix="/auth", tags=["auth"])
def _to_user_response(user: User) -> UserResponse:
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
permission=user.permission,
is_active=user.is_active,
created_at=user.created_at,
updated_at=user.updated_at,
last_login_at=user.last_login_at,
)
def _to_auth_token_response(token: AuthToken) -> AuthTokenResponse:
return AuthTokenResponse(
id=token.id,
user_id=token.user_id,
name=token.name,
token_prefix=token.token_prefix,
permission=token.permission,
token_type=token.token_type,
expires_at=token.expires_at,
last_used_at=token.last_used_at,
revoked_at=token.revoked_at,
created_at=token.created_at,
)
def _to_login_response(result: LoginResult) -> TokenResponse:
return TokenResponse(
access_token=result.token,
expires_at=result.auth_token.expires_at,
permission=result.auth_token.permission,
user=_to_user_response(result.user),
)
def _to_issued_token_response(result: IssuedClientToken) -> IssuedClientTokenResponse:
return IssuedClientTokenResponse(
token=result.token,
auth_token=_to_auth_token_response(result.auth_token),
)
def _raise_auth_error(exc: ValueError) -> NoReturn:
detail = str(exc)
if "잘못된 사용자명 또는 비밀번호" in detail:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail) from exc
if "초과" in detail:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) from exc
if "찾을 수 없습니다" in detail:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) from exc
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) from exc
@router.post("/login", response_model=TokenResponse)
def login(payload: LoginRequest, service: AuthService = Depends(get_auth_service)) -> TokenResponse:
try:
return _to_login_response(service.login(payload))
except ValueError as exc:
_raise_auth_error(exc)
@router.get("/tokens", response_model=list[AuthTokenResponse], dependencies=[Depends(require_read)])
def list_tokens(
auth_context: AuthContext = Depends(require_read),
service: AuthService = Depends(get_auth_service),
) -> list[AuthTokenResponse]:
try:
return [_to_auth_token_response(token) for token in service.list_tokens(auth_context)]
except ValueError as exc:
_raise_auth_error(exc)
@router.post("/tokens", response_model=IssuedClientTokenResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_read)])
def create_token(
payload: CreateClientTokenRequest,
auth_context: AuthContext = Depends(require_read),
service: AuthService = Depends(get_auth_service),
) -> IssuedClientTokenResponse:
try:
return _to_issued_token_response(service.issue_client_token(auth_context, payload))
except ValueError as exc:
_raise_auth_error(exc)
@router.delete("/tokens/{token_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_read)])
def revoke_token(
token_id: str,
auth_context: AuthContext = Depends(require_read),
service: AuthService = Depends(get_auth_service),
) -> Response:
try:
service.revoke_token(auth_context, token_id)
except ValueError as exc:
_raise_auth_error(exc)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+113 -33
View File
@@ -1,8 +1,10 @@
"""Portfolio API routes."""
from fastapi import APIRouter, Depends, Response, status
from fastapi import APIRouter, Depends, Request, Response, status
from app.core.auth import require_admin, require_read, require_trade_write
from app.core.auth import Permission, authorize_request
from app.core.config import Settings, get_settings
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.portfolio import (
BuyRequest,
CreatePortfolioRequest,
@@ -14,101 +16,179 @@ from app.schemas.portfolio import (
TransferRequest,
UpdatePortfolioRequest,
)
from app.services.portfolio_service import PortfolioService, get_portfolio_service
from app.services.portfolio_service import PortfolioService
router = APIRouter(prefix="/portfolios", tags=["portfolios"])
@router.get("", response_model=list[PortfolioListItem], dependencies=[Depends(require_read)])
def _get_service(settings: Settings) -> tuple[PortfolioService, object]:
repository = get_portfolio_repository_from_settings(settings)
return PortfolioService(repository), repository
def _close_repository(repository: object) -> None:
if callable(getattr(type(repository), "close", None)):
repository.close()
@router.get("", response_model=list[PortfolioListItem])
def list_portfolios(
request: Request,
broker: str | None = None,
status_filter: str | None = None,
service: PortfolioService = Depends(get_portfolio_service),
settings: Settings = Depends(get_settings),
) -> list[PortfolioListItem]:
return service.list_portfolios(broker=broker, status=status_filter)
authorize_request(Permission.READ, request, settings)
service, repository = _get_service(settings)
try:
return service.list_portfolios(broker=broker, status=status_filter)
finally:
_close_repository(repository)
@router.get("/{portfolio_id}", response_model=PortfolioDetailResponse, dependencies=[Depends(require_read)])
@router.get("/{portfolio_id}", response_model=PortfolioDetailResponse)
def get_portfolio(
portfolio_id: str,
request: Request,
*,
recent_trades: int = 5,
service: PortfolioService = Depends(get_portfolio_service),
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
return service.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=recent_trades)
authorize_request(Permission.READ, request, settings)
service, repository = _get_service(settings)
try:
return service.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=recent_trades)
finally:
_close_repository(repository)
@router.post(
"",
response_model=PortfolioDetailResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_trade_write)],
)
def create_portfolio(
payload: CreatePortfolioRequest,
service: PortfolioService = Depends(get_portfolio_service),
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
return service.create_portfolio(payload)
authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
return service.create_portfolio(payload)
finally:
_close_repository(repository)
@router.patch("/{portfolio_id}", response_model=PortfolioDetailResponse, dependencies=[Depends(require_trade_write)])
@router.patch("/{portfolio_id}", response_model=PortfolioDetailResponse)
def update_portfolio(
portfolio_id: str,
payload: UpdatePortfolioRequest,
service: PortfolioService = Depends(get_portfolio_service),
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
return service.update_portfolio(portfolio_id, payload)
authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
return service.update_portfolio(portfolio_id, payload)
finally:
_close_repository(repository)
@router.post("/{portfolio_id}/buy", response_model=PortfolioDetailResponse, dependencies=[Depends(require_trade_write)])
@router.post("/{portfolio_id}/buy", response_model=PortfolioDetailResponse)
def buy_portfolio_asset(
portfolio_id: str,
payload: BuyRequest,
service: PortfolioService = Depends(get_portfolio_service),
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
return service.buy(portfolio_id, payload)
authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
return service.buy(portfolio_id, payload)
finally:
_close_repository(repository)
@router.post("/{portfolio_id}/sell", response_model=PortfolioDetailResponse, dependencies=[Depends(require_trade_write)])
@router.post("/{portfolio_id}/sell", response_model=PortfolioDetailResponse)
def sell_portfolio_asset(
portfolio_id: str,
payload: SellRequest,
service: PortfolioService = Depends(get_portfolio_service),
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
return service.sell(portfolio_id, payload)
authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
return service.sell(portfolio_id, payload)
finally:
_close_repository(repository)
@router.post("/{portfolio_id}/invest", response_model=PortfolioDetailResponse, dependencies=[Depends(require_trade_write)])
@router.post("/{portfolio_id}/invest", response_model=PortfolioDetailResponse)
def invest_buffer(
portfolio_id: str,
payload: InvestRequest,
service: PortfolioService = Depends(get_portfolio_service),
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
return service.invest(portfolio_id, payload)
authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
return service.invest(portfolio_id, payload)
finally:
_close_repository(repository)
@router.post("/{portfolio_id}/transfer", response_model=PortfolioDetailResponse, dependencies=[Depends(require_trade_write)])
@router.post("/{portfolio_id}/transfer", response_model=PortfolioDetailResponse)
def transfer_buffer(
portfolio_id: str,
payload: TransferRequest,
service: PortfolioService = Depends(get_portfolio_service),
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
return service.transfer(portfolio_id, payload)
authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
return service.transfer(portfolio_id, payload)
finally:
_close_repository(repository)
@router.post("/{portfolio_id}/reset", response_model=PortfolioDetailResponse, dependencies=[Depends(require_trade_write)])
@router.post("/{portfolio_id}/reset", response_model=PortfolioDetailResponse)
def reset_portfolio(
portfolio_id: str,
payload: ResetPortfolioRequest,
service: PortfolioService = Depends(get_portfolio_service),
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
return service.reset(portfolio_id, payload)
authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
return service.reset(portfolio_id, payload)
finally:
_close_repository(repository)
@router.delete("/{portfolio_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_admin)])
@router.delete("/{portfolio_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_portfolio(
portfolio_id: str,
request: Request,
*,
keep_ledger: bool = False,
service: PortfolioService = Depends(get_portfolio_service),
settings: Settings = Depends(get_settings),
) -> Response:
service.delete(portfolio_id, keep_ledger=keep_ledger)
authorize_request(Permission.ADMIN, request, settings)
service, repository = _get_service(settings)
try:
service.delete(portfolio_id, keep_ledger=keep_ledger)
finally:
_close_repository(repository)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+46 -18
View File
@@ -2,24 +2,37 @@
from fastapi import APIRouter, Depends, Response, status
from app.core.auth import require_read, require_trade_write
from app.core.auth import AuthContext, require_read, require_trade_write
from app.core.config import Settings, get_settings
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.portfolio import CreateStrategySetRequest, StrategySetResponse, UpdateStrategySetRequest
from app.services.strategy_set_service import StrategySetService, get_strategy_set_repository
def get_strategy_set_service(repository=Depends(get_strategy_set_repository)) -> StrategySetService:
return StrategySetService(repository)
from app.services.strategy_set_service import StrategySetService
router = APIRouter(prefix="/strategy-sets", tags=["strategy-sets"])
@router.get("", response_model=list[StrategySetResponse], dependencies=[Depends(require_read)])
def _get_service(settings: Settings) -> tuple[StrategySetService, object]:
repository = get_portfolio_repository_from_settings(settings)
return StrategySetService(repository), repository
def _close_repository(repository: object) -> None:
if callable(getattr(type(repository), "close", None)):
repository.close()
@router.get("", response_model=list[StrategySetResponse])
def list_strategy_sets(
service: StrategySetService = Depends(get_strategy_set_service),
_auth: AuthContext = Depends(require_read),
settings: Settings = Depends(get_settings),
) -> list[StrategySetResponse]:
"""List all strategy sets."""
strategy_sets = service.list()
service, repository = _get_service(settings)
try:
strategy_sets = service.list()
finally:
_close_repository(repository)
return [
StrategySetResponse(
id=ss.id,
@@ -32,13 +45,18 @@ def list_strategy_sets(
]
@router.post("", response_model=StrategySetResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_trade_write)])
@router.post("", response_model=StrategySetResponse, status_code=status.HTTP_201_CREATED)
def create_strategy_set(
payload: CreateStrategySetRequest,
service: StrategySetService = Depends(get_strategy_set_service),
_auth: AuthContext = Depends(require_trade_write),
settings: Settings = Depends(get_settings),
) -> StrategySetResponse:
"""Create a new strategy set (or return existing if (name, config) pair already exists)."""
strategy_set = service.create(payload)
service, repository = _get_service(settings)
try:
strategy_set = service.create(payload)
finally:
_close_repository(repository)
return StrategySetResponse(
id=strategy_set.id,
name=strategy_set.name,
@@ -48,18 +66,23 @@ def create_strategy_set(
)
@router.patch("/{strategy_set_id}", response_model=StrategySetResponse, dependencies=[Depends(require_trade_write)])
@router.patch("/{strategy_set_id}", response_model=StrategySetResponse)
def update_strategy_set(
strategy_set_id: str,
payload: UpdateStrategySetRequest,
service: StrategySetService = Depends(get_strategy_set_service),
_auth: AuthContext = Depends(require_trade_write),
settings: Settings = Depends(get_settings),
) -> StrategySetResponse:
"""Update a strategy set's name and/or config.
Note: Since strategy sets are identified by (name, config) uniqueness,
updating may result in a new strategy set ID if name or config changes.
"""
strategy_set = service.update(strategy_set_id, payload)
service, repository = _get_service(settings)
try:
strategy_set = service.update(strategy_set_id, payload)
finally:
_close_repository(repository)
return StrategySetResponse(
id=strategy_set.id,
name=strategy_set.name,
@@ -69,10 +92,11 @@ def update_strategy_set(
)
@router.delete("/{strategy_set_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_trade_write)])
@router.delete("/{strategy_set_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_strategy_set(
strategy_set_id: str,
service: StrategySetService = Depends(get_strategy_set_service),
_auth: AuthContext = Depends(require_trade_write),
settings: Settings = Depends(get_settings),
) -> Response:
"""Delete a strategy set.
@@ -80,7 +104,11 @@ def delete_strategy_set(
Returns 409 Conflict if the strategy set is in use by any portfolio.
"""
try:
service.delete(strategy_set_id)
service, repository = _get_service(settings)
try:
service.delete(strategy_set_id)
finally:
_close_repository(repository)
except ValueError as e:
# If strategy set is in use, return 409 Conflict
raise ValueError(str(e)) from e
+70 -20
View File
@@ -1,26 +1,40 @@
"""User management API routes."""
from __future__ import annotations
from typing import NoReturn
from fastapi import APIRouter, Depends, HTTPException, Response, status
from app.core.auth import require_admin
from app.core.auth import AuthContext, require_admin
from app.core.config import Settings, get_settings
from app.domain.models import User
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.user import CreateUserRequest, UpdateUserRequest, UserResponse
from app.services.user_service import UserService, get_user_service
from app.services.user_service import UserService
router = APIRouter(prefix="/users", tags=["users"])
def _get_service(settings: Settings) -> tuple[UserService, object]:
repository = get_portfolio_repository_from_settings(settings)
return UserService(repository), repository
def _close_repository(repository: object) -> None:
if callable(getattr(type(repository), "close", None)):
repository.close()
def _to_user_response(user: User) -> UserResponse:
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
permission=user.permission,
is_active=user.is_active,
created_at=user.created_at,
updated_at=user.updated_at,
last_login_at=user.last_login_at,
)
@@ -33,43 +47,79 @@ def _raise_user_error(exc: ValueError) -> NoReturn:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) from exc
@router.get("", response_model=list[UserResponse], dependencies=[Depends(require_admin)])
def list_users(service: UserService = Depends(get_user_service)) -> list[UserResponse]:
return [_to_user_response(user) for user in service.list()]
@router.get("/{user_id}", response_model=UserResponse, dependencies=[Depends(require_admin)])
def get_user(user_id: str, service: UserService = Depends(get_user_service)) -> UserResponse:
@router.get("", response_model=list[UserResponse])
def list_users(
_auth: AuthContext = Depends(require_admin),
settings: Settings = Depends(get_settings),
) -> list[UserResponse]:
service, repository = _get_service(settings)
try:
return _to_user_response(service.get(user_id))
return [_to_user_response(user) for user in service.list()]
finally:
_close_repository(repository)
@router.get("/{user_id}", response_model=UserResponse)
def get_user(
user_id: str,
_auth: AuthContext = Depends(require_admin),
settings: Settings = Depends(get_settings),
) -> UserResponse:
try:
service, repository = _get_service(settings)
try:
return _to_user_response(service.get(user_id))
finally:
_close_repository(repository)
except ValueError as exc:
_raise_user_error(exc)
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_admin)])
def create_user(payload: CreateUserRequest, service: UserService = Depends(get_user_service)) -> UserResponse:
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(
payload: CreateUserRequest,
_auth: AuthContext = Depends(require_admin),
settings: Settings = Depends(get_settings),
) -> UserResponse:
try:
return _to_user_response(service.create(payload))
service, repository = _get_service(settings)
try:
return _to_user_response(service.create(payload))
finally:
_close_repository(repository)
except ValueError as exc:
_raise_user_error(exc)
@router.patch("/{user_id}", response_model=UserResponse, dependencies=[Depends(require_admin)])
@router.patch("/{user_id}", response_model=UserResponse)
def update_user(
user_id: str,
payload: UpdateUserRequest,
service: UserService = Depends(get_user_service),
_auth: AuthContext = Depends(require_admin),
settings: Settings = Depends(get_settings),
) -> UserResponse:
try:
return _to_user_response(service.update(user_id, payload))
service, repository = _get_service(settings)
try:
return _to_user_response(service.update(user_id, payload))
finally:
_close_repository(repository)
except ValueError as exc:
_raise_user_error(exc)
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_admin)])
def delete_user(user_id: str, service: UserService = Depends(get_user_service)) -> Response:
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
user_id: str,
_auth: AuthContext = Depends(require_admin),
settings: Settings = Depends(get_settings),
) -> Response:
try:
service.delete(user_id)
service, repository = _get_service(settings)
try:
service.delete(user_id)
finally:
_close_repository(repository)
except ValueError as exc:
_raise_user_error(exc)
return Response(status_code=status.HTTP_204_NO_CONTENT)