diff --git a/.env.example b/.env.example index 24aca58..3e6b788 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,11 @@ PORTFOLIOHUB_ENVIRONMENT=development # Enable DB-backed repository (uses TIMESCALEDB_* below) PORTFOLIOHUB_DB_ENABLED=false +PORTFOLIOHUB_BOOTSTRAP_USERNAME= +PORTFOLIOHUB_BOOTSTRAP_EMAIL= +PORTFOLIOHUB_BOOTSTRAP_PASSWORD= +PORTFOLIOHUB_SESSION_TOKEN_TTL_HOURS=12 +PORTFOLIOHUB_CLIENT_TOKEN_DEFAULT_TTL_DAYS=365 # TimescaleDB / PostgreSQL connection settings TIMESCALEDB_HOST=localhost diff --git a/README.md b/README.md index 67dfaf0..38285e2 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ When deploying to production, set environment variables directly instead of usin ### Key configuration options - **DB repository switch**: `PORTFOLIOHUB_DB_ENABLED` (enables DB-backed persistence) +- **Initial user bootstrap**: `PORTFOLIOHUB_BOOTSTRAP_USERNAME`, `PORTFOLIOHUB_BOOTSTRAP_EMAIL` +- **Initial user password**: `PORTFOLIOHUB_BOOTSTRAP_PASSWORD` +- **Session/token TTL**: `PORTFOLIOHUB_SESSION_TOKEN_TTL_HOURS`, `PORTFOLIOHUB_CLIENT_TOKEN_DEFAULT_TTL_DAYS` - **TimescaleDB / PostgreSQL**: `TIMESCALEDB_*` (single source of DB connection settings) - **Redis**: `REDIS_*` (for caching and sessions) - **Loki**: `LOKI_*` (for centralized logging) @@ -61,7 +64,7 @@ The repository now includes a `portfoliohub` CLI that mirrors the main portfolio ```bash portfoliohub --help portfoliohub user list -portfoliohub user create --username alice --email alice@example.com --force +portfoliohub user create --username alice --password password123 --email alice@example.com --force portfoliohub user get portfoliohub user update --username alice-updated --force portfoliohub user delete --force @@ -168,6 +171,13 @@ User management is exposed through both HTTP API and CLI. - Permission: `admin` only - Supported operations: list, get, create, update, delete +Frontend and app authentication is exposed through `/auth`. + +- `POST /auth/login`: username/password login, returns bearer session token +- `GET /auth/tokens`: list current user's issued tokens +- `POST /auth/tokens`: issue a client token for app/script usage +- `DELETE /auth/tokens/{token_id}`: revoke a previously issued token + Example API calls: ```bash @@ -182,6 +192,8 @@ curl -X POST "http://localhost:8000/users" \ The default `.env.example` includes example tokens for development. +When `PORTFOLIOHUB_DB_ENABLED=true` and both `PORTFOLIOHUB_BOOTSTRAP_USERNAME` and `PORTFOLIOHUB_BOOTSTRAP_PASSWORD` are set, the app creates that admin user automatically on startup only if the `users` table is empty. This is intended for first-run bootstrap, not recurring synchronization. + ## Planned next steps 1. Apply and verify Alembic migrations against a real PostgreSQL/Timescale instance. diff --git a/app/api/auth_routes.py b/app/api/auth_routes.py new file mode 100644 index 0000000..3eb4701 --- /dev/null +++ b/app/api/auth_routes.py @@ -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) \ No newline at end of file diff --git a/app/api/portfolios.py b/app/api/portfolios.py index 6488fce..c793468 100644 --- a/app/api/portfolios.py +++ b/app/api/portfolios.py @@ -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) diff --git a/app/api/strategy_sets.py b/app/api/strategy_sets.py index a56513c..73826a1 100644 --- a/app/api/strategy_sets.py +++ b/app/api/strategy_sets.py @@ -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 diff --git a/app/api/users.py b/app/api/users.py index d7baf08..4b9a6bc 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -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) \ No newline at end of file diff --git a/app/cli/commands/health.py b/app/cli/commands/health.py index f7ad306..2536386 100644 --- a/app/cli/commands/health.py +++ b/app/cli/commands/health.py @@ -100,5 +100,5 @@ def register_health_commands(app: typer.Typer) -> None: for key, value in health.items(): app_context.console.print(f"{key}: {value}") finally: - if hasattr(repository, "close"): + if callable(getattr(type(repository), "close", None)): repository.close() \ No newline at end of file diff --git a/app/cli/commands/portfolio.py b/app/cli/commands/portfolio.py index d6b9857..b1b9952 100644 --- a/app/cli/commands/portfolio.py +++ b/app/cli/commands/portfolio.py @@ -40,7 +40,7 @@ def _with_service(ctx: typer.Context, callback): result = execute_command(app_context.console, callback=lambda: callback(service, app_context)) return result finally: - if hasattr(repository, "close"): + if callable(getattr(type(repository), "close", None)): repository.close() diff --git a/app/cli/commands/strategy.py b/app/cli/commands/strategy.py index b7eb7ac..9cef3c2 100644 --- a/app/cli/commands/strategy.py +++ b/app/cli/commands/strategy.py @@ -19,7 +19,7 @@ def _with_service(ctx: typer.Context, callback): result = execute_command(app_context.console, callback=lambda: callback(service, app_context)) return result finally: - if hasattr(repository, "close"): + if callable(getattr(type(repository), "close", None)): repository.close() diff --git a/app/cli/commands/user.py b/app/cli/commands/user.py index ab35b3f..595fdf0 100644 --- a/app/cli/commands/user.py +++ b/app/cli/commands/user.py @@ -20,7 +20,7 @@ def _with_service(ctx: typer.Context, callback): try: return execute_command(app_context.console, callback=lambda: callback(service, app_context)) finally: - if hasattr(repository, "close"): + if callable(getattr(type(repository), "close", None)): repository.close() @@ -29,8 +29,11 @@ def _to_user_response(user) -> 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, ) @@ -53,14 +56,25 @@ def get_user( def create_user( ctx: typer.Context, username: str = typer.Option(..., help="Username."), + password: str = typer.Option(..., help="Initial password."), email: str | None = typer.Option(None, help="Email address."), + permission: str = typer.Option("read", help="Permission: read, trade-write, admin."), + is_active: bool = typer.Option(True, help="Whether the user is active."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), ) -> None: _confirm(force, f"Create user {username}?") user = _with_service( ctx, lambda service, app_context: _to_user_response( - service.create(CreateUserRequest(username=username, email=email)) + service.create( + CreateUserRequest( + username=username, + password=password, + email=email, + permission=permission, + is_active=is_active, + ) + ) ), ) render_output(ctx.obj.console, ctx.obj.output_format, user) @@ -71,14 +85,26 @@ def update_user( ctx: typer.Context, user_id: str = typer.Argument(..., help="User identifier."), username: str | None = typer.Option(None, help="New username."), + password: str | None = typer.Option(None, help="New password."), email: str | None = typer.Option(None, help="New email address."), + permission: str | None = typer.Option(None, help="New permission."), + is_active: bool | None = typer.Option(None, help="Whether the user is active."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), ) -> None: _confirm(force, f"Update user {user_id}?") user = _with_service( ctx, lambda service, app_context: _to_user_response( - service.update(user_id, UpdateUserRequest(username=username, email=email)) + service.update( + user_id, + UpdateUserRequest( + username=username, + password=password, + email=email, + permission=permission, + is_active=is_active, + ), + ) ), ) render_output(ctx.obj.console, ctx.obj.output_format, user) diff --git a/app/core/auth.py b/app/core/auth.py index 37de50a..04c8312 100644 --- a/app/core/auth.py +++ b/app/core/auth.py @@ -4,11 +4,14 @@ from __future__ import annotations from dataclasses import dataclass from enum import StrEnum +from typing import Generator -from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from fastapi import Depends, HTTPException, Request, status from app.core.config import Settings, get_settings +from app.core.security import hash_token +from app.repositories.factory import get_portfolio_repository_from_settings +from app.repositories.portfolio_repository import PortfolioRepository class Permission(StrEnum): @@ -21,9 +24,17 @@ class Permission(StrEnum): class AuthContext: token: str permission: Permission + user_id: str | None = None + token_id: str | None = None + token_source: str = "env" - -security = HTTPBearer(auto_error=False) +def get_auth_repository(settings: Settings = Depends(get_settings)) -> Generator[PortfolioRepository, None, None]: + repository = get_portfolio_repository_from_settings(settings) + try: + yield repository + finally: + if callable(getattr(type(repository), "close", None)): + repository.close() def _resolve_permission(token: str, settings: Settings) -> Permission | None: @@ -36,6 +47,26 @@ def _resolve_permission(token: str, settings: Settings) -> Permission | None: return None +def _resolve_auth_context(token: str, settings: Settings, repository: PortfolioRepository) -> AuthContext | None: + permission = _resolve_permission(token, settings) + if permission is not None: + return AuthContext(token=token, permission=permission, token_source="env") + + if not settings.db_enabled: + return None + + principal = repository.get_token_principal(hash_token(token)) + if principal is None: + return None + return AuthContext( + token=token, + permission=Permission(principal.permission), + user_id=principal.user_id, + token_id=principal.token_id, + token_source="db", + ) + + def _has_permission(current: Permission, required: Permission) -> bool: if current == Permission.ADMIN: return True @@ -48,28 +79,53 @@ def _has_permission(current: Permission, required: Permission) -> bool: def require_permission( required: Permission, - credentials: HTTPAuthorizationCredentials | None = Depends(security), + request: Request, settings: Settings = Depends(get_settings), + repository: PortfolioRepository = Depends(get_auth_repository), ) -> AuthContext: - if credentials is None or credentials.scheme.lower() != "bearer": + return authorize_request(required, request, settings, repository=repository) + + +def authorize_request( + required: Permission, + request: Request, + settings: Settings, + *, + repository: PortfolioRepository | None = None, +) -> AuthContext: + authorization = request.headers.get("Authorization", "").strip() + if not authorization: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token") + scheme, _, token = authorization.partition(" ") + if scheme.lower() != "bearer" or not token: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token") - permission = _resolve_permission(credentials.credentials, settings) - if permission is None: + managed_repository = repository is None + if repository is None: + repository = get_portfolio_repository_from_settings(settings) + + try: + auth_context = _resolve_auth_context(token, settings, repository) + finally: + if managed_repository and callable(getattr(type(repository), "close", None)): + repository.close() + + if auth_context is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") - if not _has_permission(permission, required): + if not _has_permission(auth_context.permission, required): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permission") - return AuthContext(token=credentials.credentials, permission=permission) + return auth_context def permission_dependency(required: Permission): def dependency( - credentials: HTTPAuthorizationCredentials | None = Depends(security), + request: Request, settings: Settings = Depends(get_settings), + repository: PortfolioRepository = Depends(get_auth_repository), ) -> AuthContext: - return require_permission(required, credentials=credentials, settings=settings) + return require_permission(required, request=request, settings=settings, repository=repository) return dependency diff --git a/app/core/config.py b/app/core/config.py index 82a73c4..0b9de3c 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -21,10 +21,15 @@ class Settings(BaseSettings): app_version: str = "0.1.0" environment: str = "development" allowed_origins_raw: str = Field(default="http://localhost:3000", validation_alias="PORTFOLIOHUB_ALLOWED_ORIGINS") + bootstrap_username: str = Field(default="", validation_alias="PORTFOLIOHUB_BOOTSTRAP_USERNAME") + bootstrap_email: str = Field(default="", validation_alias="PORTFOLIOHUB_BOOTSTRAP_EMAIL") + bootstrap_password: str = Field(default="", validation_alias="PORTFOLIOHUB_BOOTSTRAP_PASSWORD") read_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_READ_TOKENS") trade_write_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_TRADE_WRITE_TOKENS") admin_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_ADMIN_TOKENS") db_enabled: bool = Field(default=False, validation_alias="PORTFOLIOHUB_DB_ENABLED") + session_token_ttl_hours: int = Field(default=12, validation_alias="PORTFOLIOHUB_SESSION_TOKEN_TTL_HOURS") + client_token_default_ttl_days: int = Field(default=365, validation_alias="PORTFOLIOHUB_CLIENT_TOKEN_DEFAULT_TTL_DAYS") # External services configuration timescaledb_host: str = Field(default="localhost", validation_alias="TIMESCALEDB_HOST") @@ -49,6 +54,7 @@ class Settings(BaseSettings): env_prefix="PORTFOLIOHUB_", case_sensitive=False, extra="ignore", + populate_by_name=True, ) @computed_field @@ -56,6 +62,24 @@ class Settings(BaseSettings): def allowed_origins(self) -> list[str]: return [item.strip() for item in self.allowed_origins_raw.split(",") if item.strip()] + @computed_field + @property + def bootstrap_user_username(self) -> str | None: + normalized = self.bootstrap_username.strip() + return normalized or None + + @computed_field + @property + def bootstrap_user_email(self) -> str | None: + normalized = self.bootstrap_email.strip() + return normalized or None + + @computed_field + @property + def bootstrap_user_password(self) -> str | None: + normalized = self.bootstrap_password.strip() + return normalized or None + @computed_field @property def read_tokens(self) -> set[str]: diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..f0d2003 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,50 @@ +"""Password hashing and opaque token helpers.""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import secrets + + +def hash_password(password: str) -> str: + salt = secrets.token_bytes(16) + derived = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=16384, r=8, p=1) + return "scrypt$16384$8$1${}${}".format( + base64.b64encode(salt).decode("ascii"), + base64.b64encode(derived).decode("ascii"), + ) + + +def verify_password(password: str, encoded: str | None) -> bool: + if not encoded: + return False + try: + algorithm, n_value, r_value, p_value, salt_encoded, digest_encoded = encoded.split("$", maxsplit=5) + except ValueError: + return False + if algorithm != "scrypt": + return False + salt = base64.b64decode(salt_encoded.encode("ascii")) + expected = base64.b64decode(digest_encoded.encode("ascii")) + actual = hashlib.scrypt( + password.encode("utf-8"), + salt=salt, + n=int(n_value), + r=int(r_value), + p=int(p_value), + ) + return hmac.compare_digest(actual, expected) + + +def generate_token() -> str: + return secrets.token_urlsafe(32) + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def token_prefix(token: str) -> str: + return token[:8] \ No newline at end of file diff --git a/app/domain/models.py b/app/domain/models.py index 747a84c..85a52aa 100644 --- a/app/domain/models.py +++ b/app/domain/models.py @@ -21,8 +21,34 @@ class User: id: str username: str email: str | None = None + permission: str = "read" + is_active: bool = True + password_hash: str | None = None created_at: datetime | None = None updated_at: datetime | None = None + last_login_at: datetime | None = None + + +@dataclass +class AuthToken: + id: str + user_id: str + name: str + token_prefix: str + permission: str + token_type: str + expires_at: datetime | None = None + last_used_at: datetime | None = None + revoked_at: datetime | None = None + created_at: datetime | None = None + + +@dataclass +class TokenPrincipal: + token_id: str + user_id: str + permission: str + token_type: str @dataclass diff --git a/app/main.py b/app/main.py index 8f40041..09f5f5c 100644 --- a/app/main.py +++ b/app/main.py @@ -1,23 +1,63 @@ """FastAPI application entrypoint.""" +from contextlib import asynccontextmanager +from typing import Callable + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api.health import router as health_router +from app.api.auth_routes import router as auth_router from app.api.portfolios import router as portfolio_router from app.api.strategy_sets import router as strategy_sets_router from app.api.users import router as users_router -from app.core.config import get_settings +from app.core.config import Settings, get_settings from app.core.errors import register_exception_handlers +from app.core.security import hash_password +from app.repositories.factory import get_portfolio_repository_from_settings +from app.repositories.portfolio_repository import PortfolioRepository -def create_app() -> FastAPI: - settings = get_settings() +def _bootstrap_initial_user( + settings: Settings, + repository_factory: Callable[[Settings], PortfolioRepository], +) -> None: + if not settings.db_enabled or settings.bootstrap_user_username is None or settings.bootstrap_user_password is None: + return + + repository = repository_factory(settings) + try: + if repository.list_users(): + return + repository.create_user( + settings.bootstrap_user_username, + email=settings.bootstrap_user_email, + password_hash=hash_password(settings.bootstrap_user_password), + permission="admin", + ) + finally: + if callable(getattr(type(repository), "close", None)): + repository.close() + + +def create_app( + *, + settings_factory: Callable[[], Settings] = get_settings, + repository_factory: Callable[[Settings], PortfolioRepository] = get_portfolio_repository_from_settings, +) -> FastAPI: + settings = settings_factory() + + @asynccontextmanager + async def lifespan(_: FastAPI): + _bootstrap_initial_user(settings, repository_factory) + yield + app = FastAPI( title=settings.app_name, version=settings.app_version, docs_url="/docs", redoc_url="/redoc", + lifespan=lifespan, ) app.add_middleware( @@ -29,6 +69,7 @@ def create_app() -> FastAPI: ) app.include_router(health_router) + app.include_router(auth_router) app.include_router(portfolio_router) app.include_router(strategy_sets_router) app.include_router(users_router) diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index 763408c..05f6fb5 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -2,9 +2,10 @@ from __future__ import annotations +from datetime import datetime from typing import Protocol -from app.domain.models import LedgerEntry, Portfolio, StrategySet, User +from app.domain.models import AuthToken, LedgerEntry, Portfolio, StrategySet, TokenPrincipal, User class PortfolioRepository(Protocol): @@ -12,14 +13,53 @@ class PortfolioRepository(Protocol): def get_user(self, user_id: str) -> User: ... + def get_user_by_username(self, username: str) -> User | None: ... + def list_users(self) -> list[User]: ... - def create_user(self, username: str, *, email: str | None = None) -> User: ... + def create_user( + self, + username: str, + *, + email: str | None = None, + password_hash: str | None = None, + permission: str = "read", + is_active: bool = True, + ) -> User: ... - def update_user(self, user_id: str, *, username: str | None = None, email: str | None = None) -> User: ... + def update_user( + self, + user_id: str, + *, + username: str | None = None, + email: str | None = None, + password_hash: str | None = None, + permission: str | None = None, + is_active: bool | None = None, + ) -> User: ... def delete_user(self, user_id: str) -> None: ... + def update_user_last_login(self, user_id: str) -> None: ... + + def create_auth_token( + self, + user_id: str, + *, + name: str, + token_hash: str, + token_prefix: str, + permission: str, + token_type: str, + expires_at: datetime | None = None, + ) -> AuthToken: ... + + def list_auth_tokens(self, user_id: str) -> list[AuthToken]: ... + + def revoke_auth_token(self, user_id: str, token_id: str) -> None: ... + + def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: ... + def get(self, portfolio_id: str) -> Portfolio: ... def get_or_create( diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 76366ce..194879a 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -12,18 +12,37 @@ from typing import Any import psycopg from psycopg import sql -from app.domain.models import LedgerEntry, Portfolio, StrategySet, User +from app.domain.models import AuthToken, LedgerEntry, Portfolio, StrategySet, TokenPrincipal, User _SELECT_USER = """ SELECT id, username, email, + permission, + is_active, + password_hash, created_at, - updated_at + updated_at, + last_login_at FROM users """ +_SELECT_AUTH_TOKEN = """ + SELECT + id, + user_id, + name, + token_prefix, + permission, + token_type, + expires_at, + last_used_at, + revoked_at, + created_at + FROM auth_tokens +""" + _SELECT_PORTFOLIO = """ SELECT portfolio_id, @@ -94,11 +113,44 @@ class PostgresPortfolioRepository: id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, email TEXT UNIQUE, + permission TEXT NOT NULL DEFAULT 'read', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + password_hash TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMPTZ ) """ ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS auth_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + token_prefix TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + permission TEXT NOT NULL, + token_type TEXT NOT NULL, + expires_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """ + ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_created + ON auth_tokens (user_id, created_at DESC) + """ + ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_auth_tokens_hash_active + ON auth_tokens (token_hash) + """ + ) cur.execute( """ CREATE TABLE IF NOT EXISTS strategy_sets ( @@ -178,8 +230,29 @@ class PostgresPortfolioRepository: id=row[0], username=row[1], email=row[2], - created_at=row[3] if len(row) > 3 else None, - updated_at=row[4] if len(row) > 4 else None, + permission=row[3] if len(row) > 3 else "read", + is_active=bool(row[4]) if len(row) > 4 else True, + password_hash=row[5] if len(row) > 5 else None, + created_at=row[6] if len(row) > 6 else None, + updated_at=row[7] if len(row) > 7 else None, + last_login_at=row[8] if len(row) > 8 else None, + ) + + @staticmethod + def _row_to_auth_token(row: Any) -> AuthToken: + if row is None: + raise ValueError("토큰을 찾을 수 없습니다") + return AuthToken( + id=row[0], + user_id=row[1], + name=row[2], + token_prefix=row[3], + permission=row[4], + token_type=row[5], + expires_at=row[6] if len(row) > 6 else None, + last_used_at=row[7] if len(row) > 7 else None, + revoked_at=row[8] if len(row) > 8 else None, + created_at=row[9] if len(row) > 9 else None, ) @staticmethod @@ -260,6 +333,15 @@ class PostgresPortfolioRepository: row = cur.fetchone() return self._row_to_user(row) + def get_user_by_username(self, username: str) -> User | None: + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute(_SELECT_USER + "WHERE username = %s", (username,)) + row = cur.fetchone() + if row is None: + return None + return self._row_to_user(row) + def list_users(self) -> list[User]: self.ensure_schema() with self._connect().cursor() as cur: @@ -267,23 +349,40 @@ class PostgresPortfolioRepository: rows = cur.fetchall() return [self._row_to_user(row) for row in rows] - def create_user(self, username: str, *, email: str | None = None) -> User: + def create_user( + self, + username: str, + *, + email: str | None = None, + password_hash: str | None = None, + permission: str = "read", + is_active: bool = True, + ) -> User: self.ensure_schema() with self._connect().cursor() as cur: self._ensure_user_uniqueness(cur, username=username, email=email) user_id = str(uuid.uuid4()) cur.execute( """ - INSERT INTO users (id, username, email) - VALUES (%s, %s, %s) - RETURNING id, username, email, created_at, updated_at + INSERT INTO users (id, username, email, permission, is_active, password_hash) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id, username, email, permission, is_active, password_hash, created_at, updated_at, last_login_at """, - (user_id, username, email), + (user_id, username, email, permission, is_active, password_hash), ) row = cur.fetchone() return self._row_to_user(row) - def update_user(self, user_id: str, *, username: str | None = None, email: str | None = None) -> User: + def update_user( + self, + user_id: str, + *, + username: str | None = None, + email: str | None = None, + password_hash: str | None = None, + permission: str | None = None, + is_active: bool | None = None, + ) -> User: self.ensure_schema() assignments: list[str] = [] params: list[object] = [] @@ -301,6 +400,15 @@ class PostgresPortfolioRepository: if email is not None: assignments.append("email = %s") params.append(email) + if password_hash is not None: + assignments.append("password_hash = %s") + params.append(password_hash) + if permission is not None: + assignments.append("permission = %s") + params.append(permission) + if is_active is not None: + assignments.append("is_active = %s") + params.append(is_active) if not assignments: return self._row_to_user(existing_row) @@ -311,7 +419,7 @@ class PostgresPortfolioRepository: UPDATE users SET {', '.join(assignments)} WHERE id = %s - RETURNING id, username, email, created_at, updated_at + RETURNING id, username, email, permission, is_active, password_hash, created_at, updated_at, last_login_at """, (*params, user_id), ) @@ -324,8 +432,85 @@ class PostgresPortfolioRepository: cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,)) if cur.fetchone() is None: raise ValueError("사용자를 찾을 수 없습니다") + cur.execute("DELETE FROM auth_tokens WHERE user_id = %s", (user_id,)) cur.execute("DELETE FROM users WHERE id = %s", (user_id,)) + def update_user_last_login(self, user_id: str) -> None: + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute( + "UPDATE users SET last_login_at = NOW(), updated_at = NOW() WHERE id = %s", + (user_id,), + ) + + def create_auth_token( + self, + user_id: str, + *, + name: str, + token_hash: str, + token_prefix: str, + permission: str, + token_type: str, + expires_at: datetime | None = None, + ) -> AuthToken: + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,)) + if cur.fetchone() is None: + raise ValueError("사용자를 찾을 수 없습니다") + token_id = str(uuid.uuid4()) + cur.execute( + """ + INSERT INTO auth_tokens (id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id, user_id, name, token_prefix, permission, token_type, expires_at, last_used_at, revoked_at, created_at + """, + (token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at), + ) + row = cur.fetchone() + return self._row_to_auth_token(row) + + def list_auth_tokens(self, user_id: str) -> list[AuthToken]: + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute( + _SELECT_AUTH_TOKEN + "WHERE user_id = %s ORDER BY created_at DESC, id ASC", + (user_id,), + ) + rows = cur.fetchall() + return [self._row_to_auth_token(row) for row in rows] + + def revoke_auth_token(self, user_id: str, token_id: str) -> None: + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute( + "UPDATE auth_tokens SET revoked_at = NOW() WHERE id = %s AND user_id = %s RETURNING id", + (token_id, user_id), + ) + row = cur.fetchone() + if row is None: + raise ValueError("토큰을 찾을 수 없습니다") + + def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute( + """ + SELECT id, user_id, permission, token_type + FROM auth_tokens + WHERE token_hash = %s + AND revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > NOW()) + """, + (token_hash,), + ) + row = cur.fetchone() + if row is None: + return None + cur.execute("UPDATE auth_tokens SET last_used_at = NOW() WHERE id = %s", (row[0],)) + return TokenPrincipal(token_id=row[0], user_id=row[1], permission=row[2], token_type=row[3]) + def get(self, portfolio_id: str) -> Portfolio: self.ensure_schema() with self._connect().cursor() as cur: diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000..bea1e52 --- /dev/null +++ b/app/schemas/auth.py @@ -0,0 +1,64 @@ +"""Authentication request and response schemas.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, Field, field_validator + +from app.schemas.user import UserResponse + + +class LoginRequest(BaseModel): + username: str = Field(min_length=1) + password: str = Field(min_length=1) + + @field_validator("username") + @classmethod + def normalize_username(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("username은 비어 있을 수 없습니다") + return normalized + + +class TokenResponse(BaseModel): + access_token: str + token_type: Literal["bearer"] = "bearer" + expires_at: datetime | None = None + permission: Literal["read", "trade-write", "admin"] + user: UserResponse + + +class CreateClientTokenRequest(BaseModel): + name: str = Field(min_length=1) + permission: Literal["read", "trade-write", "admin"] | None = None + expires_in_days: int | None = Field(default=None, ge=1, le=3650) + + @field_validator("name") + @classmethod + def normalize_name(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("name은 비어 있을 수 없습니다") + return normalized + + +class AuthTokenResponse(BaseModel): + id: str + user_id: str + name: str + token_prefix: str + permission: Literal["read", "trade-write", "admin"] + token_type: Literal["session", "client"] + expires_at: datetime | None = None + last_used_at: datetime | None = None + revoked_at: datetime | None = None + created_at: datetime | None = None + + +class IssuedClientTokenResponse(BaseModel): + token: str + token_type: Literal["bearer"] = "bearer" + auth_token: AuthTokenResponse \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py index a20f96d..24f64e2 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime +from typing import Literal from pydantic import BaseModel, Field, field_validator @@ -11,13 +12,19 @@ class UserResponse(BaseModel): id: str username: str email: str | None = None + permission: Literal["read", "trade-write", "admin"] = "read" + is_active: bool = True created_at: datetime | None = None updated_at: datetime | None = None + last_login_at: datetime | None = None class CreateUserRequest(BaseModel): username: str = Field(min_length=1) email: str | None = None + password: str = Field(min_length=8) + permission: Literal["read", "trade-write", "admin"] = "read" + is_active: bool = True @field_validator("username") @classmethod @@ -39,6 +46,9 @@ class CreateUserRequest(BaseModel): class UpdateUserRequest(BaseModel): username: str | None = Field(default=None, min_length=1) email: str | None = None + password: str | None = Field(default=None, min_length=8) + permission: Literal["read", "trade-write", "admin"] | None = None + is_active: bool | None = None @field_validator("username") @classmethod diff --git a/app/services/auth_service.py b/app/services/auth_service.py new file mode 100644 index 0000000..0ad5d65 --- /dev/null +++ b/app/services/auth_service.py @@ -0,0 +1,102 @@ +"""Authentication and token issuance service.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +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 generate_token, hash_token, token_prefix, verify_password +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 CreateClientTokenRequest, LoginRequest + + +@dataclass +class LoginResult: + token: str + auth_token: AuthToken + user: User + + +@dataclass +class IssuedClientToken: + 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.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 = None + if payload.expires_in_days is not None: + expires_at = datetime.now(timezone.utc) + timedelta(days=payload.expires_in_days) + else: + expires_at = datetime.now(timezone.utc) + timedelta(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.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.user_id is None: + raise ValueError("DB 사용자 컨텍스트에서만 토큰을 폐기할 수 있습니다") + self._repository.revoke_auth_token(auth_context.user_id, token_id) + + +def get_auth_service( + settings: Settings = Depends(get_settings), + repository: PortfolioRepository = Depends(get_auth_service_repository), +) -> AuthService: + return AuthService(repository, settings) \ No newline at end of file diff --git a/app/services/user_service.py b/app/services/user_service.py index e06acc2..59558a8 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -5,6 +5,7 @@ from __future__ import annotations from fastapi import Depends from app.core.config import Settings, get_settings +from app.core.security import hash_password from app.domain.models import User from app.repositories.factory import get_portfolio_repository_from_settings from app.repositories.portfolio_repository import PortfolioRepository @@ -26,13 +27,22 @@ class UserService: return self._repository.get_user(user_id) def create(self, payload: CreateUserRequest) -> User: - return self._repository.create_user(payload.username, email=payload.email) + return self._repository.create_user( + payload.username, + email=payload.email, + password_hash=hash_password(payload.password), + permission=payload.permission, + is_active=payload.is_active, + ) def update(self, user_id: str, payload: UpdateUserRequest) -> User: return self._repository.update_user( user_id, username=payload.username, email=payload.email, + password_hash=hash_password(payload.password) if payload.password is not None else None, + permission=payload.permission, + is_active=payload.is_active, ) def delete(self, user_id: str) -> None: diff --git a/docs/api-examples.md b/docs/api-examples.md index 682e74f..6e5e766 100644 --- a/docs/api-examples.md +++ b/docs/api-examples.md @@ -259,11 +259,105 @@ Response 204: ### 5.1 GET /strategy-sets -## 6. Users +## 6. Auth + +### 6.1 POST /auth/login + +Request: + +```bash +curl -X POST "http://localhost:8000/auth/login" \ + -H "Content-Type: application/json" \ + -d '{ + "username": "admin", + "password": "bootstrap123" + }' +``` + +Response 200: + +```json +{ + "access_token": "plain-token-value", + "token_type": "bearer", + "expires_at": "2026-04-03T12:00:00Z", + "permission": "admin", + "user": { + "id": "user-1", + "username": "admin", + "email": "admin@example.com", + "permission": "admin", + "is_active": true, + "created_at": "2026-04-03T00:00:00Z", + "updated_at": "2026-04-03T00:00:00Z", + "last_login_at": "2026-04-03T00:00:00Z" + } +} +``` + +### 6.2 POST /auth/tokens + +Request: + +```bash +curl -X POST "http://localhost:8000/auth/tokens" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "name": "mobile-app", + "permission": "read", + "expires_in_days": 30 + }' +``` + +Response 201: + +```json +{ + "token": "plain-client-token", + "token_type": "bearer", + "auth_token": { + "id": "token-1", + "user_id": "user-1", + "name": "mobile-app", + "token_prefix": "abcd1234", + "permission": "read", + "token_type": "client", + "expires_at": "2026-05-03T00:00:00Z", + "last_used_at": null, + "revoked_at": null, + "created_at": "2026-04-03T00:00:00Z" + } +} +``` + +### 6.3 GET /auth/tokens + +Request: + +```bash +curl -X GET "http://localhost:8000/auth/tokens" \ + -H "Authorization: Bearer " +``` + +### 6.4 DELETE /auth/tokens/{token_id} + +Request: + +```bash +curl -X DELETE "http://localhost:8000/auth/tokens/token-1" \ + -H "Authorization: Bearer " +``` + +Response 204: + +- No content. + +## 7. Users All user management endpoints require an admin token. -### 6.1 GET /users +### 7.1 GET /users Request: @@ -286,7 +380,7 @@ Response 200: ] ``` -### 6.2 GET /users/{user_id} +### 7.2 GET /users/{user_id} Request: @@ -307,7 +401,7 @@ Response 200: } ``` -### 6.3 POST /users +### 7.3 POST /users Request: @@ -317,7 +411,10 @@ curl -X POST "http://localhost:8000/users" \ -H "Content-Type: application/json" \ -d '{ "username": "alice", - "email": "alice@example.com" + "email": "alice@example.com", + "password": "password123", + "permission": "read", + "is_active": true }' ``` @@ -328,12 +425,15 @@ Response 201: "id": "user-1", "username": "alice", "email": "alice@example.com", + "permission": "read", + "is_active": true, "created_at": "2026-04-03T00:00:00Z", - "updated_at": "2026-04-03T00:00:00Z" + "updated_at": "2026-04-03T00:00:00Z", + "last_login_at": null } ``` -### 6.4 PATCH /users/{user_id} +### 7.4 PATCH /users/{user_id} Request: @@ -353,12 +453,15 @@ Response 200: "id": "user-1", "username": "alice-updated", "email": "alice@example.com", + "permission": "read", + "is_active": true, "created_at": "2026-04-03T00:00:00Z", - "updated_at": "2026-04-03T00:10:00Z" + "updated_at": "2026-04-03T00:10:00Z", + "last_login_at": null } ``` -### 6.5 DELETE /users/{user_id} +### 7.5 DELETE /users/{user_id} Request: diff --git a/docs/db-schema-spec.md b/docs/db-schema-spec.md index 2d84ffd..60a35d8 100644 --- a/docs/db-schema-spec.md +++ b/docs/db-schema-spec.md @@ -5,12 +5,13 @@ This document defines the database schema in an AI-agent-friendly format. - Source of truth: Alembic revisions in `migrations/versions` -- Current baseline revision: `20260403_0004` +- Current baseline revision: `20260403_0005` - Baseline files: - `migrations/versions/20260403_0001_initial_portfolio_schema.py` - `migrations/versions/20260403_0002_strategy_set_columns.py` - `migrations/versions/20260403_0003_portfolio_strategy_refs.py` - `migrations/versions/20260403_0004_users_table.py` + - `migrations/versions/20260403_0005_auth_user_fields_and_tokens.py` ## 2. Scope @@ -18,6 +19,7 @@ Schema objects covered by baseline revision: - `strategy_sets` - `users` +- `auth_tokens` - `portfolios` - `portfolio_ledger` - secondary indexes @@ -45,8 +47,12 @@ Columns: | id | Text | NO | - | Primary Key | | username | Text | NO | - | Unique username | | email | Text | YES | - | Unique when present | +| permission | Text | NO | "read" | User permission level | +| is_active | Boolean | NO | TRUE | Whether login is allowed | +| password_hash | Text | YES | - | Scrypt password hash | | created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp | | updated_at | DateTime(timezone=True) | NO | NOW() | Last update timestamp | +| last_login_at | DateTime(timezone=True) | YES | - | Last successful login timestamp | Constraints: @@ -54,7 +60,34 @@ Constraints: - Unique: `uq_users_username` on (`username`) - Unique: `uq_users_email` on (`email`) -### 4.2 strategy_sets +### 4.2 auth_tokens + +Purpose: + +- Stores DB-issued session tokens and client tokens. + +Columns: + +| Column | Type | Null | Default | Constraints / Notes | +|---|---|---|---|---| +| id | Text | NO | - | Primary Key | +| user_id | Text | NO | - | Logical user reference | +| name | Text | NO | - | Human-readable token name | +| token_prefix | Text | NO | - | Display prefix for issued token | +| token_hash | Text | NO | - | Unique SHA-256 token hash | +| permission | Text | NO | - | Effective token permission | +| token_type | Text | NO | - | `session` or `client` | +| expires_at | DateTime(timezone=True) | YES | - | Expiration time | +| last_used_at | DateTime(timezone=True) | YES | - | Last successful use | +| revoked_at | DateTime(timezone=True) | YES | - | Revocation time | +| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp | + +Constraints: + +- Primary key: `id` +- Unique: `uq_auth_tokens_token_hash` on (`token_hash`) + +### 4.3 strategy_sets Purpose: @@ -76,7 +109,7 @@ Constraints: - Unique: `uq_strategy_sets_name_config` on (`name`, `config`) - Unique: `uq_strategy_sets_nick` on (`nick`) -### 4.3 portfolios +### 4.4 portfolios Purpose: @@ -84,7 +117,7 @@ Purpose: Current baseline note: -- The columns below describe the currently migrated schema through revision `20260403_0004`. +- The columns below describe the currently migrated schema through revision `20260403_0005`. Columns: @@ -114,7 +147,7 @@ Constraint notes: - `active_strategy_id` is a nullable logical reference to `strategy_sets.id` - `avabile_strategies` stores zero or more selectable strategy set IDs when present -### 4.4 portfolio_ledger +### 4.5 portfolio_ledger Purpose: @@ -152,14 +185,28 @@ Constraints: - Unique: no - Intent: speed operational listing by recency. -### 5.2 idx_portfolio_ledger_portfolio_time +### 5.2 idx_auth_tokens_user_created + +- Table: `auth_tokens` +- Columns/expressions: (`user_id`, `created_at`) +- Unique: no +- Intent: fetch current user's issued tokens efficiently. + +### 5.3 idx_auth_tokens_hash_active + +- Table: `auth_tokens` +- Columns/expressions: (`token_hash`) +- Unique: no +- Intent: resolve bearer tokens efficiently. + +### 5.4 idx_portfolio_ledger_portfolio_time - Table: `portfolio_ledger` - Columns/expressions: (`portfolio_id`, `recorded_at DESC`) - Unique: no - Intent: fetch recent ledger entries per portfolio efficiently. -### 5.3 idx_portfolios_broker_status_updated +### 5.5 idx_portfolios_broker_status_updated - Table: `portfolios` - Columns/expressions: (`broker`, `status`, `updated_at DESC`) @@ -202,3 +249,4 @@ When schema changes: | 20260403_0002 | migrations/versions/20260403_0002_strategy_set_columns.py | Rename `strategy_name`->`name`, `strategy_config`->`config`, add unique `nick`, handle legacy unnamed unique constraint | | 20260403_0003 | migrations/versions/20260403_0003_portfolio_strategy_refs.py | Remove embedded portfolio strategy fields, add `active_strategy_id` and `avabile_strategies` | | 20260403_0004 | migrations/versions/20260403_0004_users_table.py | Add `users` table with unique username/email and created_at index | +| 20260403_0005 | migrations/versions/20260403_0005_auth_user_fields_and_tokens.py | Add password/permission/login fields to `users` and create `auth_tokens` table | diff --git a/docs/portfoliohub-spec.md b/docs/portfoliohub-spec.md index 4a9d8be..8913f90 100644 --- a/docs/portfoliohub-spec.md +++ b/docs/portfoliohub-spec.md @@ -72,10 +72,15 @@ Configuration precedence (high to low): Core settings: - `PORTFOLIOHUB_ALLOWED_ORIGINS`: comma-separated CORS origins +- `PORTFOLIOHUB_BOOTSTRAP_USERNAME`: initial username created on startup when users table is empty +- `PORTFOLIOHUB_BOOTSTRAP_EMAIL`: optional email for the bootstrap user +- `PORTFOLIOHUB_BOOTSTRAP_PASSWORD`: initial password for the bootstrap admin user - `PORTFOLIOHUB_READ_TOKENS`: comma-separated read tokens - `PORTFOLIOHUB_TRADE_WRITE_TOKENS`: comma-separated write tokens - `PORTFOLIOHUB_ADMIN_TOKENS`: comma-separated admin tokens - `PORTFOLIOHUB_DB_ENABLED`: enables DB repository selection when `true` +- `PORTFOLIOHUB_SESSION_TOKEN_TTL_HOURS`: login session token TTL in hours +- `PORTFOLIOHUB_CLIENT_TOKEN_DEFAULT_TTL_DAYS`: default client token TTL in days Database settings (active source): @@ -121,6 +126,7 @@ Failure responses: Base routes: - Health: `/health` +- Auth: `/auth` - Portfolio: `/portfolios` - Users: `/users` @@ -247,7 +253,38 @@ Note: Since strategy sets are identified by (name, config) uniqueness, updating - See `docs/api-examples.md` for maintained request/response examples. -### 7.5 User Endpoints +### 7.5 Auth Endpoints + +#### Login + +- `POST /auth/login` +- Auth: none +- Body: `LoginRequest` +- Response `200`: `TokenResponse` +- Response `401`: invalid username/password + +#### List Current Tokens + +- `GET /auth/tokens` +- Auth: `read` or higher with DB-issued token/session +- Response `200`: array of `AuthTokenResponse` + +#### Create Client Token + +- `POST /auth/tokens` +- Auth: `read` or higher with DB-issued token/session +- Body: `CreateClientTokenRequest` +- Response `201`: `IssuedClientTokenResponse` +- Response `403`: requested token permission exceeds current permission + +#### Revoke Client Token + +- `DELETE /auth/tokens/{token_id}` +- Auth: `read` or higher with DB-issued token/session +- Response `204`: no content +- Response `404`: token not found + +### 7.6 User Endpoints All user management endpoints are admin-only. @@ -272,6 +309,10 @@ All user management endpoints are admin-only. - Response `201`: `UserResponse` - Response `409`: duplicate username or email +Bootstrap rule: + +- If DB-backed persistence is enabled and both `PORTFOLIOHUB_BOOTSTRAP_USERNAME` and `PORTFOLIOHUB_BOOTSTRAP_PASSWORD` are configured, application startup creates the bootstrap admin user only when the `users` table is empty. + #### Update User - `PATCH /users/{user_id}` @@ -326,11 +367,28 @@ All user management endpoints are admin-only. - `username: str` - `email: str | null` +- `password: str` +- `permission: "read" | "trade-write" | "admin"` +- `is_active: bool` `UpdateUserRequest` - `username: str | null` - `email: str | null` +- `password: str | null` +- `permission: "read" | "trade-write" | "admin" | null` +- `is_active: bool | null` + +`LoginRequest` + +- `username: str` +- `password: str` + +`CreateClientTokenRequest` + +- `name: str` +- `permission: "read" | "trade-write" | "admin" | null` +- `expires_in_days: int | null` - `price: float > 0` - `fee: float >= 0` - `tax: float >= 0` diff --git a/migrations/versions/20260403_0005_auth_user_fields_and_tokens.py b/migrations/versions/20260403_0005_auth_user_fields_and_tokens.py new file mode 100644 index 0000000..2dadeee --- /dev/null +++ b/migrations/versions/20260403_0005_auth_user_fields_and_tokens.py @@ -0,0 +1,90 @@ +"""add user auth fields and auth tokens + +Revision ID: 20260403_0005 +Revises: 20260403_0004 +Create Date: 2026-04-03 03:00:00 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +revision = "20260403_0005" +down_revision = "20260403_0004" +branch_labels = None +depends_on = None + + +def _table_exists(inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def _column_exists(inspector, table_name: str, column_name: str) -> bool: + return any(column["name"] == column_name for column in inspector.get_columns(table_name)) + + +def _index_exists(inspector, table_name: str, index_name: str) -> bool: + return any(index["name"] == index_name for index in inspector.get_indexes(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + if not _column_exists(inspector, "users", "permission"): + op.add_column("users", sa.Column("permission", sa.Text(), nullable=False, server_default="read")) + if not _column_exists(inspector, "users", "is_active"): + op.add_column("users", sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("TRUE"))) + if not _column_exists(inspector, "users", "password_hash"): + op.add_column("users", sa.Column("password_hash", sa.Text(), nullable=True)) + if not _column_exists(inspector, "users", "last_login_at"): + op.add_column("users", sa.Column("last_login_at", sa.TIMESTAMP(timezone=True), nullable=True)) + + if not _table_exists(inspector, "auth_tokens"): + op.create_table( + "auth_tokens", + sa.Column("id", sa.Text(), nullable=False), + sa.Column("user_id", sa.Text(), nullable=False), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("token_prefix", sa.Text(), nullable=False), + sa.Column("token_hash", sa.Text(), nullable=False), + sa.Column("permission", sa.Text(), nullable=False), + sa.Column("token_type", sa.Text(), nullable=False), + sa.Column("expires_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("last_used_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("revoked_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token_hash", name="uq_auth_tokens_token_hash"), + ) + + inspector = inspect(bind) + if not _index_exists(inspector, "auth_tokens", "idx_auth_tokens_user_created"): + op.create_index("idx_auth_tokens_user_created", "auth_tokens", ["user_id", "created_at"]) + if not _index_exists(inspector, "auth_tokens", "idx_auth_tokens_hash_active"): + op.create_index("idx_auth_tokens_hash_active", "auth_tokens", ["token_hash"]) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + if _table_exists(inspector, "auth_tokens"): + if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_hash_active"): + op.drop_index("idx_auth_tokens_hash_active", table_name="auth_tokens") + if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_user_created"): + op.drop_index("idx_auth_tokens_user_created", table_name="auth_tokens") + op.drop_table("auth_tokens") + + inspector = inspect(bind) + if _column_exists(inspector, "users", "last_login_at"): + op.drop_column("users", "last_login_at") + if _column_exists(inspector, "users", "password_hash"): + op.drop_column("users", "password_hash") + if _column_exists(inspector, "users", "is_active"): + op.drop_column("users", "is_active") + if _column_exists(inspector, "users", "permission"): + op.drop_column("users", "permission") \ No newline at end of file diff --git a/tests/test_app.py b/tests/test_app.py index d2aabea..b2d1a3c 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,7 +1,9 @@ from fastapi.testclient import TestClient +from app.core.config import Settings from app.core.config import get_settings -from app.main import app +from app.domain.models import User +from app.main import app, create_app def test_healthcheck() -> None: @@ -25,3 +27,80 @@ def test_settings_default_origin() -> None: settings = get_settings() assert settings.allowed_origins == ["http://localhost:3000"] + + +class _BootstrapRepo: + def __init__(self, users: list[User] | None = None) -> None: + self.users = list(users or []) + self.created: list[tuple[str, str | None, str | None, str]] = [] + self.closed = False + + def list_users(self) -> list[User]: + return list(self.users) + + def create_user( + self, + username: str, + *, + email: str | None = None, + password_hash: str | None = None, + permission: str = "read", + is_active: bool = True, + ) -> User: + self.created.append((username, email, password_hash, permission)) + user = User( + id=f"user-{len(self.users) + 1}", + username=username, + email=email, + permission=permission, + is_active=is_active, + password_hash=password_hash, + ) + self.users.append(user) + return user + + def close(self) -> None: + self.closed = True + + +def test_startup_bootstraps_initial_user_when_users_table_empty() -> None: + repo = _BootstrapRepo() + settings = Settings( + db_enabled=True, + bootstrap_username="admin", + bootstrap_email="admin@example.com", + bootstrap_password="bootstrap123", + allowed_origins_raw="http://localhost:3000", + ) + bootstrap_app = create_app(settings_factory=lambda: settings, repository_factory=lambda current: repo) + + with TestClient(bootstrap_app) as client: + response = client.get("/health") + + assert response.status_code == 200 + + assert repo.created[0][0] == "admin" + assert repo.created[0][1] == "admin@example.com" + assert repo.created[0][2] is not None + assert repo.created[0][3] == "admin" + assert repo.closed is True + + +def test_startup_does_not_bootstrap_when_users_exist() -> None: + repo = _BootstrapRepo(users=[User(id="user-1", username="existing")]) + settings = Settings( + db_enabled=True, + bootstrap_username="admin", + bootstrap_email="admin@example.com", + bootstrap_password="bootstrap123", + allowed_origins_raw="http://localhost:3000", + ) + bootstrap_app = create_app(settings_factory=lambda: settings, repository_factory=lambda current: repo) + + with TestClient(bootstrap_app) as client: + response = client.get("/health") + + assert response.status_code == 200 + + assert repo.created == [] + assert repo.closed is True diff --git a/tests/test_cli.py b/tests/test_cli.py index 1e4533c..cbedd15 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -39,7 +39,15 @@ class FakeRepo: def list_users(self) -> list[User]: return sorted(self._users.values(), key=lambda user: user.username) - def create_user(self, username: str, *, email: str | None = None) -> User: + def create_user( + self, + username: str, + *, + email: str | None = None, + password_hash: str | None = None, + permission: str = "read", + is_active: bool = True, + ) -> User: if any(user.username == username for user in self._users.values()): raise ValueError(f"이미 존재하는 username입니다: {username}") if email is not None and any(user.email == email for user in self._users.values()): @@ -49,6 +57,9 @@ class FakeRepo: id=f"user-{self._user_seq}", username=username, email=email, + permission=permission, + is_active=is_active, + password_hash=password_hash, created_at=now, updated_at=now, ) @@ -56,7 +67,16 @@ class FakeRepo: self._users[user.id] = user return user - def update_user(self, user_id: str, *, username: str | None = None, email: str | None = None) -> User: + def update_user( + self, + user_id: str, + *, + username: str | None = None, + email: str | None = None, + password_hash: str | None = None, + permission: str | None = None, + is_active: bool | None = None, + ) -> User: user = self.get_user(user_id) if username is not None and any(item.id != user_id and item.username == username for item in self._users.values()): raise ValueError(f"이미 존재하는 username입니다: {username}") @@ -66,6 +86,12 @@ class FakeRepo: user.username = username if email is not None: user.email = email + if password_hash is not None: + user.password_hash = password_hash + if permission is not None: + user.permission = permission + if is_active is not None: + user.is_active = is_active user.updated_at = datetime.now(timezone.utc) return user @@ -459,7 +485,19 @@ def test_cli_user_create_supports_json() -> None: result = runner.invoke( runner.app, - ["--format", "json", "user", "create", "--username", "alice", "--email", "alice@example.com", "--force"], + [ + "--format", + "json", + "user", + "create", + "--username", + "alice", + "--password", + "password123", + "--email", + "alice@example.com", + "--force", + ], ) assert result.exit_code == 0 diff --git a/tests/test_users.py b/tests/test_users.py index 99930b1..40ba07d 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -4,15 +4,23 @@ from datetime import datetime, timezone from fastapi.testclient import TestClient -from app.domain.models import User +import app.api.users as users_api +from app.core.security import hash_password, hash_token +from app.domain.models import AuthToken, TokenPrincipal, User from app.main import app +from app.core.auth import get_auth_repository +from app.services.auth_service import AuthService, get_auth_service from app.services.user_service import UserService, get_user_service class FakeUserRepo: def __init__(self) -> None: self.users: dict[str, User] = {} + self.users_by_username: dict[str, User] = {} + self.tokens: dict[str, AuthToken] = {} + self.token_principals: dict[str, TokenPrincipal] = {} self.next_id = 1 + self.next_token_id = 1 def get_user(self, user_id: str) -> User: user = self.users.get(user_id) @@ -20,10 +28,21 @@ class FakeUserRepo: raise ValueError("사용자를 찾을 수 없습니다") return user + def get_user_by_username(self, username: str) -> User | None: + return self.users_by_username.get(username) + def list_users(self) -> list[User]: return sorted(self.users.values(), key=lambda user: user.username) - def create_user(self, username: str, *, email: str | None = None) -> User: + def create_user( + self, + username: str, + *, + email: str | None = None, + password_hash: str | None = None, + permission: str = "read", + is_active: bool = True, + ) -> User: if any(user.username == username for user in self.users.values()): raise ValueError(f"이미 존재하는 username입니다: {username}") if email is not None and any(user.email == email for user in self.users.values()): @@ -33,34 +52,113 @@ class FakeUserRepo: id=f"user-{self.next_id}", username=username, email=email, + permission=permission, + is_active=is_active, + password_hash=password_hash, created_at=now, updated_at=now, ) self.next_id += 1 self.users[user.id] = user + self.users_by_username[user.username] = user return user - def update_user(self, user_id: str, *, username: str | None = None, email: str | None = None) -> User: + def update_user( + self, + user_id: str, + *, + username: str | None = None, + email: str | None = None, + password_hash: str | None = None, + permission: str | None = None, + is_active: bool | None = None, + ) -> User: user = self.get_user(user_id) if username is not None and any(item.id != user_id and item.username == username for item in self.users.values()): raise ValueError(f"이미 존재하는 username입니다: {username}") if email is not None and any(item.id != user_id and item.email == email for item in self.users.values()): raise ValueError(f"이미 존재하는 email입니다: {email}") + self.users_by_username.pop(user.username, None) if username is not None: user.username = username if email is not None: user.email = email + if password_hash is not None: + user.password_hash = password_hash + if permission is not None: + user.permission = permission + if is_active is not None: + user.is_active = is_active user.updated_at = datetime.now(timezone.utc) + self.users_by_username[user.username] = user return user def delete_user(self, user_id: str) -> None: if user_id not in self.users: raise ValueError("사용자를 찾을 수 없습니다") - del self.users[user_id] + user = self.users.pop(user_id) + self.users_by_username.pop(user.username, None) + + def update_user_last_login(self, user_id: str) -> None: + user = self.get_user(user_id) + user.last_login_at = datetime.now(timezone.utc) + + def create_auth_token( + self, + user_id: str, + *, + name: str, + token_hash: str, + token_prefix: str, + permission: str, + token_type: str, + expires_at: datetime | None = None, + ) -> AuthToken: + now = datetime.now(timezone.utc) + token = AuthToken( + id=f"token-{self.next_token_id}", + user_id=user_id, + name=name, + token_prefix=token_prefix, + permission=permission, + token_type=token_type, + expires_at=expires_at, + created_at=now, + ) + self.next_token_id += 1 + self.tokens[token.id] = token + self.token_principals[token_hash] = TokenPrincipal( + token_id=token.id, + user_id=user_id, + permission=permission, + token_type=token_type, + ) + return token + + def list_auth_tokens(self, user_id: str) -> list[AuthToken]: + return [token for token in self.tokens.values() if token.user_id == user_id] + + def revoke_auth_token(self, user_id: str, token_id: str) -> None: + token = self.tokens.get(token_id) + if token is None or token.user_id != user_id: + raise ValueError("토큰을 찾을 수 없습니다") + token.revoked_at = datetime.now(timezone.utc) + + def get_token_principal(self, token_hash_value: str) -> TokenPrincipal | None: + principal = self.token_principals.get(token_hash_value) + if principal is None: + return None + token = self.tokens.get(principal.token_id) + if token is None or token.revoked_at is not None: + return None + if token.expires_at is not None and token.expires_at <= datetime.now(timezone.utc): + return None + token.last_used_at = datetime.now(timezone.utc) + return principal def _client_with_repo(monkeypatch, repo: FakeUserRepo) -> TestClient: - monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false") + monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true") monkeypatch.setenv("PORTFOLIOHUB_ADMIN_TOKENS", "admin-token") monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token") from app.core.config import get_settings @@ -68,6 +166,9 @@ def _client_with_repo(monkeypatch, repo: FakeUserRepo) -> TestClient: get_settings.cache_clear() app.dependency_overrides.clear() app.dependency_overrides[get_user_service] = lambda: UserService(repo) + app.dependency_overrides[get_auth_repository] = lambda: repo + app.dependency_overrides[get_auth_service] = lambda: AuthService(repo, get_settings()) + monkeypatch.setattr(users_api, "get_portfolio_repository_from_settings", lambda settings: repo) return TestClient(app) @@ -83,7 +184,7 @@ def test_list_users_requires_admin(monkeypatch) -> None: def test_list_users_returns_payload(monkeypatch) -> None: repo = FakeUserRepo() - repo.create_user("alice", email="alice@example.com") + repo.create_user("alice", email="alice@example.com", password_hash=hash_password("password123")) client = _client_with_repo(monkeypatch, repo) response = client.get("/users", headers={"Authorization": "Bearer admin-token"}) @@ -97,13 +198,13 @@ def test_list_users_returns_payload(monkeypatch) -> None: def test_create_user_rejects_duplicate_username(monkeypatch) -> None: repo = FakeUserRepo() - repo.create_user("alice") + repo.create_user("alice", password_hash=hash_password("password123")) client = _client_with_repo(monkeypatch, repo) response = client.post( "/users", headers={"Authorization": "Bearer admin-token"}, - json={"username": "alice", "email": "alice2@example.com"}, + json={"username": "alice", "email": "alice2@example.com", "password": "password123"}, ) app.dependency_overrides.clear() @@ -113,7 +214,7 @@ def test_create_user_rejects_duplicate_username(monkeypatch) -> None: def test_get_update_and_delete_user_flow(monkeypatch) -> None: repo = FakeUserRepo() - user = repo.create_user("alice", email="alice@example.com") + user = repo.create_user("alice", email="alice@example.com", password_hash=hash_password("password123")) client = _client_with_repo(monkeypatch, repo) get_response = client.get(f"/users/{user.id}", headers={"Authorization": "Bearer admin-token"}) @@ -129,4 +230,33 @@ def test_get_update_and_delete_user_flow(monkeypatch) -> None: assert get_response.json()["username"] == "alice" assert update_response.status_code == 200 assert update_response.json()["username"] == "alice-updated" - assert delete_response.status_code == 204 \ No newline at end of file + assert delete_response.status_code == 204 + + +def test_login_and_issue_client_token_flow(monkeypatch) -> None: + repo = FakeUserRepo() + repo.create_user( + "alice", + email="alice@example.com", + password_hash=hash_password("password123"), + permission="trade-write", + ) + client = _client_with_repo(monkeypatch, repo) + + login_response = client.post("/auth/login", json={"username": "alice", "password": "password123"}) + + assert login_response.status_code == 200 + login_payload = login_response.json() + session_token = login_payload["access_token"] + token_response = client.post( + "/auth/tokens", + headers={"Authorization": f"Bearer {session_token}"}, + json={"name": "mobile-app", "permission": "read", "expires_in_days": 30}, + ) + + app.dependency_overrides.clear() + assert token_response.status_code == 201 + token_payload = token_response.json() + assert token_payload["auth_token"]["name"] == "mobile-app" + assert token_payload["auth_token"]["token_type"] == "client" + assert token_payload["token"] \ No newline at end of file