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
+112 -9
View File
@@ -259,11 +259,105 @@ Response 204:
### 5.1 GET /strategy-sets
## 6. Users
## 6. Auth
### 6.1 POST /auth/login
Request:
```bash
curl -X POST "http://localhost:8000/auth/login" \
-H "Content-Type: application/json" \
-d '{
"username": "admin",
"password": "bootstrap123"
}'
```
Response 200:
```json
{
"access_token": "plain-token-value",
"token_type": "bearer",
"expires_at": "2026-04-03T12:00:00Z",
"permission": "admin",
"user": {
"id": "user-1",
"username": "admin",
"email": "admin@example.com",
"permission": "admin",
"is_active": true,
"created_at": "2026-04-03T00:00:00Z",
"updated_at": "2026-04-03T00:00:00Z",
"last_login_at": "2026-04-03T00:00:00Z"
}
}
```
### 6.2 POST /auth/tokens
Request:
```bash
curl -X POST "http://localhost:8000/auth/tokens" \
-H "Authorization: Bearer <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.
### 6.1 GET /users
### 7.1 GET /users
Request:
@@ -286,7 +380,7 @@ Response 200:
]
```
### 6.2 GET /users/{user_id}
### 7.2 GET /users/{user_id}
Request:
@@ -307,7 +401,7 @@ Response 200:
}
```
### 6.3 POST /users
### 7.3 POST /users
Request:
@@ -317,7 +411,10 @@ curl -X POST "http://localhost:8000/users" \
-H "Content-Type: application/json" \
-d '{
"username": "alice",
"email": "alice@example.com"
"email": "alice@example.com",
"password": "password123",
"permission": "read",
"is_active": true
}'
```
@@ -328,12 +425,15 @@ Response 201:
"id": "user-1",
"username": "alice",
"email": "alice@example.com",
"permission": "read",
"is_active": true,
"created_at": "2026-04-03T00:00:00Z",
"updated_at": "2026-04-03T00:00:00Z"
"updated_at": "2026-04-03T00:00:00Z",
"last_login_at": null
}
```
### 6.4 PATCH /users/{user_id}
### 7.4 PATCH /users/{user_id}
Request:
@@ -353,12 +453,15 @@ Response 200:
"id": "user-1",
"username": "alice-updated",
"email": "alice@example.com",
"permission": "read",
"is_active": true,
"created_at": "2026-04-03T00:00:00Z",
"updated_at": "2026-04-03T00:10:00Z"
"updated_at": "2026-04-03T00:10:00Z",
"last_login_at": null
}
```
### 6.5 DELETE /users/{user_id}
### 7.5 DELETE /users/{user_id}
Request:
+55 -7
View File
@@ -5,12 +5,13 @@
This document defines the database schema in an AI-agent-friendly format.
- Source of truth: Alembic revisions in `migrations/versions`
- Current baseline revision: `20260403_0004`
- Current baseline revision: `20260403_0005`
- Baseline files:
- `migrations/versions/20260403_0001_initial_portfolio_schema.py`
- `migrations/versions/20260403_0002_strategy_set_columns.py`
- `migrations/versions/20260403_0003_portfolio_strategy_refs.py`
- `migrations/versions/20260403_0004_users_table.py`
- `migrations/versions/20260403_0005_auth_user_fields_and_tokens.py`
## 2. Scope
@@ -18,6 +19,7 @@ Schema objects covered by baseline revision:
- `strategy_sets`
- `users`
- `auth_tokens`
- `portfolios`
- `portfolio_ledger`
- secondary indexes
@@ -45,8 +47,12 @@ Columns:
| id | Text | NO | - | Primary Key |
| username | Text | NO | - | Unique username |
| email | Text | YES | - | Unique when present |
| permission | Text | NO | "read" | User permission level |
| is_active | Boolean | NO | TRUE | Whether login is allowed |
| password_hash | Text | YES | - | Scrypt password hash |
| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp |
| updated_at | DateTime(timezone=True) | NO | NOW() | Last update timestamp |
| last_login_at | DateTime(timezone=True) | YES | - | Last successful login timestamp |
Constraints:
@@ -54,7 +60,34 @@ Constraints:
- Unique: `uq_users_username` on (`username`)
- Unique: `uq_users_email` on (`email`)
### 4.2 strategy_sets
### 4.2 auth_tokens
Purpose:
- Stores DB-issued session tokens and client tokens.
Columns:
| Column | Type | Null | Default | Constraints / Notes |
|---|---|---|---|---|
| id | Text | NO | - | Primary Key |
| user_id | Text | NO | - | Logical user reference |
| name | Text | NO | - | Human-readable token name |
| token_prefix | Text | NO | - | Display prefix for issued token |
| token_hash | Text | NO | - | Unique SHA-256 token hash |
| permission | Text | NO | - | Effective token permission |
| token_type | Text | NO | - | `session` or `client` |
| expires_at | DateTime(timezone=True) | YES | - | Expiration time |
| last_used_at | DateTime(timezone=True) | YES | - | Last successful use |
| revoked_at | DateTime(timezone=True) | YES | - | Revocation time |
| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp |
Constraints:
- Primary key: `id`
- Unique: `uq_auth_tokens_token_hash` on (`token_hash`)
### 4.3 strategy_sets
Purpose:
@@ -76,7 +109,7 @@ Constraints:
- Unique: `uq_strategy_sets_name_config` on (`name`, `config`)
- Unique: `uq_strategy_sets_nick` on (`nick`)
### 4.3 portfolios
### 4.4 portfolios
Purpose:
@@ -84,7 +117,7 @@ Purpose:
Current baseline note:
- The columns below describe the currently migrated schema through revision `20260403_0004`.
- The columns below describe the currently migrated schema through revision `20260403_0005`.
Columns:
@@ -114,7 +147,7 @@ Constraint notes:
- `active_strategy_id` is a nullable logical reference to `strategy_sets.id`
- `avabile_strategies` stores zero or more selectable strategy set IDs when present
### 4.4 portfolio_ledger
### 4.5 portfolio_ledger
Purpose:
@@ -152,14 +185,28 @@ Constraints:
- Unique: no
- Intent: speed operational listing by recency.
### 5.2 idx_portfolio_ledger_portfolio_time
### 5.2 idx_auth_tokens_user_created
- Table: `auth_tokens`
- Columns/expressions: (`user_id`, `created_at`)
- Unique: no
- Intent: fetch current user's issued tokens efficiently.
### 5.3 idx_auth_tokens_hash_active
- Table: `auth_tokens`
- Columns/expressions: (`token_hash`)
- Unique: no
- Intent: resolve bearer tokens efficiently.
### 5.4 idx_portfolio_ledger_portfolio_time
- Table: `portfolio_ledger`
- Columns/expressions: (`portfolio_id`, `recorded_at DESC`)
- Unique: no
- Intent: fetch recent ledger entries per portfolio efficiently.
### 5.3 idx_portfolios_broker_status_updated
### 5.5 idx_portfolios_broker_status_updated
- Table: `portfolios`
- Columns/expressions: (`broker`, `status`, `updated_at DESC`)
@@ -202,3 +249,4 @@ When schema changes:
| 20260403_0002 | migrations/versions/20260403_0002_strategy_set_columns.py | Rename `strategy_name`->`name`, `strategy_config`->`config`, add unique `nick`, handle legacy unnamed unique constraint |
| 20260403_0003 | migrations/versions/20260403_0003_portfolio_strategy_refs.py | Remove embedded portfolio strategy fields, add `active_strategy_id` and `avabile_strategies` |
| 20260403_0004 | migrations/versions/20260403_0004_users_table.py | Add `users` table with unique username/email and created_at index |
| 20260403_0005 | migrations/versions/20260403_0005_auth_user_fields_and_tokens.py | Add password/permission/login fields to `users` and create `auth_tokens` table |
+59 -1
View File
@@ -72,10 +72,15 @@ Configuration precedence (high to low):
Core settings:
- `PORTFOLIOHUB_ALLOWED_ORIGINS`: comma-separated CORS origins
- `PORTFOLIOHUB_BOOTSTRAP_USERNAME`: initial username created on startup when users table is empty
- `PORTFOLIOHUB_BOOTSTRAP_EMAIL`: optional email for the bootstrap user
- `PORTFOLIOHUB_BOOTSTRAP_PASSWORD`: initial password for the bootstrap admin user
- `PORTFOLIOHUB_READ_TOKENS`: comma-separated read tokens
- `PORTFOLIOHUB_TRADE_WRITE_TOKENS`: comma-separated write tokens
- `PORTFOLIOHUB_ADMIN_TOKENS`: comma-separated admin tokens
- `PORTFOLIOHUB_DB_ENABLED`: enables DB repository selection when `true`
- `PORTFOLIOHUB_SESSION_TOKEN_TTL_HOURS`: login session token TTL in hours
- `PORTFOLIOHUB_CLIENT_TOKEN_DEFAULT_TTL_DAYS`: default client token TTL in days
Database settings (active source):
@@ -121,6 +126,7 @@ Failure responses:
Base routes:
- Health: `/health`
- Auth: `/auth`
- Portfolio: `/portfolios`
- Users: `/users`
@@ -247,7 +253,38 @@ Note: Since strategy sets are identified by (name, config) uniqueness, updating
- See `docs/api-examples.md` for maintained request/response examples.
### 7.5 User Endpoints
### 7.5 Auth Endpoints
#### Login
- `POST /auth/login`
- Auth: none
- Body: `LoginRequest`
- Response `200`: `TokenResponse`
- Response `401`: invalid username/password
#### List Current Tokens
- `GET /auth/tokens`
- Auth: `read` or higher with DB-issued token/session
- Response `200`: array of `AuthTokenResponse`
#### Create Client Token
- `POST /auth/tokens`
- Auth: `read` or higher with DB-issued token/session
- Body: `CreateClientTokenRequest`
- Response `201`: `IssuedClientTokenResponse`
- Response `403`: requested token permission exceeds current permission
#### Revoke Client Token
- `DELETE /auth/tokens/{token_id}`
- Auth: `read` or higher with DB-issued token/session
- Response `204`: no content
- Response `404`: token not found
### 7.6 User Endpoints
All user management endpoints are admin-only.
@@ -272,6 +309,10 @@ All user management endpoints are admin-only.
- Response `201`: `UserResponse`
- Response `409`: duplicate username or email
Bootstrap rule:
- If DB-backed persistence is enabled and both `PORTFOLIOHUB_BOOTSTRAP_USERNAME` and `PORTFOLIOHUB_BOOTSTRAP_PASSWORD` are configured, application startup creates the bootstrap admin user only when the `users` table is empty.
#### Update User
- `PATCH /users/{user_id}`
@@ -326,11 +367,28 @@ All user management endpoints are admin-only.
- `username: str`
- `email: str | null`
- `password: str`
- `permission: "read" | "trade-write" | "admin"`
- `is_active: bool`
`UpdateUserRequest`
- `username: str | null`
- `email: str | null`
- `password: str | null`
- `permission: "read" | "trade-write" | "admin" | null`
- `is_active: bool | null`
`LoginRequest`
- `username: str`
- `password: str`
`CreateClientTokenRequest`
- `name: str`
- `permission: "read" | "trade-write" | "admin" | null`
- `expires_in_days: int | null`
- `price: float > 0`
- `fee: float >= 0`
- `tax: float >= 0`