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
+5
View File
@@ -7,6 +7,11 @@ PORTFOLIOHUB_ENVIRONMENT=development
# Enable DB-backed repository (uses TIMESCALEDB_* below) # Enable DB-backed repository (uses TIMESCALEDB_* below)
PORTFOLIOHUB_DB_ENABLED=false 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 / PostgreSQL connection settings
TIMESCALEDB_HOST=localhost TIMESCALEDB_HOST=localhost
+13 -1
View File
@@ -30,6 +30,9 @@ When deploying to production, set environment variables directly instead of usin
### Key configuration options ### Key configuration options
- **DB repository switch**: `PORTFOLIOHUB_DB_ENABLED` (enables DB-backed persistence) - **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) - **TimescaleDB / PostgreSQL**: `TIMESCALEDB_*` (single source of DB connection settings)
- **Redis**: `REDIS_*` (for caching and sessions) - **Redis**: `REDIS_*` (for caching and sessions)
- **Loki**: `LOKI_*` (for centralized logging) - **Loki**: `LOKI_*` (for centralized logging)
@@ -61,7 +64,7 @@ The repository now includes a `portfoliohub` CLI that mirrors the main portfolio
```bash ```bash
portfoliohub --help portfoliohub --help
portfoliohub user list 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 <user-id> portfoliohub user get <user-id>
portfoliohub user update <user-id> --username alice-updated --force portfoliohub user update <user-id> --username alice-updated --force
portfoliohub user delete <user-id> --force portfoliohub user delete <user-id> --force
@@ -168,6 +171,13 @@ User management is exposed through both HTTP API and CLI.
- Permission: `admin` only - Permission: `admin` only
- Supported operations: list, get, create, update, delete - 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: Example API calls:
```bash ```bash
@@ -182,6 +192,8 @@ curl -X POST "http://localhost:8000/users" \
The default `.env.example` includes example tokens for development. 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 ## Planned next steps
1. Apply and verify Alembic migrations against a real PostgreSQL/Timescale instance. 1. Apply and verify Alembic migrations against a real PostgreSQL/Timescale instance.
+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.""" """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 ( from app.schemas.portfolio import (
BuyRequest, BuyRequest,
CreatePortfolioRequest, CreatePortfolioRequest,
@@ -14,101 +16,179 @@ from app.schemas.portfolio import (
TransferRequest, TransferRequest,
UpdatePortfolioRequest, 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 = 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( def list_portfolios(
request: Request,
broker: str | None = None, broker: str | None = None,
status_filter: str | None = None, status_filter: str | None = None,
service: PortfolioService = Depends(get_portfolio_service), settings: Settings = Depends(get_settings),
) -> list[PortfolioListItem]: ) -> 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( def get_portfolio(
portfolio_id: str, portfolio_id: str,
request: Request,
*,
recent_trades: int = 5, recent_trades: int = 5,
service: PortfolioService = Depends(get_portfolio_service), settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse: ) -> 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( @router.post(
"", "",
response_model=PortfolioDetailResponse, response_model=PortfolioDetailResponse,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_trade_write)],
) )
def create_portfolio( def create_portfolio(
payload: CreatePortfolioRequest, payload: CreatePortfolioRequest,
service: PortfolioService = Depends(get_portfolio_service), request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse: ) -> 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( def update_portfolio(
portfolio_id: str, portfolio_id: str,
payload: UpdatePortfolioRequest, payload: UpdatePortfolioRequest,
service: PortfolioService = Depends(get_portfolio_service), request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse: ) -> 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( def buy_portfolio_asset(
portfolio_id: str, portfolio_id: str,
payload: BuyRequest, payload: BuyRequest,
service: PortfolioService = Depends(get_portfolio_service), request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse: ) -> 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( def sell_portfolio_asset(
portfolio_id: str, portfolio_id: str,
payload: SellRequest, payload: SellRequest,
service: PortfolioService = Depends(get_portfolio_service), request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse: ) -> 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( def invest_buffer(
portfolio_id: str, portfolio_id: str,
payload: InvestRequest, payload: InvestRequest,
service: PortfolioService = Depends(get_portfolio_service), request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse: ) -> 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( def transfer_buffer(
portfolio_id: str, portfolio_id: str,
payload: TransferRequest, payload: TransferRequest,
service: PortfolioService = Depends(get_portfolio_service), request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse: ) -> 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( def reset_portfolio(
portfolio_id: str, portfolio_id: str,
payload: ResetPortfolioRequest, payload: ResetPortfolioRequest,
service: PortfolioService = Depends(get_portfolio_service), request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse: ) -> 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( def delete_portfolio(
portfolio_id: str, portfolio_id: str,
request: Request,
*,
keep_ledger: bool = False, keep_ledger: bool = False,
service: PortfolioService = Depends(get_portfolio_service), settings: Settings = Depends(get_settings),
) -> Response: ) -> 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) 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 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.schemas.portfolio import CreateStrategySetRequest, StrategySetResponse, UpdateStrategySetRequest
from app.services.strategy_set_service import StrategySetService, get_strategy_set_repository from app.services.strategy_set_service import StrategySetService
def get_strategy_set_service(repository=Depends(get_strategy_set_repository)) -> StrategySetService:
return StrategySetService(repository)
router = APIRouter(prefix="/strategy-sets", tags=["strategy-sets"]) 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( def list_strategy_sets(
service: StrategySetService = Depends(get_strategy_set_service), _auth: AuthContext = Depends(require_read),
settings: Settings = Depends(get_settings),
) -> list[StrategySetResponse]: ) -> list[StrategySetResponse]:
"""List all strategy sets.""" """List all strategy sets."""
strategy_sets = service.list() service, repository = _get_service(settings)
try:
strategy_sets = service.list()
finally:
_close_repository(repository)
return [ return [
StrategySetResponse( StrategySetResponse(
id=ss.id, 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( def create_strategy_set(
payload: CreateStrategySetRequest, payload: CreateStrategySetRequest,
service: StrategySetService = Depends(get_strategy_set_service), _auth: AuthContext = Depends(require_trade_write),
settings: Settings = Depends(get_settings),
) -> StrategySetResponse: ) -> StrategySetResponse:
"""Create a new strategy set (or return existing if (name, config) pair already exists).""" """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( return StrategySetResponse(
id=strategy_set.id, id=strategy_set.id,
name=strategy_set.name, 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( def update_strategy_set(
strategy_set_id: str, strategy_set_id: str,
payload: UpdateStrategySetRequest, payload: UpdateStrategySetRequest,
service: StrategySetService = Depends(get_strategy_set_service), _auth: AuthContext = Depends(require_trade_write),
settings: Settings = Depends(get_settings),
) -> StrategySetResponse: ) -> StrategySetResponse:
"""Update a strategy set's name and/or config. """Update a strategy set's name and/or config.
Note: Since strategy sets are identified by (name, config) uniqueness, Note: Since strategy sets are identified by (name, config) uniqueness,
updating may result in a new strategy set ID if name or config changes. 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( return StrategySetResponse(
id=strategy_set.id, id=strategy_set.id,
name=strategy_set.name, 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( def delete_strategy_set(
strategy_set_id: str, strategy_set_id: str,
service: StrategySetService = Depends(get_strategy_set_service), _auth: AuthContext = Depends(require_trade_write),
settings: Settings = Depends(get_settings),
) -> Response: ) -> Response:
"""Delete a strategy set. """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. Returns 409 Conflict if the strategy set is in use by any portfolio.
""" """
try: 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: except ValueError as e:
# If strategy set is in use, return 409 Conflict # If strategy set is in use, return 409 Conflict
raise ValueError(str(e)) from e raise ValueError(str(e)) from e
+70 -20
View File
@@ -1,26 +1,40 @@
"""User management API routes.""" """User management API routes."""
from __future__ import annotations from __future__ import annotations
from typing import NoReturn from typing import NoReturn
from fastapi import APIRouter, Depends, HTTPException, Response, status 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.domain.models import User
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.user import CreateUserRequest, UpdateUserRequest, UserResponse 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"]) 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: def _to_user_response(user: User) -> UserResponse:
return UserResponse( return UserResponse(
id=user.id, id=user.id,
username=user.username, username=user.username,
email=user.email, email=user.email,
permission=user.permission,
is_active=user.is_active,
created_at=user.created_at, created_at=user.created_at,
updated_at=user.updated_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 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) from exc
@router.get("", response_model=list[UserResponse], dependencies=[Depends(require_admin)]) @router.get("", response_model=list[UserResponse])
def list_users(service: UserService = Depends(get_user_service)) -> list[UserResponse]: def list_users(
return [_to_user_response(user) for user in service.list()] _auth: AuthContext = Depends(require_admin),
settings: Settings = Depends(get_settings),
) -> list[UserResponse]:
@router.get("/{user_id}", response_model=UserResponse, dependencies=[Depends(require_admin)]) service, repository = _get_service(settings)
def get_user(user_id: str, service: UserService = Depends(get_user_service)) -> UserResponse:
try: 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: except ValueError as exc:
_raise_user_error(exc) _raise_user_error(exc)
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_admin)]) @router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(payload: CreateUserRequest, service: UserService = Depends(get_user_service)) -> UserResponse: def create_user(
payload: CreateUserRequest,
_auth: AuthContext = Depends(require_admin),
settings: Settings = Depends(get_settings),
) -> UserResponse:
try: 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: except ValueError as exc:
_raise_user_error(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( def update_user(
user_id: str, user_id: str,
payload: UpdateUserRequest, payload: UpdateUserRequest,
service: UserService = Depends(get_user_service), _auth: AuthContext = Depends(require_admin),
settings: Settings = Depends(get_settings),
) -> UserResponse: ) -> UserResponse:
try: 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: except ValueError as exc:
_raise_user_error(exc) _raise_user_error(exc)
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_admin)]) @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(user_id: str, service: UserService = Depends(get_user_service)) -> Response: def delete_user(
user_id: str,
_auth: AuthContext = Depends(require_admin),
settings: Settings = Depends(get_settings),
) -> Response:
try: try:
service.delete(user_id) service, repository = _get_service(settings)
try:
service.delete(user_id)
finally:
_close_repository(repository)
except ValueError as exc: except ValueError as exc:
_raise_user_error(exc) _raise_user_error(exc)
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
+1 -1
View File
@@ -100,5 +100,5 @@ def register_health_commands(app: typer.Typer) -> None:
for key, value in health.items(): for key, value in health.items():
app_context.console.print(f"{key}: {value}") app_context.console.print(f"{key}: {value}")
finally: finally:
if hasattr(repository, "close"): if callable(getattr(type(repository), "close", None)):
repository.close() repository.close()
+1 -1
View File
@@ -40,7 +40,7 @@ def _with_service(ctx: typer.Context, callback):
result = execute_command(app_context.console, callback=lambda: callback(service, app_context)) result = execute_command(app_context.console, callback=lambda: callback(service, app_context))
return result return result
finally: finally:
if hasattr(repository, "close"): if callable(getattr(type(repository), "close", None)):
repository.close() repository.close()
+1 -1
View File
@@ -19,7 +19,7 @@ def _with_service(ctx: typer.Context, callback):
result = execute_command(app_context.console, callback=lambda: callback(service, app_context)) result = execute_command(app_context.console, callback=lambda: callback(service, app_context))
return result return result
finally: finally:
if hasattr(repository, "close"): if callable(getattr(type(repository), "close", None)):
repository.close() repository.close()
+29 -3
View File
@@ -20,7 +20,7 @@ def _with_service(ctx: typer.Context, callback):
try: try:
return execute_command(app_context.console, callback=lambda: callback(service, app_context)) return execute_command(app_context.console, callback=lambda: callback(service, app_context))
finally: finally:
if hasattr(repository, "close"): if callable(getattr(type(repository), "close", None)):
repository.close() repository.close()
@@ -29,8 +29,11 @@ def _to_user_response(user) -> UserResponse:
id=user.id, id=user.id,
username=user.username, username=user.username,
email=user.email, email=user.email,
permission=user.permission,
is_active=user.is_active,
created_at=user.created_at, created_at=user.created_at,
updated_at=user.updated_at, updated_at=user.updated_at,
last_login_at=user.last_login_at,
) )
@@ -53,14 +56,25 @@ def get_user(
def create_user( def create_user(
ctx: typer.Context, ctx: typer.Context,
username: str = typer.Option(..., help="Username."), username: str = typer.Option(..., help="Username."),
password: str = typer.Option(..., help="Initial password."),
email: str | None = typer.Option(None, help="Email address."), 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."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None: ) -> None:
_confirm(force, f"Create user {username}?") _confirm(force, f"Create user {username}?")
user = _with_service( user = _with_service(
ctx, ctx,
lambda service, app_context: _to_user_response( 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) render_output(ctx.obj.console, ctx.obj.output_format, user)
@@ -71,14 +85,26 @@ def update_user(
ctx: typer.Context, ctx: typer.Context,
user_id: str = typer.Argument(..., help="User identifier."), user_id: str = typer.Argument(..., help="User identifier."),
username: str | None = typer.Option(None, help="New username."), 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."), 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."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None: ) -> None:
_confirm(force, f"Update user {user_id}?") _confirm(force, f"Update user {user_id}?")
user = _with_service( user = _with_service(
ctx, ctx,
lambda service, app_context: _to_user_response( 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) render_output(ctx.obj.console, ctx.obj.output_format, user)
+68 -12
View File
@@ -4,11 +4,14 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
from typing import Generator
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.config import Settings, get_settings 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): class Permission(StrEnum):
@@ -21,9 +24,17 @@ class Permission(StrEnum):
class AuthContext: class AuthContext:
token: str token: str
permission: Permission permission: Permission
user_id: str | None = None
token_id: str | None = None
token_source: str = "env"
def get_auth_repository(settings: Settings = Depends(get_settings)) -> Generator[PortfolioRepository, None, None]:
security = HTTPBearer(auto_error=False) 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: def _resolve_permission(token: str, settings: Settings) -> Permission | None:
@@ -36,6 +47,26 @@ def _resolve_permission(token: str, settings: Settings) -> Permission | None:
return 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: def _has_permission(current: Permission, required: Permission) -> bool:
if current == Permission.ADMIN: if current == Permission.ADMIN:
return True return True
@@ -48,28 +79,53 @@ def _has_permission(current: Permission, required: Permission) -> bool:
def require_permission( def require_permission(
required: Permission, required: Permission,
credentials: HTTPAuthorizationCredentials | None = Depends(security), request: Request,
settings: Settings = Depends(get_settings), settings: Settings = Depends(get_settings),
repository: PortfolioRepository = Depends(get_auth_repository),
) -> AuthContext: ) -> 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") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
permission = _resolve_permission(credentials.credentials, settings) managed_repository = repository is None
if permission 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") 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") 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 permission_dependency(required: Permission):
def dependency( def dependency(
credentials: HTTPAuthorizationCredentials | None = Depends(security), request: Request,
settings: Settings = Depends(get_settings), settings: Settings = Depends(get_settings),
repository: PortfolioRepository = Depends(get_auth_repository),
) -> AuthContext: ) -> AuthContext:
return require_permission(required, credentials=credentials, settings=settings) return require_permission(required, request=request, settings=settings, repository=repository)
return dependency return dependency
+24
View File
@@ -21,10 +21,15 @@ class Settings(BaseSettings):
app_version: str = "0.1.0" app_version: str = "0.1.0"
environment: str = "development" environment: str = "development"
allowed_origins_raw: str = Field(default="http://localhost:3000", validation_alias="PORTFOLIOHUB_ALLOWED_ORIGINS") 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") read_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_READ_TOKENS")
trade_write_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_TRADE_WRITE_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") admin_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_ADMIN_TOKENS")
db_enabled: bool = Field(default=False, validation_alias="PORTFOLIOHUB_DB_ENABLED") 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 # External services configuration
timescaledb_host: str = Field(default="localhost", validation_alias="TIMESCALEDB_HOST") timescaledb_host: str = Field(default="localhost", validation_alias="TIMESCALEDB_HOST")
@@ -49,6 +54,7 @@ class Settings(BaseSettings):
env_prefix="PORTFOLIOHUB_", env_prefix="PORTFOLIOHUB_",
case_sensitive=False, case_sensitive=False,
extra="ignore", extra="ignore",
populate_by_name=True,
) )
@computed_field @computed_field
@@ -56,6 +62,24 @@ class Settings(BaseSettings):
def allowed_origins(self) -> list[str]: def allowed_origins(self) -> list[str]:
return [item.strip() for item in self.allowed_origins_raw.split(",") if item.strip()] 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 @computed_field
@property @property
def read_tokens(self) -> set[str]: def read_tokens(self) -> set[str]:
+50
View File
@@ -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]
+26
View File
@@ -21,8 +21,34 @@ class User:
id: str id: str
username: str username: str
email: str | None = None email: str | None = None
permission: str = "read"
is_active: bool = True
password_hash: str | None = None
created_at: datetime | None = None created_at: datetime | None = None
updated_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 @dataclass
+44 -3
View File
@@ -1,23 +1,63 @@
"""FastAPI application entrypoint.""" """FastAPI application entrypoint."""
from contextlib import asynccontextmanager
from typing import Callable
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api.health import router as health_router 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.portfolios import router as portfolio_router
from app.api.strategy_sets import router as strategy_sets_router from app.api.strategy_sets import router as strategy_sets_router
from app.api.users import router as users_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.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: def _bootstrap_initial_user(
settings = get_settings() 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( app = FastAPI(
title=settings.app_name, title=settings.app_name,
version=settings.app_version, version=settings.app_version,
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
lifespan=lifespan,
) )
app.add_middleware( app.add_middleware(
@@ -29,6 +69,7 @@ def create_app() -> FastAPI:
) )
app.include_router(health_router) app.include_router(health_router)
app.include_router(auth_router)
app.include_router(portfolio_router) app.include_router(portfolio_router)
app.include_router(strategy_sets_router) app.include_router(strategy_sets_router)
app.include_router(users_router) app.include_router(users_router)
+43 -3
View File
@@ -2,9 +2,10 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import Protocol 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): class PortfolioRepository(Protocol):
@@ -12,14 +13,53 @@ class PortfolioRepository(Protocol):
def get_user(self, user_id: str) -> User: ... 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 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 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(self, portfolio_id: str) -> Portfolio: ...
def get_or_create( def get_or_create(
+197 -12
View File
@@ -12,18 +12,37 @@ from typing import Any
import psycopg import psycopg
from psycopg import sql 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_USER = """
SELECT SELECT
id, id,
username, username,
email, email,
permission,
is_active,
password_hash,
created_at, created_at,
updated_at updated_at,
last_login_at
FROM users 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 = """
SELECT SELECT
portfolio_id, portfolio_id,
@@ -94,11 +113,44 @@ class PostgresPortfolioRepository:
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
email TEXT 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(), 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( cur.execute(
""" """
CREATE TABLE IF NOT EXISTS strategy_sets ( CREATE TABLE IF NOT EXISTS strategy_sets (
@@ -178,8 +230,29 @@ class PostgresPortfolioRepository:
id=row[0], id=row[0],
username=row[1], username=row[1],
email=row[2], email=row[2],
created_at=row[3] if len(row) > 3 else None, permission=row[3] if len(row) > 3 else "read",
updated_at=row[4] if len(row) > 4 else None, 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 @staticmethod
@@ -260,6 +333,15 @@ class PostgresPortfolioRepository:
row = cur.fetchone() row = cur.fetchone()
return self._row_to_user(row) 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]: def list_users(self) -> list[User]:
self.ensure_schema() self.ensure_schema()
with self._connect().cursor() as cur: with self._connect().cursor() as cur:
@@ -267,23 +349,40 @@ class PostgresPortfolioRepository:
rows = cur.fetchall() rows = cur.fetchall()
return [self._row_to_user(row) for row in rows] 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() self.ensure_schema()
with self._connect().cursor() as cur: with self._connect().cursor() as cur:
self._ensure_user_uniqueness(cur, username=username, email=email) self._ensure_user_uniqueness(cur, username=username, email=email)
user_id = str(uuid.uuid4()) user_id = str(uuid.uuid4())
cur.execute( cur.execute(
""" """
INSERT INTO users (id, username, email) INSERT INTO users (id, username, email, permission, is_active, password_hash)
VALUES (%s, %s, %s) VALUES (%s, %s, %s, %s, %s, %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
""", """,
(user_id, username, email), (user_id, username, email, permission, is_active, password_hash),
) )
row = cur.fetchone() row = cur.fetchone()
return self._row_to_user(row) 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() self.ensure_schema()
assignments: list[str] = [] assignments: list[str] = []
params: list[object] = [] params: list[object] = []
@@ -301,6 +400,15 @@ class PostgresPortfolioRepository:
if email is not None: if email is not None:
assignments.append("email = %s") assignments.append("email = %s")
params.append(email) 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: if not assignments:
return self._row_to_user(existing_row) return self._row_to_user(existing_row)
@@ -311,7 +419,7 @@ class PostgresPortfolioRepository:
UPDATE users UPDATE users
SET {', '.join(assignments)} SET {', '.join(assignments)}
WHERE id = %s 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), (*params, user_id),
) )
@@ -324,8 +432,85 @@ class PostgresPortfolioRepository:
cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,)) cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,))
if cur.fetchone() is None: if cur.fetchone() is None:
raise ValueError("사용자를 찾을 수 없습니다") raise ValueError("사용자를 찾을 수 없습니다")
cur.execute("DELETE FROM auth_tokens WHERE user_id = %s", (user_id,))
cur.execute("DELETE FROM users WHERE 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: def get(self, portfolio_id: str) -> Portfolio:
self.ensure_schema() self.ensure_schema()
with self._connect().cursor() as cur: with self._connect().cursor() as cur:
+64
View File
@@ -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
+10
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
@@ -11,13 +12,19 @@ class UserResponse(BaseModel):
id: str id: str
username: str username: str
email: str | None = None email: str | None = None
permission: Literal["read", "trade-write", "admin"] = "read"
is_active: bool = True
created_at: datetime | None = None created_at: datetime | None = None
updated_at: datetime | None = None updated_at: datetime | None = None
last_login_at: datetime | None = None
class CreateUserRequest(BaseModel): class CreateUserRequest(BaseModel):
username: str = Field(min_length=1) username: str = Field(min_length=1)
email: str | None = None email: str | None = None
password: str = Field(min_length=8)
permission: Literal["read", "trade-write", "admin"] = "read"
is_active: bool = True
@field_validator("username") @field_validator("username")
@classmethod @classmethod
@@ -39,6 +46,9 @@ class CreateUserRequest(BaseModel):
class UpdateUserRequest(BaseModel): class UpdateUserRequest(BaseModel):
username: str | None = Field(default=None, min_length=1) username: str | None = Field(default=None, min_length=1)
email: str | None = None 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") @field_validator("username")
@classmethod @classmethod
+102
View File
@@ -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)
+11 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from fastapi import Depends from fastapi import Depends
from app.core.config import Settings, get_settings from app.core.config import Settings, get_settings
from app.core.security import hash_password
from app.domain.models import User from app.domain.models import User
from app.repositories.factory import get_portfolio_repository_from_settings from app.repositories.factory import get_portfolio_repository_from_settings
from app.repositories.portfolio_repository import PortfolioRepository from app.repositories.portfolio_repository import PortfolioRepository
@@ -26,13 +27,22 @@ class UserService:
return self._repository.get_user(user_id) return self._repository.get_user(user_id)
def create(self, payload: CreateUserRequest) -> User: 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: def update(self, user_id: str, payload: UpdateUserRequest) -> User:
return self._repository.update_user( return self._repository.update_user(
user_id, user_id,
username=payload.username, username=payload.username,
email=payload.email, 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: def delete(self, user_id: str) -> None:
+112 -9
View File
@@ -259,11 +259,105 @@ Response 204:
### 5.1 GET /strategy-sets ### 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 <SESSION_OR_DB_TOKEN>" \
-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 <SESSION_OR_DB_TOKEN>"
```
### 6.4 DELETE /auth/tokens/{token_id}
Request:
```bash
curl -X DELETE "http://localhost:8000/auth/tokens/token-1" \
-H "Authorization: Bearer <SESSION_OR_DB_TOKEN>"
```
Response 204:
- No content.
## 7. Users
All user management endpoints require an admin token. All user management endpoints require an admin token.
### 6.1 GET /users ### 7.1 GET /users
Request: Request:
@@ -286,7 +380,7 @@ Response 200:
] ]
``` ```
### 6.2 GET /users/{user_id} ### 7.2 GET /users/{user_id}
Request: Request:
@@ -307,7 +401,7 @@ Response 200:
} }
``` ```
### 6.3 POST /users ### 7.3 POST /users
Request: Request:
@@ -317,7 +411,10 @@ curl -X POST "http://localhost:8000/users" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"username": "alice", "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", "id": "user-1",
"username": "alice", "username": "alice",
"email": "alice@example.com", "email": "alice@example.com",
"permission": "read",
"is_active": true,
"created_at": "2026-04-03T00:00:00Z", "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: Request:
@@ -353,12 +453,15 @@ Response 200:
"id": "user-1", "id": "user-1",
"username": "alice-updated", "username": "alice-updated",
"email": "alice@example.com", "email": "alice@example.com",
"permission": "read",
"is_active": true,
"created_at": "2026-04-03T00:00:00Z", "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: Request:
+55 -7
View File
@@ -5,12 +5,13 @@
This document defines the database schema in an AI-agent-friendly format. This document defines the database schema in an AI-agent-friendly format.
- Source of truth: Alembic revisions in `migrations/versions` - Source of truth: Alembic revisions in `migrations/versions`
- Current baseline revision: `20260403_0004` - Current baseline revision: `20260403_0005`
- Baseline files: - Baseline files:
- `migrations/versions/20260403_0001_initial_portfolio_schema.py` - `migrations/versions/20260403_0001_initial_portfolio_schema.py`
- `migrations/versions/20260403_0002_strategy_set_columns.py` - `migrations/versions/20260403_0002_strategy_set_columns.py`
- `migrations/versions/20260403_0003_portfolio_strategy_refs.py` - `migrations/versions/20260403_0003_portfolio_strategy_refs.py`
- `migrations/versions/20260403_0004_users_table.py` - `migrations/versions/20260403_0004_users_table.py`
- `migrations/versions/20260403_0005_auth_user_fields_and_tokens.py`
## 2. Scope ## 2. Scope
@@ -18,6 +19,7 @@ Schema objects covered by baseline revision:
- `strategy_sets` - `strategy_sets`
- `users` - `users`
- `auth_tokens`
- `portfolios` - `portfolios`
- `portfolio_ledger` - `portfolio_ledger`
- secondary indexes - secondary indexes
@@ -45,8 +47,12 @@ Columns:
| id | Text | NO | - | Primary Key | | id | Text | NO | - | Primary Key |
| username | Text | NO | - | Unique username | | username | Text | NO | - | Unique username |
| email | Text | YES | - | Unique when present | | 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 | | created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp |
| updated_at | DateTime(timezone=True) | NO | NOW() | Last update timestamp | | updated_at | DateTime(timezone=True) | NO | NOW() | Last update timestamp |
| last_login_at | DateTime(timezone=True) | YES | - | Last successful login timestamp |
Constraints: Constraints:
@@ -54,7 +60,34 @@ Constraints:
- Unique: `uq_users_username` on (`username`) - Unique: `uq_users_username` on (`username`)
- Unique: `uq_users_email` on (`email`) - 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: Purpose:
@@ -76,7 +109,7 @@ Constraints:
- Unique: `uq_strategy_sets_name_config` on (`name`, `config`) - Unique: `uq_strategy_sets_name_config` on (`name`, `config`)
- Unique: `uq_strategy_sets_nick` on (`nick`) - Unique: `uq_strategy_sets_nick` on (`nick`)
### 4.3 portfolios ### 4.4 portfolios
Purpose: Purpose:
@@ -84,7 +117,7 @@ Purpose:
Current baseline note: 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: Columns:
@@ -114,7 +147,7 @@ Constraint notes:
- `active_strategy_id` is a nullable logical reference to `strategy_sets.id` - `active_strategy_id` is a nullable logical reference to `strategy_sets.id`
- `avabile_strategies` stores zero or more selectable strategy set IDs when present - `avabile_strategies` stores zero or more selectable strategy set IDs when present
### 4.4 portfolio_ledger ### 4.5 portfolio_ledger
Purpose: Purpose:
@@ -152,14 +185,28 @@ Constraints:
- Unique: no - Unique: no
- Intent: speed operational listing by recency. - 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` - Table: `portfolio_ledger`
- Columns/expressions: (`portfolio_id`, `recorded_at DESC`) - Columns/expressions: (`portfolio_id`, `recorded_at DESC`)
- Unique: no - Unique: no
- Intent: fetch recent ledger entries per portfolio efficiently. - Intent: fetch recent ledger entries per portfolio efficiently.
### 5.3 idx_portfolios_broker_status_updated ### 5.5 idx_portfolios_broker_status_updated
- Table: `portfolios` - Table: `portfolios`
- Columns/expressions: (`broker`, `status`, `updated_at DESC`) - 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_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_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_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 |
+59 -1
View File
@@ -72,10 +72,15 @@ Configuration precedence (high to low):
Core settings: Core settings:
- `PORTFOLIOHUB_ALLOWED_ORIGINS`: comma-separated CORS origins - `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_READ_TOKENS`: comma-separated read tokens
- `PORTFOLIOHUB_TRADE_WRITE_TOKENS`: comma-separated write tokens - `PORTFOLIOHUB_TRADE_WRITE_TOKENS`: comma-separated write tokens
- `PORTFOLIOHUB_ADMIN_TOKENS`: comma-separated admin tokens - `PORTFOLIOHUB_ADMIN_TOKENS`: comma-separated admin tokens
- `PORTFOLIOHUB_DB_ENABLED`: enables DB repository selection when `true` - `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): Database settings (active source):
@@ -121,6 +126,7 @@ Failure responses:
Base routes: Base routes:
- Health: `/health` - Health: `/health`
- Auth: `/auth`
- Portfolio: `/portfolios` - Portfolio: `/portfolios`
- Users: `/users` - 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. - 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. All user management endpoints are admin-only.
@@ -272,6 +309,10 @@ All user management endpoints are admin-only.
- Response `201`: `UserResponse` - Response `201`: `UserResponse`
- Response `409`: duplicate username or email - 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 #### Update User
- `PATCH /users/{user_id}` - `PATCH /users/{user_id}`
@@ -326,11 +367,28 @@ All user management endpoints are admin-only.
- `username: str` - `username: str`
- `email: str | null` - `email: str | null`
- `password: str`
- `permission: "read" | "trade-write" | "admin"`
- `is_active: bool`
`UpdateUserRequest` `UpdateUserRequest`
- `username: str | null` - `username: str | null`
- `email: 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` - `price: float > 0`
- `fee: float >= 0` - `fee: float >= 0`
- `tax: float >= 0` - `tax: float >= 0`
@@ -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")
+80 -1
View File
@@ -1,7 +1,9 @@
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.core.config import Settings
from app.core.config import get_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: def test_healthcheck() -> None:
@@ -25,3 +27,80 @@ def test_settings_default_origin() -> None:
settings = get_settings() settings = get_settings()
assert settings.allowed_origins == ["http://localhost:3000"] 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
+41 -3
View File
@@ -39,7 +39,15 @@ class FakeRepo:
def list_users(self) -> list[User]: def list_users(self) -> list[User]:
return sorted(self._users.values(), key=lambda user: user.username) 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()): if any(user.username == username for user in self._users.values()):
raise ValueError(f"이미 존재하는 username입니다: {username}") raise ValueError(f"이미 존재하는 username입니다: {username}")
if email is not None and any(user.email == email for user in self._users.values()): 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}", id=f"user-{self._user_seq}",
username=username, username=username,
email=email, email=email,
permission=permission,
is_active=is_active,
password_hash=password_hash,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
) )
@@ -56,7 +67,16 @@ class FakeRepo:
self._users[user.id] = user self._users[user.id] = user
return 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) 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()): 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}") raise ValueError(f"이미 존재하는 username입니다: {username}")
@@ -66,6 +86,12 @@ class FakeRepo:
user.username = username user.username = username
if email is not None: if email is not None:
user.email = email 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) user.updated_at = datetime.now(timezone.utc)
return user return user
@@ -459,7 +485,19 @@ def test_cli_user_create_supports_json() -> None:
result = runner.invoke( result = runner.invoke(
runner.app, 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 assert result.exit_code == 0
+140 -10
View File
@@ -4,15 +4,23 @@ from datetime import datetime, timezone
from fastapi.testclient import TestClient 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.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 from app.services.user_service import UserService, get_user_service
class FakeUserRepo: class FakeUserRepo:
def __init__(self) -> None: def __init__(self) -> None:
self.users: dict[str, User] = {} 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_id = 1
self.next_token_id = 1
def get_user(self, user_id: str) -> User: def get_user(self, user_id: str) -> User:
user = self.users.get(user_id) user = self.users.get(user_id)
@@ -20,10 +28,21 @@ class FakeUserRepo:
raise ValueError("사용자를 찾을 수 없습니다") raise ValueError("사용자를 찾을 수 없습니다")
return user 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]: def list_users(self) -> list[User]:
return sorted(self.users.values(), key=lambda user: user.username) 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()): if any(user.username == username for user in self.users.values()):
raise ValueError(f"이미 존재하는 username입니다: {username}") raise ValueError(f"이미 존재하는 username입니다: {username}")
if email is not None and any(user.email == email for user in self.users.values()): 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}", id=f"user-{self.next_id}",
username=username, username=username,
email=email, email=email,
permission=permission,
is_active=is_active,
password_hash=password_hash,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
) )
self.next_id += 1 self.next_id += 1
self.users[user.id] = user self.users[user.id] = user
self.users_by_username[user.username] = user
return 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) 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()): 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}") 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()): 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}") raise ValueError(f"이미 존재하는 email입니다: {email}")
self.users_by_username.pop(user.username, None)
if username is not None: if username is not None:
user.username = username user.username = username
if email is not None: if email is not None:
user.email = email 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) user.updated_at = datetime.now(timezone.utc)
self.users_by_username[user.username] = user
return user return user
def delete_user(self, user_id: str) -> None: def delete_user(self, user_id: str) -> None:
if user_id not in self.users: if user_id not in self.users:
raise ValueError("사용자를 찾을 수 없습니다") 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: 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_ADMIN_TOKENS", "admin-token")
monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token") monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token")
from app.core.config import get_settings from app.core.config import get_settings
@@ -68,6 +166,9 @@ def _client_with_repo(monkeypatch, repo: FakeUserRepo) -> TestClient:
get_settings.cache_clear() get_settings.cache_clear()
app.dependency_overrides.clear() app.dependency_overrides.clear()
app.dependency_overrides[get_user_service] = lambda: UserService(repo) 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) return TestClient(app)
@@ -83,7 +184,7 @@ def test_list_users_requires_admin(monkeypatch) -> None:
def test_list_users_returns_payload(monkeypatch) -> None: def test_list_users_returns_payload(monkeypatch) -> None:
repo = FakeUserRepo() 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) client = _client_with_repo(monkeypatch, repo)
response = client.get("/users", headers={"Authorization": "Bearer admin-token"}) 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: def test_create_user_rejects_duplicate_username(monkeypatch) -> None:
repo = FakeUserRepo() repo = FakeUserRepo()
repo.create_user("alice") repo.create_user("alice", password_hash=hash_password("password123"))
client = _client_with_repo(monkeypatch, repo) client = _client_with_repo(monkeypatch, repo)
response = client.post( response = client.post(
"/users", "/users",
headers={"Authorization": "Bearer admin-token"}, 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() 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: def test_get_update_and_delete_user_flow(monkeypatch) -> None:
repo = FakeUserRepo() 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) client = _client_with_repo(monkeypatch, repo)
get_response = client.get(f"/users/{user.id}", headers={"Authorization": "Bearer admin-token"}) 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 get_response.json()["username"] == "alice"
assert update_response.status_code == 200 assert update_response.status_code == 200
assert update_response.json()["username"] == "alice-updated" assert update_response.json()["username"] == "alice-updated"
assert delete_response.status_code == 204 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"]