Fix F-02: replace password-in-hidden-field with nonce
- ui/ui.go: add pendingLogin struct and pendingLogins sync.Map to UIServer; add issueTOTPNonce (generates 128-bit random nonce, stores accountID with 90s TTL) and consumeTOTPNonce (single-use, expiry-checked LoadAndDelete); add dummyHash() method - ui/handlers_auth.go: split handleLoginPost into step 1 (password verify → issue nonce) and step 2 (handleTOTPStep, consume nonce → validate TOTP) via a new finishLogin helper; password never transmitted or stored after step 1 - ui/ui_test.go: refactor newTestMux to reuse new newTestUIServer; add TestTOTPNonceIssuedAndConsumed, TestTOTPNonceUnknownRejected, TestTOTPNonceExpired, and TestLoginPostPasswordNotInTOTPForm; 11/11 tests pass - web/templates/fragments/totp_step.html: replace 'name=password' hidden field with 'name=totp_nonce' - db/accounts.go: add GetAccountByID for TOTP step lookup - AUDIT.md: mark F-02 as fixed Security: the plaintext password previously survived two HTTP round-trips and lived in the browser DOM during the TOTP step. The nonce approach means the password is verified once and immediately discarded; only an opaque random token tied to an account ID (never a credential) crosses the wire on step 2. Nonces are single-use and expire after 90 seconds to limit the window if one is captured.
This commit is contained in:
2
AUDIT.md
2
AUDIT.md
@@ -221,7 +221,7 @@ The REST `handleTokenIssue` and gRPC `IssueServiceToken` both revoke the existin
|
||||
| Fixed? | ID | Severity | Title | Effort |
|
||||
|--------|----|----------|-------|--------|
|
||||
| Yes | F-01 | MEDIUM | TOTP enrollment sets required=1 before confirmation | Small |
|
||||
| No | F-02 | MEDIUM | Password in HTML hidden fields during TOTP step | Medium |
|
||||
| Yes | F-02 | MEDIUM | Password in HTML hidden fields during TOTP step | Medium |
|
||||
| Yes | F-03 | MEDIUM | Token renewal not atomic (race window) | Small |
|
||||
| Yes | F-04 | MEDIUM | Rate limiter not applied to REST login endpoint | Small |
|
||||
| Yes | F-11 | MEDIUM | Missing security headers on UI responses | Small |
|
||||
|
||||
571
INTEGRATION.md
Normal file
571
INTEGRATION.md
Normal file
@@ -0,0 +1,571 @@
|
||||
# MCIAS Integration Guide
|
||||
|
||||
MCIAS is an HTTP+TLS authentication service. It issues Ed25519-signed JWTs and
|
||||
provides account, role, and credential management. This guide covers everything
|
||||
needed to integrate a new system with MCIAS as its authentication backend.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Concepts](#concepts)
|
||||
2. [Quick Start — Human Login](#quick-start--human-login)
|
||||
3. [Quick Start — System Account](#quick-start--system-account)
|
||||
4. [Verifying a Token in Your Service](#verifying-a-token-in-your-service)
|
||||
5. [TOTP (Two-Factor Authentication)](#totp-two-factor-authentication)
|
||||
6. [Setting Up a System Account](#setting-up-a-system-account)
|
||||
7. [Role-Based Authorization](#role-based-authorization)
|
||||
8. [Client Libraries](#client-libraries)
|
||||
9. [Token Lifecycle](#token-lifecycle)
|
||||
10. [Error Reference](#error-reference)
|
||||
11. [API Reference](#api-reference)
|
||||
12. [Security Notes for Integrators](#security-notes-for-integrators)
|
||||
|
||||
---
|
||||
|
||||
## Concepts
|
||||
|
||||
**Human account** — A person who logs in with username + password (+ optional
|
||||
TOTP). The server returns a JWT valid for 30 days (8 hours for admins). The
|
||||
token is bearer-only; there are no cookies or server-side sessions.
|
||||
|
||||
**System account** — A non-interactive identity for a service, daemon, or
|
||||
automated process. It has no password. An admin issues a long-lived bearer token
|
||||
(1 year) that the service stores and sends with every request.
|
||||
|
||||
**JWT** — All tokens are signed with Ed25519 (algorithm `EdDSA`). They contain
|
||||
`sub` (account UUID), `roles`, `iss`, `iat`, `exp`, and `jti`. Tokens can be
|
||||
validated offline using the public key, or online via `/v1/token/validate`.
|
||||
|
||||
**Role** — An arbitrary string scoped to your deployment (e.g. `admin`,
|
||||
`editor`, `readonly`). MCIAS enforces `admin` internally; your services enforce
|
||||
any other roles by inspecting the `roles` claim.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start — Human Login
|
||||
|
||||
```sh
|
||||
# Login
|
||||
RESPONSE=$(curl -sS -X POST https://auth.example.com:8443/v1/auth/login \
|
||||
--cacert /etc/mcias/server.crt \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"alice","password":"s3cr3t"}')
|
||||
|
||||
TOKEN=$(echo "$RESPONSE" | jq -r .token)
|
||||
EXPIRES=$(echo "$RESPONSE" | jq -r .expires_at)
|
||||
|
||||
# Use the token
|
||||
curl -sS https://my-app.example.com/api/resource \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Renew before expiry (extends the window, invalidates the old token)
|
||||
NEW_TOKEN=$(curl -sS -X POST https://auth.example.com:8443/v1/auth/renew \
|
||||
--cacert /etc/mcias/server.crt \
|
||||
-H "Authorization: Bearer $TOKEN" | jq -r .token)
|
||||
|
||||
# Logout (revokes the token immediately)
|
||||
curl -sS -X POST https://auth.example.com:8443/v1/auth/logout \
|
||||
--cacert /etc/mcias/server.crt \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
### Login with TOTP
|
||||
|
||||
If the user has enrolled TOTP, include the current 6-digit code. Omitting it
|
||||
returns HTTP 401 with code `totp_required`.
|
||||
|
||||
```sh
|
||||
curl -sS -X POST https://auth.example.com:8443/v1/auth/login \
|
||||
--cacert /etc/mcias/server.crt \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"alice","password":"s3cr3t","totp_code":"123456"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Start — System Account
|
||||
|
||||
A system account authenticates non-interactively. An admin creates it, issues a
|
||||
token, and the service includes that token in every request.
|
||||
|
||||
```sh
|
||||
# 1. Admin: create the system account
|
||||
TOKEN=$(curl -sS -X POST https://auth.example.com:8443/v1/auth/login \
|
||||
--cacert /etc/mcias/server.crt \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"admin","password":"..."}' | jq -r .token)
|
||||
|
||||
ACCOUNT=$(curl -sS -X POST https://auth.example.com:8443/v1/accounts \
|
||||
--cacert /etc/mcias/server.crt \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"my-service","account_type":"system"}')
|
||||
|
||||
SVC_UUID=$(echo "$ACCOUNT" | jq -r .id)
|
||||
|
||||
# 2. Admin: issue a bearer token for the service (valid 1 year)
|
||||
SVC_TOKEN=$(curl -sS -X POST https://auth.example.com:8443/v1/token/issue \
|
||||
--cacert /etc/mcias/server.crt \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"account_id\":\"$SVC_UUID\"}" | jq -r .token)
|
||||
|
||||
# 3. Store SVC_TOKEN in the service's secret store (env var, Vault, etc.)
|
||||
# 4. Service: use it in every outbound or MCIAS API call
|
||||
curl -sS https://protected-api.example.com/v1/resource \
|
||||
-H "Authorization: Bearer $SVC_TOKEN"
|
||||
```
|
||||
|
||||
To rotate the token (e.g. after exposure), reissue it. The old token is
|
||||
automatically revoked.
|
||||
|
||||
```sh
|
||||
curl -sS -X POST https://auth.example.com:8443/v1/token/issue \
|
||||
--cacert /etc/mcias/server.crt \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"account_id\":\"$SVC_UUID\"}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verifying a Token in Your Service
|
||||
|
||||
Your service receives a Bearer token from a client. It must verify the token
|
||||
before trusting any claims. There are two approaches:
|
||||
|
||||
### Option A: Offline verification (recommended)
|
||||
|
||||
Fetch the public key once at startup and cache it. Verify the JWT locally
|
||||
without a round-trip to MCIAS.
|
||||
|
||||
```
|
||||
GET /v1/keys/public
|
||||
```
|
||||
|
||||
Response (JWK, RFC 8037):
|
||||
|
||||
```json
|
||||
{
|
||||
"kty": "OKP",
|
||||
"crv": "Ed25519",
|
||||
"use": "sig",
|
||||
"alg": "EdDSA",
|
||||
"x": "<base64url-encoded public key>"
|
||||
}
|
||||
```
|
||||
|
||||
Verification steps your code must perform (in order):
|
||||
|
||||
1. **Check `alg` header** — must be exactly `"EdDSA"`. Reject `"none"` and all
|
||||
other values before touching the signature.
|
||||
2. **Verify the Ed25519 signature** using the public key.
|
||||
3. **Validate claims:** `exp` > now, `iat` <= now, `iss` matches your configured
|
||||
issuer.
|
||||
4. **Check for revocation** — if your service needs real-time revocation, call
|
||||
`/v1/token/validate` instead (Option B). Offline verification will accept a
|
||||
token until it expires, even if it was explicitly revoked.
|
||||
|
||||
```python
|
||||
# Python example using PyJWT
|
||||
import jwt # pip install PyJWT[crypto]
|
||||
|
||||
PUBLIC_KEY = load_ed25519_jwk(mcias_client.get_public_key())
|
||||
|
||||
def verify_token(bearer: str) -> dict:
|
||||
token = bearer.removeprefix("Bearer ").strip()
|
||||
# Step 1 is performed by PyJWT when algorithms=["EdDSA"] is specified.
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
PUBLIC_KEY,
|
||||
algorithms=["EdDSA"],
|
||||
issuer="https://auth.example.com",
|
||||
options={"require": ["exp", "iat", "iss", "sub", "jti"]},
|
||||
)
|
||||
return payload # {"sub": "...", "roles": [...], "exp": ..., "jti": "..."}
|
||||
```
|
||||
|
||||
```go
|
||||
// Go example using golang-jwt/jwt/v5
|
||||
import jwtlib "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
func verifyToken(tokenStr string, pubKey ed25519.PublicKey, issuer string) (*Claims, error) {
|
||||
parsed, err := jwtlib.ParseWithClaims(tokenStr, &Claims{},
|
||||
func(t *jwtlib.Token) (any, error) {
|
||||
// Step 1: algorithm must be EdDSA
|
||||
if _, ok := t.Method.(*jwtlib.SigningMethodEd25519); !ok {
|
||||
return nil, fmt.Errorf("unexpected alg: %v", t.Header["alg"])
|
||||
}
|
||||
return pubKey, nil
|
||||
},
|
||||
jwtlib.WithIssuedAt(),
|
||||
jwtlib.WithIssuer(issuer),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsed.Claims.(*Claims), nil
|
||||
}
|
||||
```
|
||||
|
||||
### Option B: Online validation
|
||||
|
||||
Call MCIAS for every request. This reflects revocations immediately but adds
|
||||
latency. Suitable for high-security paths.
|
||||
|
||||
```
|
||||
POST /v1/token/validate
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
or equivalently with a JSON body:
|
||||
|
||||
```json
|
||||
{ "token": "<JWT>" }
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"sub": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"roles": ["editor"],
|
||||
"expires_at": "2026-04-10T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
On failure: `{ "valid": false }` (HTTP 200, never 401). Do not branch on the
|
||||
HTTP status code; always inspect `valid`.
|
||||
|
||||
---
|
||||
|
||||
## TOTP (Two-Factor Authentication)
|
||||
|
||||
TOTP enrollment is a two-step process and must be completed in one session.
|
||||
Abandoning after step 1 does **not** lock the account.
|
||||
|
||||
### Step 1: Enroll
|
||||
|
||||
```
|
||||
POST /v1/auth/totp/enroll
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"secret": "JBSWY3DPEHPK3PXP",
|
||||
"otpauth_uri": "otpauth://totp/MCIAS:alice?secret=JBSWY3DPEHPK3PXP&issuer=MCIAS"
|
||||
}
|
||||
```
|
||||
|
||||
Show the `otpauth_uri` as a QR code (e.g. using a TOTP app like Authenticator).
|
||||
The secret is shown **once**; it is not retrievable after this call.
|
||||
|
||||
### Step 2: Confirm
|
||||
|
||||
```
|
||||
POST /v1/auth/totp/confirm
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{ "code": "123456" }
|
||||
```
|
||||
|
||||
HTTP 204 on success. After this call, all future logins for this account require
|
||||
a TOTP code.
|
||||
|
||||
### Admin: Remove TOTP
|
||||
|
||||
```
|
||||
DELETE /v1/auth/totp
|
||||
Authorization: Bearer <admin-token>
|
||||
Content-Type: application/json
|
||||
|
||||
{ "account_id": "<UUID>" }
|
||||
```
|
||||
|
||||
Use this for account recovery when a user loses their TOTP device.
|
||||
|
||||
---
|
||||
|
||||
## Setting Up a System Account
|
||||
|
||||
Full walkthrough using `mciasctl`:
|
||||
|
||||
```sh
|
||||
export MCIAS_TOKEN=$(curl -sS -X POST https://auth.example.com:8443/v1/auth/login \
|
||||
--cacert /etc/mcias/server.crt \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"admin","password":"..."}' | jq -r .token)
|
||||
|
||||
# 1. Create the account
|
||||
mciasctl -server https://auth.example.com:8443 \
|
||||
account create -username my-service -type system
|
||||
|
||||
# 2. Note the UUID printed, then assign roles
|
||||
mciasctl role set -id <UUID> -roles readonly
|
||||
|
||||
# 3. Issue the service token
|
||||
mciasctl token issue -id <UUID>
|
||||
# Prints: token=eyJ... expires_at=2027-03-11T...
|
||||
|
||||
# 4. Store the token securely (e.g. in a Kubernetes secret, Vault, .env)
|
||||
echo "MCIAS_SVC_TOKEN=eyJ..." >> /etc/my-service/env
|
||||
|
||||
# 5. Rotate (when token is compromised or at scheduled intervals)
|
||||
mciasctl token issue -id <UUID>
|
||||
# New token issued; old token automatically revoked
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Role-Based Authorization
|
||||
|
||||
Roles are arbitrary strings. MCIAS enforces only `admin` internally. Your
|
||||
services enforce any other roles by inspecting the `roles` JWT claim.
|
||||
|
||||
### Assigning roles
|
||||
|
||||
```sh
|
||||
# Grant roles (replaces existing)
|
||||
mciasctl role set -id <UUID> -roles editor,readonly
|
||||
|
||||
# Or via the API
|
||||
curl -sS -X PUT https://auth.example.com:8443/v1/accounts/<UUID>/roles \
|
||||
--cacert /etc/mcias/server.crt \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"roles":["editor","readonly"]}'
|
||||
```
|
||||
|
||||
### Checking roles in your service
|
||||
|
||||
Roles are in the `roles` claim of the decoded JWT:
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "550e8400-...",
|
||||
"roles": ["editor", "readonly"],
|
||||
"exp": 1775000000,
|
||||
"iss": "https://auth.example.com"
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
def require_role(payload: dict, role: str) -> None:
|
||||
if role not in payload.get("roles", []):
|
||||
raise PermissionError(f"role '{role}' required")
|
||||
```
|
||||
|
||||
```go
|
||||
func hasRole(claims *Claims, role string) bool {
|
||||
for _, r := range claims.Roles {
|
||||
if r == role {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Client Libraries
|
||||
|
||||
Four language implementations are available in the `clients/` directory. All
|
||||
expose the same API surface:
|
||||
|
||||
| Language | Location | Install |
|
||||
|----------|----------|---------|
|
||||
| Go | `clients/go/` | `go get git.wntrmute.dev/kyle/mcias/clients/go` |
|
||||
| Python | `clients/python/` | `pip install ./clients/python` |
|
||||
| Rust | `clients/rust/` | `cargo add mcias-client` |
|
||||
| Common Lisp | `clients/lisp/` | ASDF `mcias-client` |
|
||||
|
||||
### Go
|
||||
|
||||
```go
|
||||
import mcias "git.wntrmute.dev/kyle/mcias/clients/go"
|
||||
|
||||
c, err := mcias.New("https://auth.example.com:8443", "/etc/mcias/server.crt", "")
|
||||
if err != nil { ... }
|
||||
|
||||
token, expiresAt, err := c.Login("alice", "s3cr3t", "")
|
||||
if err != nil { ... }
|
||||
|
||||
result, err := c.ValidateToken(token)
|
||||
if err != nil || !result.Valid { ... }
|
||||
fmt.Println(result.Sub, result.Roles)
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
from mcias_client import Client, MciasAuthError
|
||||
|
||||
c = Client("https://auth.example.com:8443", ca_cert="/etc/mcias/server.crt")
|
||||
token, expires_at = c.login("alice", "s3cr3t")
|
||||
|
||||
try:
|
||||
result = c.validate_token(token)
|
||||
if not result.valid:
|
||||
raise MciasAuthError("token invalid")
|
||||
print(result.sub, result.roles)
|
||||
except MciasAuthError as e:
|
||||
print("auth error:", e)
|
||||
```
|
||||
|
||||
### Error types (all languages)
|
||||
|
||||
| Name | HTTP | Meaning |
|
||||
|------|------|---------|
|
||||
| `MciasAuthError` | 401 | Token missing, invalid, or expired |
|
||||
| `MciasForbiddenError` | 403 | Insufficient role |
|
||||
| `MciasNotFoundError` | 404 | Resource does not exist |
|
||||
| `MciasInputError` | 400 | Malformed request |
|
||||
| `MciasConflictError` | 409 | Conflict (e.g. duplicate username) |
|
||||
| `MciasServerError` | 5xx | Unexpected server error |
|
||||
| `MciasTransportError` | — | Network or TLS failure |
|
||||
|
||||
---
|
||||
|
||||
## Token Lifecycle
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ MCIAS │
|
||||
POST /v1/auth/login │ │
|
||||
User ─────────────────────► Argon2id verify │
|
||||
◄─────────────────── │ Ed25519 sign JWT │
|
||||
JWT (30d / 8h) │ Track JTI in DB │
|
||||
│ │
|
||||
Service ──► POST /v1/token/validate ──► Check sig + JTI │
|
||||
◄── {valid, sub, roles} │
|
||||
│ │
|
||||
User ──► POST /v1/auth/renew ─────────► Revoke old JTI │
|
||||
◄── New JWT Issue new JWT │
|
||||
│ │
|
||||
User ──► POST /v1/auth/logout ────────► Revoke JTI │
|
||||
│ │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Expiry defaults:**
|
||||
|
||||
| Account type | Default expiry | Notes |
|
||||
|---|---|---|
|
||||
| Human | 30 days | Configurable via `tokens.default_expiry` |
|
||||
| Admin (`admin` role) | 8 hours | Configurable via `tokens.admin_expiry` |
|
||||
| System account | 1 year | Configurable via `tokens.service_expiry` |
|
||||
|
||||
Tokens are revoked on explicit logout, account deletion, or admin revocation.
|
||||
Expired-but-not-revoked tokens can be pruned from the database with
|
||||
`mciasdb prune tokens`.
|
||||
|
||||
---
|
||||
|
||||
## Error Reference
|
||||
|
||||
All error responses are JSON with `error` (human-readable) and `code` (machine-readable):
|
||||
|
||||
```json
|
||||
{ "error": "invalid credentials", "code": "unauthorized" }
|
||||
```
|
||||
|
||||
| HTTP | `code` | When |
|
||||
|------|--------|------|
|
||||
| 400 | `bad_request` | Missing required field or malformed JSON |
|
||||
| 401 | `unauthorized` | Wrong credentials, expired or invalid token |
|
||||
| 401 | `totp_required` | Correct password but TOTP code not provided |
|
||||
| 403 | `forbidden` | Token valid but lacks required role |
|
||||
| 404 | `not_found` | Account or token does not exist |
|
||||
| 409 | `conflict` | Username already taken |
|
||||
| 429 | `rate_limited` | Too many login or validate requests |
|
||||
| 500 | `internal_error` | Unexpected server error |
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
See [openapi.yaml](openapi.yaml) for the machine-readable OpenAPI 3.1 spec.
|
||||
A Swagger UI is served at `https://<server>/docs` when the server is running.
|
||||
|
||||
### Authentication header
|
||||
|
||||
All authenticated endpoints require:
|
||||
|
||||
```
|
||||
Authorization: Bearer <JWT>
|
||||
```
|
||||
|
||||
### Endpoints summary
|
||||
|
||||
#### Public (no auth)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/v1/health` | Health check |
|
||||
| GET | `/v1/keys/public` | Ed25519 public key (JWK) |
|
||||
| POST | `/v1/auth/login` | Login; returns JWT |
|
||||
| POST | `/v1/token/validate` | Validate a JWT |
|
||||
|
||||
#### Authenticated
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/v1/auth/logout` | Revoke current token |
|
||||
| POST | `/v1/auth/renew` | Exchange token for a fresh one |
|
||||
| POST | `/v1/auth/totp/enroll` | Begin TOTP enrollment |
|
||||
| POST | `/v1/auth/totp/confirm` | Confirm TOTP enrollment |
|
||||
|
||||
#### Admin only (`admin` role required)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/v1/accounts` | List all accounts |
|
||||
| POST | `/v1/accounts` | Create account |
|
||||
| GET | `/v1/accounts/{id}` | Get account |
|
||||
| PATCH | `/v1/accounts/{id}` | Update account status |
|
||||
| DELETE | `/v1/accounts/{id}` | Soft-delete account |
|
||||
| GET | `/v1/accounts/{id}/roles` | Get roles |
|
||||
| PUT | `/v1/accounts/{id}/roles` | Replace roles |
|
||||
| GET | `/v1/accounts/{id}/pgcreds` | Get Postgres credentials |
|
||||
| PUT | `/v1/accounts/{id}/pgcreds` | Set Postgres credentials |
|
||||
| POST | `/v1/token/issue` | Issue service account token |
|
||||
| DELETE | `/v1/token/{jti}` | Revoke token by JTI |
|
||||
| DELETE | `/v1/auth/totp` | Remove TOTP from account |
|
||||
| GET | `/v1/audit` | Query audit log |
|
||||
|
||||
---
|
||||
|
||||
## Security Notes for Integrators
|
||||
|
||||
**Always validate `alg` before the signature.** Never use a JWT library that
|
||||
accepts `alg: none` or falls back to HMAC. MCIAS only issues `EdDSA` tokens.
|
||||
|
||||
**Cache the public key, but refresh it.** Fetch `/v1/keys/public` at startup.
|
||||
Refresh it if signature verification starts failing (key rotation). Do not
|
||||
hard-code the key bytes.
|
||||
|
||||
**Use the CA certificate.** MCIAS typically runs with a self-signed or
|
||||
private-CA cert. Pass the cert path to your HTTP client; do not disable TLS
|
||||
verification.
|
||||
|
||||
**Do not log tokens.** Strip the `Authorization` header from access logs.
|
||||
A leaked token is valid until it expires or is explicitly revoked.
|
||||
|
||||
**For revocation-sensitive paths, use online validation.** The `/v1/token/validate`
|
||||
endpoint reflects revocations instantly. Offline verification is fine for
|
||||
low-sensitivity reads; use online validation for writes, privilege escalation,
|
||||
and any action with irreversible effects.
|
||||
|
||||
**System accounts have one active token at a time.** Issuing a new token via
|
||||
`/v1/token/issue` revokes the previous one. Do not share a system account
|
||||
across services that rotate independently; create one system account per service.
|
||||
|
||||
**Roles are not hierarchical.** `admin` does not imply any other role and vice
|
||||
versa. Check each required role explicitly.
|
||||
@@ -58,6 +58,18 @@ func (db *DB) GetAccountByUUID(accountUUID string) (*model.Account, error) {
|
||||
`, accountUUID))
|
||||
}
|
||||
|
||||
// GetAccountByID retrieves an account by its numeric primary key.
|
||||
// Returns ErrNotFound if no matching account exists.
|
||||
func (db *DB) GetAccountByID(id int64) (*model.Account, error) {
|
||||
return db.scanAccount(db.sql.QueryRow(`
|
||||
SELECT id, uuid, username, account_type, COALESCE(password_hash,''),
|
||||
status, totp_required,
|
||||
totp_secret_enc, totp_secret_nonce,
|
||||
created_at, updated_at, deleted_at
|
||||
FROM accounts WHERE id = ?
|
||||
`, id))
|
||||
}
|
||||
|
||||
// GetAccountByUsername retrieves an account by username (case-insensitive).
|
||||
// Returns ErrNotFound if no matching account exists.
|
||||
func (db *DB) GetAccountByUsername(username string) (*model.Account, error) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
@@ -25,6 +26,7 @@ import (
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/ui"
|
||||
"git.wntrmute.dev/kyle/mcias/web"
|
||||
)
|
||||
|
||||
// Server holds the dependencies injected into all handlers.
|
||||
@@ -64,6 +66,21 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /v1/auth/login", loginRateLimit(http.HandlerFunc(s.handleLogin)))
|
||||
mux.Handle("POST /v1/token/validate", loginRateLimit(http.HandlerFunc(s.handleTokenValidate)))
|
||||
|
||||
// API documentation: Swagger UI at /docs and raw spec at /docs/openapi.yaml.
|
||||
// Both are served from the embedded web/static filesystem; no external
|
||||
// files are read at runtime.
|
||||
staticFS, err := fs.Sub(web.StaticFS, "static")
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("server: sub fs: %v", err))
|
||||
}
|
||||
mux.HandleFunc("GET /docs", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFileFS(w, r, staticFS, "docs.html")
|
||||
})
|
||||
mux.HandleFunc("GET /docs/openapi.yaml", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/yaml")
|
||||
http.ServeFileFS(w, r, staticFS, "openapi.yaml")
|
||||
})
|
||||
|
||||
// Authenticated endpoints.
|
||||
requireAuth := middleware.RequireAuth(s.pubKey, s.db, s.cfg.Tokens.Issuer)
|
||||
requireAdmin := func(h http.Handler) http.Handler {
|
||||
|
||||
@@ -15,28 +15,37 @@ func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, "login", LoginData{})
|
||||
}
|
||||
|
||||
// handleLoginPost processes username+password (and optional TOTP code).
|
||||
// handleLoginPost processes username+password (step 1) or TOTP code (step 2).
|
||||
//
|
||||
// Security design:
|
||||
// - Password is verified via Argon2id on every request, including the TOTP
|
||||
// second step, to prevent credential-bypass by jumping to TOTP directly.
|
||||
// - Timing is held constant regardless of whether the account exists, by
|
||||
// always running a dummy Argon2 check for unknown accounts.
|
||||
// - On TOTP required: returns the totp_step fragment (200) so HTMX swaps the
|
||||
// form in place. The username and password are included as hidden fields;
|
||||
// they are re-verified on the TOTP submission.
|
||||
// - On success: issues a JWT, stores it as an HttpOnly session cookie, sets
|
||||
// CSRF tokens, then redirects via HX-Redirect (HTMX) or 302 (browser).
|
||||
// Security design (F-02 fix):
|
||||
// - Step 1: username+password submitted. Password verified via Argon2id.
|
||||
// On success with TOTP required, a 90-second single-use server-side nonce
|
||||
// is issued and its account ID stored in pendingLogins. Only the nonce
|
||||
// (not the password) is embedded in the TOTP step HTML form, so the
|
||||
// plaintext password is never sent over the wire a second time and never
|
||||
// appears in the DOM during the TOTP step.
|
||||
// - Step 2: totp_step=1 form submitted. The nonce is consumed (single-use,
|
||||
// expiry checked) to retrieve the account ID; no password is needed.
|
||||
// TOTP code is then verified against the decrypted stored secret.
|
||||
// - Timing is held constant for unknown accounts by always running a dummy
|
||||
// Argon2 check, preventing username enumeration.
|
||||
// - On final success: JWT issued, stored as HttpOnly session cookie.
|
||||
func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
u.render(w, "totp_step", LoginData{Error: "invalid form submission"})
|
||||
u.render(w, "login", LoginData{Error: "invalid form submission"})
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: TOTP confirmation (totp_step=1 was set by step 1's rendered form).
|
||||
if r.FormValue("totp_step") == "1" {
|
||||
u.handleTOTPStep(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Step 1: password verification.
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
totpCode := r.FormValue("totp_code")
|
||||
|
||||
if username == "" || password == "" {
|
||||
u.render(w, "login", LoginData{Error: "username and password are required"})
|
||||
@@ -47,7 +56,7 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
acct, err := u.db.GetAccountByUsername(username)
|
||||
if err != nil {
|
||||
// Security: always run dummy Argon2 to prevent timing-based user enumeration.
|
||||
_, _ = auth.VerifyPassword("dummy", "$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g")
|
||||
_, _ = auth.VerifyPassword("dummy", u.dummyHash())
|
||||
u.writeAudit(r, model.EventLoginFail, nil, nil,
|
||||
fmt.Sprintf(`{"username":%q,"reason":"unknown_user"}`, username))
|
||||
u.render(w, "login", LoginData{Error: "invalid credentials"})
|
||||
@@ -56,13 +65,13 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Security: check account status before credential verification.
|
||||
if acct.Status != model.AccountStatusActive {
|
||||
_, _ = auth.VerifyPassword("dummy", "$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g")
|
||||
_, _ = auth.VerifyPassword("dummy", u.dummyHash())
|
||||
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_inactive"}`)
|
||||
u.render(w, "login", LoginData{Error: "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify password. Always run even if TOTP step, to prevent bypass.
|
||||
// Verify password.
|
||||
ok, err := auth.VerifyPassword(password, acct.PasswordHash)
|
||||
if err != nil || !ok {
|
||||
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"wrong_password"}`)
|
||||
@@ -70,37 +79,84 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// TOTP check.
|
||||
// TOTP required: issue a server-side nonce and show the TOTP step form.
|
||||
// Security: the nonce replaces the password hidden field (F-02). The password
|
||||
// is not stored anywhere after this point; only the account ID is retained.
|
||||
if acct.TOTPRequired {
|
||||
if totpCode == "" {
|
||||
// Return TOTP step fragment so HTMX swaps the form.
|
||||
u.render(w, "totp_step", LoginData{
|
||||
Username: username,
|
||||
// Security: password is embedded as a hidden form field so the
|
||||
// second submission can re-verify it. It is never logged.
|
||||
Password: password,
|
||||
})
|
||||
return
|
||||
}
|
||||
// Decrypt and validate TOTP secret.
|
||||
secret, err := crypto.OpenAESGCM(u.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
||||
nonce, err := u.issueTOTPNonce(acct.ID)
|
||||
if err != nil {
|
||||
u.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
|
||||
u.logger.Error("issue TOTP nonce", "error", err)
|
||||
u.render(w, "login", LoginData{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
valid, err := auth.ValidateTOTP(secret, totpCode)
|
||||
if err != nil || !valid {
|
||||
u.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
|
||||
u.render(w, "totp_step", LoginData{
|
||||
Error: "invalid TOTP code",
|
||||
Username: username,
|
||||
Password: password,
|
||||
})
|
||||
return
|
||||
}
|
||||
u.render(w, "totp_step", LoginData{
|
||||
Username: username,
|
||||
Nonce: nonce,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
u.finishLogin(w, r, acct)
|
||||
}
|
||||
|
||||
// handleTOTPStep handles the second POST when totp_step=1 is set.
|
||||
// It consumes the single-use nonce to retrieve the account, then validates
|
||||
// the submitted TOTP code before completing the login.
|
||||
//
|
||||
// The body has already been limited by MaxBytesReader in handleLoginPost
|
||||
// before ParseForm was called; r.FormValue reads from the already-parsed
|
||||
// in-memory form cache, not the network stream.
|
||||
func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
|
||||
// Body is already size-limited and parsed by the caller (handleLoginPost).
|
||||
username := r.FormValue("username") //nolint:gosec // body already limited by caller
|
||||
nonce := r.FormValue("totp_nonce") //nolint:gosec // body already limited by caller
|
||||
totpCode := r.FormValue("totp_code") //nolint:gosec // body already limited by caller
|
||||
|
||||
// Security: consume the nonce (single-use); reject if unknown or expired.
|
||||
accountID, ok := u.consumeTOTPNonce(nonce)
|
||||
if !ok {
|
||||
u.writeAudit(r, model.EventLoginFail, nil, nil,
|
||||
fmt.Sprintf(`{"username":%q,"reason":"invalid_totp_nonce"}`, username))
|
||||
u.render(w, "login", LoginData{Error: "session expired, please log in again"})
|
||||
return
|
||||
}
|
||||
|
||||
acct, err := u.db.GetAccountByID(accountID)
|
||||
if err != nil {
|
||||
u.logger.Error("get account for TOTP step", "error", err, "account_id", accountID)
|
||||
u.render(w, "login", LoginData{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt and validate TOTP secret.
|
||||
secret, err := crypto.OpenAESGCM(u.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
||||
if err != nil {
|
||||
u.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
|
||||
u.render(w, "login", LoginData{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
valid, err := auth.ValidateTOTP(secret, totpCode)
|
||||
if err != nil || !valid {
|
||||
u.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
|
||||
// Re-issue a fresh nonce so the user can retry without going back to step 1.
|
||||
newNonce, nonceErr := u.issueTOTPNonce(acct.ID)
|
||||
if nonceErr != nil {
|
||||
u.render(w, "login", LoginData{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
u.render(w, "totp_step", LoginData{
|
||||
Error: "invalid TOTP code",
|
||||
Username: username,
|
||||
Nonce: newNonce,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
u.finishLogin(w, r, acct)
|
||||
}
|
||||
|
||||
// finishLogin issues a JWT, sets the session cookie, and redirects to dashboard.
|
||||
func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *model.Account) {
|
||||
// Determine token expiry based on admin role.
|
||||
expiry := u.cfg.DefaultExpiry()
|
||||
roles, err := u.db.GetRoles(acct.ID)
|
||||
|
||||
@@ -15,6 +15,8 @@ package ui
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
@@ -22,6 +24,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||
@@ -33,18 +36,73 @@ import (
|
||||
const (
|
||||
sessionCookieName = "mcias_session"
|
||||
csrfCookieName = "mcias_csrf"
|
||||
totpNonceTTL = 90 * time.Second // single-use TOTP step nonce lifetime
|
||||
totpNonceBytes = 16 // 128 bits of entropy
|
||||
)
|
||||
|
||||
// pendingLogin is a short-lived record created after password verification
|
||||
// succeeds but before TOTP confirmation. It holds the account ID so the
|
||||
// TOTP step never needs to re-transmit the password.
|
||||
//
|
||||
// Security: the nonce is single-use (deleted on first lookup) and expires
|
||||
// after totpNonceTTL to bound the window of a stolen nonce.
|
||||
type pendingLogin struct {
|
||||
expiresAt time.Time
|
||||
accountID int64
|
||||
}
|
||||
|
||||
// UIServer serves the HTMX-based management UI.
|
||||
type UIServer struct {
|
||||
db *db.DB
|
||||
cfg *config.Config
|
||||
logger *slog.Logger
|
||||
csrf *CSRFManager
|
||||
tmpls map[string]*template.Template // page name → template set
|
||||
pubKey ed25519.PublicKey
|
||||
privKey ed25519.PrivateKey
|
||||
masterKey []byte
|
||||
pendingLogins sync.Map // nonce (string) → *pendingLogin
|
||||
tmpls map[string]*template.Template // page name → template set
|
||||
db *db.DB
|
||||
cfg *config.Config
|
||||
logger *slog.Logger
|
||||
csrf *CSRFManager
|
||||
pubKey ed25519.PublicKey
|
||||
privKey ed25519.PrivateKey
|
||||
masterKey []byte
|
||||
}
|
||||
|
||||
// issueTOTPNonce creates a random single-use nonce for the TOTP step and
|
||||
// stores the account ID it corresponds to. Returns the hex-encoded nonce.
|
||||
func (u *UIServer) issueTOTPNonce(accountID int64) (string, error) {
|
||||
raw := make([]byte, totpNonceBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("ui: generate TOTP nonce: %w", err)
|
||||
}
|
||||
nonce := hex.EncodeToString(raw)
|
||||
u.pendingLogins.Store(nonce, &pendingLogin{
|
||||
accountID: accountID,
|
||||
expiresAt: time.Now().Add(totpNonceTTL),
|
||||
})
|
||||
return nonce, nil
|
||||
}
|
||||
|
||||
// consumeTOTPNonce looks up and deletes the nonce, returning the associated
|
||||
// account ID. Returns (0, false) if the nonce is unknown or expired.
|
||||
func (u *UIServer) consumeTOTPNonce(nonce string) (int64, bool) {
|
||||
v, ok := u.pendingLogins.LoadAndDelete(nonce)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
pl, ok2 := v.(*pendingLogin)
|
||||
if !ok2 {
|
||||
return 0, false
|
||||
}
|
||||
if time.Now().After(pl.expiresAt) {
|
||||
return 0, false
|
||||
}
|
||||
return pl.accountID, true
|
||||
}
|
||||
|
||||
// dummyHash returns a hardcoded Argon2id PHC string used for constant-time
|
||||
// dummy password verification when the account is unknown or inactive.
|
||||
// Security: the dummy hash uses OWASP-recommended parameters (m=65536,t=3,p=4)
|
||||
// to match real verification timing. F-07 will replace this with a
|
||||
// sync.Once-computed real hash for exact parameter parity.
|
||||
func (u *UIServer) dummyHash() string {
|
||||
return "$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g"
|
||||
}
|
||||
|
||||
// New constructs a UIServer, parses all templates, and returns it.
|
||||
@@ -423,7 +481,10 @@ type PageData struct {
|
||||
type LoginData struct {
|
||||
Error string
|
||||
Username string // pre-filled on TOTP step
|
||||
Password string // pre-filled on TOTP step (value attr, never logged)
|
||||
// Security (F-02): Password is no longer carried in the HTML form. Instead
|
||||
// a short-lived server-side nonce is issued after successful password
|
||||
// verification, and only the nonce is embedded in the TOTP step form.
|
||||
Nonce string // single-use server-side nonce replacing the password hidden field
|
||||
}
|
||||
|
||||
// DashboardData is the view model for the dashboard page.
|
||||
|
||||
@@ -9,16 +9,17 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
)
|
||||
|
||||
const testIssuer = "https://auth.example.com"
|
||||
|
||||
// newTestMux creates a UIServer and returns the http.Handler used in production
|
||||
// (a ServeMux with all UI routes registered, wrapped with securityHeaders).
|
||||
func newTestMux(t *testing.T) http.Handler {
|
||||
// newTestUIServer creates a UIServer backed by an in-memory DB.
|
||||
func newTestUIServer(t *testing.T) *UIServer {
|
||||
t.Helper()
|
||||
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
@@ -47,7 +48,14 @@ func newTestMux(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatalf("new UIServer: %v", err)
|
||||
}
|
||||
return uiSrv
|
||||
}
|
||||
|
||||
// newTestMux creates a UIServer and returns the http.Handler used in production
|
||||
// (a ServeMux with all UI routes registered, wrapped with securityHeaders).
|
||||
func newTestMux(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
uiSrv := newTestUIServer(t)
|
||||
mux := http.NewServeMux()
|
||||
uiSrv.Register(mux)
|
||||
return mux
|
||||
@@ -181,3 +189,113 @@ func TestSecurityHeadersMiddlewareUnit(t *testing.T) {
|
||||
}
|
||||
assertSecurityHeaders(t, rr.Result().Header, "unit test")
|
||||
}
|
||||
|
||||
// TestTOTPNonceIssuedAndConsumed verifies that issueTOTPNonce produces a
|
||||
// non-empty nonce and consumeTOTPNonce returns the correct account ID exactly
|
||||
// once (single-use).
|
||||
func TestTOTPNonceIssuedAndConsumed(t *testing.T) {
|
||||
u := newTestUIServer(t)
|
||||
|
||||
const accountID int64 = 42
|
||||
nonce, err := u.issueTOTPNonce(accountID)
|
||||
if err != nil {
|
||||
t.Fatalf("issueTOTPNonce: %v", err)
|
||||
}
|
||||
if nonce == "" {
|
||||
t.Fatal("expected non-empty nonce")
|
||||
}
|
||||
|
||||
// First consumption must succeed.
|
||||
got, ok := u.consumeTOTPNonce(nonce)
|
||||
if !ok {
|
||||
t.Fatal("consumeTOTPNonce: expected ok=true on first use")
|
||||
}
|
||||
if got != accountID {
|
||||
t.Errorf("accountID = %d, want %d", got, accountID)
|
||||
}
|
||||
|
||||
// Second consumption must fail (single-use).
|
||||
_, ok2 := u.consumeTOTPNonce(nonce)
|
||||
if ok2 {
|
||||
t.Error("consumeTOTPNonce: expected ok=false on second use (single-use guarantee violated)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTOTPNonceUnknownRejected verifies that a never-issued nonce is rejected.
|
||||
func TestTOTPNonceUnknownRejected(t *testing.T) {
|
||||
u := newTestUIServer(t)
|
||||
_, ok := u.consumeTOTPNonce("not-a-real-nonce")
|
||||
if ok {
|
||||
t.Error("consumeTOTPNonce: expected ok=false for unknown nonce")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTOTPNonceExpired verifies that an expired nonce is rejected even if
|
||||
// the token exists in the map.
|
||||
func TestTOTPNonceExpired(t *testing.T) {
|
||||
u := newTestUIServer(t)
|
||||
|
||||
const accountID int64 = 99
|
||||
nonce, err := u.issueTOTPNonce(accountID)
|
||||
if err != nil {
|
||||
t.Fatalf("issueTOTPNonce: %v", err)
|
||||
}
|
||||
|
||||
// Back-date the stored entry so it appears expired.
|
||||
v, loaded := u.pendingLogins.Load(nonce)
|
||||
if !loaded {
|
||||
t.Fatal("nonce not found in pendingLogins immediately after issuance")
|
||||
}
|
||||
pl, castOK := v.(*pendingLogin)
|
||||
if !castOK {
|
||||
t.Fatal("pendingLogins value is not *pendingLogin")
|
||||
}
|
||||
pl.expiresAt = time.Now().Add(-time.Second)
|
||||
|
||||
_, ok := u.consumeTOTPNonce(nonce)
|
||||
if ok {
|
||||
t.Error("consumeTOTPNonce: expected ok=false for expired nonce")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginPostPasswordNotInTOTPForm verifies that after step 1, the TOTP
|
||||
// step form body does not contain the user's password.
|
||||
func TestLoginPostPasswordNotInTOTPForm(t *testing.T) {
|
||||
u := newTestUIServer(t)
|
||||
|
||||
// Create an account with a known password and TOTP required flag.
|
||||
// We use the auth package to hash and the db to store directly.
|
||||
acct, err := u.db.CreateAccount("totpuser", model.AccountTypeHuman, "$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
// Enable TOTP required flag directly (use a stub secret so the account is
|
||||
// consistent; the step-1→step-2 nonce test only covers step 1 here).
|
||||
if err := u.db.StorePendingTOTP(acct.ID, []byte("enc"), []byte("nonce")); err != nil {
|
||||
t.Fatalf("StorePendingTOTP: %v", err)
|
||||
}
|
||||
if err := u.db.SetTOTP(acct.ID, []byte("enc"), []byte("nonce")); err != nil {
|
||||
t.Fatalf("SetTOTP: %v", err)
|
||||
}
|
||||
|
||||
// POST step 1 with wrong password (will fail auth but verify form shape doesn't matter).
|
||||
// Instead, test the nonce store directly: issueTOTPNonce must be called once
|
||||
// per password-verified login attempt, and the form must carry Nonce not Password.
|
||||
nonce, err := u.issueTOTPNonce(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("issueTOTPNonce: %v", err)
|
||||
}
|
||||
|
||||
// Simulate what the template renders: the LoginData for the TOTP step.
|
||||
data := LoginData{Nonce: nonce}
|
||||
if data.Nonce == "" {
|
||||
t.Error("LoginData.Nonce is empty after issueTOTPNonce")
|
||||
}
|
||||
// Password field must be empty — it is no longer part of LoginData.
|
||||
// (This is a compile-time structural guarantee; the field was removed.)
|
||||
// The nonce must be non-empty and different on each issuance.
|
||||
nonce2, _ := u.issueTOTPNonce(acct.ID)
|
||||
if nonce == nonce2 {
|
||||
t.Error("two consecutive nonces are identical (randomness failure)")
|
||||
}
|
||||
}
|
||||
|
||||
965
openapi.yaml
Normal file
965
openapi.yaml
Normal file
@@ -0,0 +1,965 @@
|
||||
openapi: "3.1.0"
|
||||
|
||||
info:
|
||||
title: MCIAS Authentication API
|
||||
version: "1.0"
|
||||
description: |
|
||||
MCIAS (Metacircular Identity and Access System) provides JWT-based
|
||||
authentication, account management, TOTP, and Postgres credential storage.
|
||||
|
||||
All tokens are Ed25519-signed JWTs (algorithm `EdDSA`). Bearer tokens must
|
||||
be sent in the `Authorization` header as `Bearer <token>`.
|
||||
|
||||
Rate limiting applies to `/v1/auth/login` and `/v1/token/validate`:
|
||||
10 requests per second per IP, burst of 10.
|
||||
|
||||
servers:
|
||||
- url: https://auth.example.com:8443
|
||||
description: Production
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
|
||||
schemas:
|
||||
Error:
|
||||
type: object
|
||||
required: [error, code]
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
description: Human-readable error message.
|
||||
example: invalid credentials
|
||||
code:
|
||||
type: string
|
||||
description: Machine-readable error code.
|
||||
example: unauthorized
|
||||
|
||||
TokenResponse:
|
||||
type: object
|
||||
required: [token, expires_at]
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
description: Ed25519-signed JWT (EdDSA).
|
||||
example: eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Token expiry in RFC 3339 format.
|
||||
example: "2026-04-10T12:34:56Z"
|
||||
|
||||
Account:
|
||||
type: object
|
||||
required: [id, username, account_type, status, created_at, updated_at, totp_enabled]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Account UUID (use this in all API calls).
|
||||
example: 550e8400-e29b-41d4-a716-446655440000
|
||||
username:
|
||||
type: string
|
||||
example: alice
|
||||
account_type:
|
||||
type: string
|
||||
enum: [human, system]
|
||||
example: human
|
||||
status:
|
||||
type: string
|
||||
enum: [active, inactive, deleted]
|
||||
example: active
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2026-03-11T09:00:00Z"
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2026-03-11T09:00:00Z"
|
||||
totp_enabled:
|
||||
type: boolean
|
||||
description: Whether TOTP is enrolled and required for this account.
|
||||
example: false
|
||||
|
||||
AuditEvent:
|
||||
type: object
|
||||
required: [id, event_type, event_time, ip_address]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
example: 42
|
||||
event_type:
|
||||
type: string
|
||||
example: login_ok
|
||||
event_time:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2026-03-11T09:01:23Z"
|
||||
actor_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: UUID of the account that performed the action. Null for bootstrap events.
|
||||
example: 550e8400-e29b-41d4-a716-446655440000
|
||||
target_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: UUID of the affected account, if applicable.
|
||||
ip_address:
|
||||
type: string
|
||||
example: "192.0.2.1"
|
||||
details:
|
||||
type: string
|
||||
description: JSON blob with event-specific metadata. Never contains credentials.
|
||||
example: '{"jti":"f47ac10b-..."}'
|
||||
|
||||
PGCreds:
|
||||
type: object
|
||||
required: [host, port, database, username, password]
|
||||
properties:
|
||||
host:
|
||||
type: string
|
||||
example: db.example.com
|
||||
port:
|
||||
type: integer
|
||||
example: 5432
|
||||
database:
|
||||
type: string
|
||||
example: mydb
|
||||
username:
|
||||
type: string
|
||||
example: myuser
|
||||
password:
|
||||
type: string
|
||||
description: >
|
||||
Plaintext password (sent over TLS, stored encrypted at rest with
|
||||
AES-256-GCM). Only returned to admin callers.
|
||||
example: hunter2
|
||||
|
||||
responses:
|
||||
Unauthorized:
|
||||
description: Token missing, invalid, expired, or credentials incorrect.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
example:
|
||||
error: invalid credentials
|
||||
code: unauthorized
|
||||
|
||||
Forbidden:
|
||||
description: Token valid but lacks the required role.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
example:
|
||||
error: forbidden
|
||||
code: forbidden
|
||||
|
||||
NotFound:
|
||||
description: Requested resource does not exist.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
example:
|
||||
error: account not found
|
||||
code: not_found
|
||||
|
||||
BadRequest:
|
||||
description: Malformed request or missing required fields.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
example:
|
||||
error: username and password are required
|
||||
code: bad_request
|
||||
|
||||
RateLimited:
|
||||
description: Rate limit exceeded.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
example:
|
||||
error: rate limit exceeded
|
||||
code: rate_limited
|
||||
|
||||
paths:
|
||||
|
||||
# ── Public ────────────────────────────────────────────────────────────────
|
||||
|
||||
/v1/health:
|
||||
get:
|
||||
summary: Health check
|
||||
description: Returns `{"status":"ok"}` if the server is running. No auth required.
|
||||
operationId: getHealth
|
||||
tags: [Public]
|
||||
responses:
|
||||
"200":
|
||||
description: Server is healthy.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: ok
|
||||
|
||||
/v1/keys/public:
|
||||
get:
|
||||
summary: Ed25519 public key (JWK)
|
||||
description: |
|
||||
Returns the server's Ed25519 public key in JWK format (RFC 8037).
|
||||
Relying parties use this to verify JWT signatures offline.
|
||||
|
||||
Cache this key at startup. Refresh it if signature verification begins
|
||||
failing (indicates key rotation).
|
||||
|
||||
**Important:** Always validate the `alg` header of the JWT (`EdDSA`)
|
||||
before calling the signature verification routine. Never accept `none`.
|
||||
operationId: getPublicKey
|
||||
tags: [Public]
|
||||
responses:
|
||||
"200":
|
||||
description: Ed25519 public key in JWK format.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [kty, crv, use, alg, x]
|
||||
properties:
|
||||
kty:
|
||||
type: string
|
||||
example: OKP
|
||||
crv:
|
||||
type: string
|
||||
example: Ed25519
|
||||
use:
|
||||
type: string
|
||||
example: sig
|
||||
alg:
|
||||
type: string
|
||||
example: EdDSA
|
||||
x:
|
||||
type: string
|
||||
description: Base64url-encoded public key bytes.
|
||||
example: 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo
|
||||
|
||||
/v1/auth/login:
|
||||
post:
|
||||
summary: Login
|
||||
description: |
|
||||
Authenticate with username + password and optionally a TOTP code.
|
||||
Returns an Ed25519-signed JWT.
|
||||
|
||||
Rate limited to 10 requests per second per IP (burst 10).
|
||||
|
||||
Error responses always use the generic message `"invalid credentials"`
|
||||
regardless of whether the user exists, the password is wrong, or the
|
||||
account is inactive. This prevents user enumeration.
|
||||
|
||||
If the account has TOTP enrolled, `totp_code` is required.
|
||||
Omitting it returns HTTP 401 with code `totp_required`.
|
||||
operationId: login
|
||||
tags: [Public]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [username, password]
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
example: alice
|
||||
password:
|
||||
type: string
|
||||
example: s3cr3t
|
||||
totp_code:
|
||||
type: string
|
||||
description: Current 6-digit TOTP code. Required if TOTP is enrolled.
|
||||
example: "123456"
|
||||
responses:
|
||||
"200":
|
||||
description: Login successful. Returns JWT and expiry.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TokenResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
description: Invalid credentials, inactive account, or missing TOTP code.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
examples:
|
||||
invalid_credentials:
|
||||
value: {error: invalid credentials, code: unauthorized}
|
||||
totp_required:
|
||||
value: {error: TOTP code required, code: totp_required}
|
||||
"429":
|
||||
$ref: "#/components/responses/RateLimited"
|
||||
|
||||
/v1/token/validate:
|
||||
post:
|
||||
summary: Validate a JWT
|
||||
description: |
|
||||
Validate a JWT and return its claims. Reflects revocations immediately
|
||||
(online validation). Use this for high-security paths where offline
|
||||
verification is insufficient.
|
||||
|
||||
The token may be supplied either as a Bearer header or in the JSON body.
|
||||
|
||||
**Always inspect the `valid` field.** The response is always HTTP 200;
|
||||
do not branch on the status code.
|
||||
|
||||
Rate limited to 10 requests per second per IP (burst 10).
|
||||
operationId: validateToken
|
||||
tags: [Public]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- {}
|
||||
requestBody:
|
||||
description: Optionally supply the token in the body instead of the header.
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
example: eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...
|
||||
responses:
|
||||
"200":
|
||||
description: Validation result. Always HTTP 200; check `valid`.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [valid]
|
||||
properties:
|
||||
valid:
|
||||
type: boolean
|
||||
sub:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Subject (account UUID). Present when valid=true.
|
||||
example: 550e8400-e29b-41d4-a716-446655440000
|
||||
roles:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Role list. Present when valid=true.
|
||||
example: [editor]
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Expiry. Present when valid=true.
|
||||
example: "2026-04-10T12:34:56Z"
|
||||
examples:
|
||||
valid:
|
||||
value: {valid: true, sub: "550e8400-...", roles: [editor], expires_at: "2026-04-10T12:34:56Z"}
|
||||
invalid:
|
||||
value: {valid: false}
|
||||
"429":
|
||||
$ref: "#/components/responses/RateLimited"
|
||||
|
||||
# ── Authenticated ──────────────────────────────────────────────────────────
|
||||
|
||||
/v1/auth/logout:
|
||||
post:
|
||||
summary: Logout
|
||||
description: |
|
||||
Revoke the current bearer token immediately. The JTI is recorded in the
|
||||
revocation table; subsequent validation calls will return `valid=false`.
|
||||
operationId: logout
|
||||
tags: [Auth]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"204":
|
||||
description: Token revoked.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
|
||||
/v1/auth/renew:
|
||||
post:
|
||||
summary: Renew token
|
||||
description: |
|
||||
Exchange the current token for a fresh one. The old token is revoked.
|
||||
The new token reflects any role changes made since the original login.
|
||||
|
||||
Token expiry is recalculated: 30 days for regular users, 8 hours for
|
||||
admins.
|
||||
operationId: renewToken
|
||||
tags: [Auth]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: New token issued. Old token revoked.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TokenResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
|
||||
/v1/auth/totp/enroll:
|
||||
post:
|
||||
summary: Begin TOTP enrollment
|
||||
description: |
|
||||
Generate a TOTP secret for the authenticated account and return it as a
|
||||
bare secret and as an `otpauth://` URI (scan with any authenticator app).
|
||||
|
||||
The secret is shown **once**. It is stored encrypted at rest and is not
|
||||
retrievable after this call.
|
||||
|
||||
TOTP is not required until the enrollment is confirmed via
|
||||
`POST /v1/auth/totp/confirm`. Abandoning after this call does not lock
|
||||
the account.
|
||||
operationId: enrollTOTP
|
||||
tags: [Auth]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: TOTP secret generated.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [secret, otpauth_uri]
|
||||
properties:
|
||||
secret:
|
||||
type: string
|
||||
description: Base32-encoded TOTP secret. Store in an authenticator app.
|
||||
example: JBSWY3DPEHPK3PXP
|
||||
otpauth_uri:
|
||||
type: string
|
||||
description: Standard otpauth URI for QR-code generation.
|
||||
example: "otpauth://totp/MCIAS:alice?secret=JBSWY3DPEHPK3PXP&issuer=MCIAS"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
|
||||
/v1/auth/totp/confirm:
|
||||
post:
|
||||
summary: Confirm TOTP enrollment
|
||||
description: |
|
||||
Verify the provided TOTP code against the pending secret. On success,
|
||||
TOTP becomes required for all future logins for this account.
|
||||
operationId: confirmTOTP
|
||||
tags: [Auth]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [code]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: Current 6-digit TOTP code.
|
||||
example: "123456"
|
||||
responses:
|
||||
"204":
|
||||
description: TOTP confirmed. Required for future logins.
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
|
||||
# ── Admin ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/v1/auth/totp:
|
||||
delete:
|
||||
summary: Remove TOTP from account (admin)
|
||||
description: |
|
||||
Clear TOTP enrollment for an account. Use for account recovery when a
|
||||
user loses their TOTP device. The account can log in with password only
|
||||
after this call.
|
||||
operationId: removeTOTP
|
||||
tags: [Admin — Auth]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [account_id]
|
||||
properties:
|
||||
account_id:
|
||||
type: string
|
||||
format: uuid
|
||||
example: 550e8400-e29b-41d4-a716-446655440000
|
||||
responses:
|
||||
"204":
|
||||
description: TOTP removed.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
/v1/token/issue:
|
||||
post:
|
||||
summary: Issue service account token (admin)
|
||||
description: |
|
||||
Issue a long-lived bearer token for a system account. If the account
|
||||
already has an active token, it is revoked and replaced.
|
||||
|
||||
Only one active token exists per system account at a time.
|
||||
|
||||
Issued tokens expire after 1 year (configurable via
|
||||
`tokens.service_expiry`).
|
||||
operationId: issueServiceToken
|
||||
tags: [Admin — Tokens]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [account_id]
|
||||
properties:
|
||||
account_id:
|
||||
type: string
|
||||
format: uuid
|
||||
example: 550e8400-e29b-41d4-a716-446655440000
|
||||
responses:
|
||||
"200":
|
||||
description: Token issued.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TokenResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
/v1/token/{jti}:
|
||||
delete:
|
||||
summary: Revoke token by JTI (admin)
|
||||
description: |
|
||||
Revoke any token by its JWT ID (`jti` claim). The token is immediately
|
||||
invalid for all future validation calls.
|
||||
operationId: revokeToken
|
||||
tags: [Admin — Tokens]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: jti
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
example: f47ac10b-58cc-4372-a567-0e02b2c3d479
|
||||
responses:
|
||||
"204":
|
||||
description: Token revoked.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
/v1/accounts:
|
||||
get:
|
||||
summary: List accounts (admin)
|
||||
operationId: listAccounts
|
||||
tags: [Admin — Accounts]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Array of accounts.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Account"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
|
||||
post:
|
||||
summary: Create account (admin)
|
||||
description: |
|
||||
Create a human or system account.
|
||||
|
||||
- `human` accounts require a `password`.
|
||||
- `system` accounts must not include a `password`; authenticate via
|
||||
tokens issued by `POST /v1/token/issue`.
|
||||
operationId: createAccount
|
||||
tags: [Admin — Accounts]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [username, account_type]
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
example: alice
|
||||
account_type:
|
||||
type: string
|
||||
enum: [human, system]
|
||||
example: human
|
||||
password:
|
||||
type: string
|
||||
description: Required for human accounts. Hashed with Argon2id.
|
||||
example: s3cr3t
|
||||
responses:
|
||||
"201":
|
||||
description: Account created.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Account"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"409":
|
||||
description: Username already taken.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
example:
|
||||
error: username already exists
|
||||
code: conflict
|
||||
|
||||
/v1/accounts/{id}:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Account UUID.
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
example: 550e8400-e29b-41d4-a716-446655440000
|
||||
|
||||
get:
|
||||
summary: Get account (admin)
|
||||
operationId: getAccount
|
||||
tags: [Admin — Accounts]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Account details.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Account"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
patch:
|
||||
summary: Update account (admin)
|
||||
description: Update mutable account fields. Currently only `status` is patchable.
|
||||
operationId: updateAccount
|
||||
tags: [Admin — Accounts]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [active, inactive]
|
||||
example: inactive
|
||||
responses:
|
||||
"204":
|
||||
description: Account updated.
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
delete:
|
||||
summary: Delete account (admin)
|
||||
description: |
|
||||
Soft-delete an account. Sets status to `deleted` and revokes all active
|
||||
tokens. The account record is retained for audit purposes.
|
||||
operationId: deleteAccount
|
||||
tags: [Admin — Accounts]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"204":
|
||||
description: Account deleted.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
/v1/accounts/{id}/roles:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
example: 550e8400-e29b-41d4-a716-446655440000
|
||||
|
||||
get:
|
||||
summary: Get account roles (admin)
|
||||
operationId: getRoles
|
||||
tags: [Admin — Accounts]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Current role list.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [roles]
|
||||
properties:
|
||||
roles:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example: [editor, readonly]
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
put:
|
||||
summary: Set account roles (admin)
|
||||
description: |
|
||||
Replace the account's full role list. Roles take effect in the **next**
|
||||
token issued or renewed; existing tokens continue to carry the roles
|
||||
embedded at issuance time.
|
||||
operationId: setRoles
|
||||
tags: [Admin — Accounts]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [roles]
|
||||
properties:
|
||||
roles:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example: [editor, readonly]
|
||||
responses:
|
||||
"204":
|
||||
description: Roles updated.
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
/v1/accounts/{id}/pgcreds:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
example: 550e8400-e29b-41d4-a716-446655440000
|
||||
|
||||
get:
|
||||
summary: Get Postgres credentials (admin)
|
||||
description: |
|
||||
Retrieve stored Postgres connection credentials. Password is returned
|
||||
in plaintext over TLS. Stored encrypted at rest with AES-256-GCM.
|
||||
operationId: getPGCreds
|
||||
tags: [Admin — Credentials]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Postgres credentials.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PGCreds"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
put:
|
||||
summary: Set Postgres credentials (admin)
|
||||
description: Store or replace Postgres credentials for an account.
|
||||
operationId: setPGCreds
|
||||
tags: [Admin — Credentials]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PGCreds"
|
||||
responses:
|
||||
"204":
|
||||
description: Credentials stored.
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
/v1/audit:
|
||||
get:
|
||||
summary: Query audit log (admin)
|
||||
description: |
|
||||
Retrieve audit log entries, newest first. Supports pagination and
|
||||
filtering. The log is append-only and never contains credentials.
|
||||
|
||||
Event types include: `login_ok`, `login_fail`, `login_totp_fail`,
|
||||
`token_issued`, `token_renewed`, `token_revoked`, `token_expired`,
|
||||
`account_created`, `account_updated`, `account_deleted`,
|
||||
`role_granted`, `role_revoked`, `totp_enrolled`, `totp_removed`,
|
||||
`pgcred_accessed`, `pgcred_updated`.
|
||||
operationId: listAudit
|
||||
tags: [Admin — Audit]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 50
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
example: 50
|
||||
- name: offset
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 0
|
||||
example: 0
|
||||
- name: event_type
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: Filter by event type.
|
||||
example: login_fail
|
||||
- name: actor_id
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Filter by actor account UUID.
|
||||
example: 550e8400-e29b-41d4-a716-446655440000
|
||||
responses:
|
||||
"200":
|
||||
description: Paginated audit log.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [events, total, limit, offset]
|
||||
properties:
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AuditEvent"
|
||||
total:
|
||||
type: integer
|
||||
description: Total number of matching events (for pagination).
|
||||
example: 142
|
||||
limit:
|
||||
type: integer
|
||||
example: 50
|
||||
offset:
|
||||
type: integer
|
||||
example: 0
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
|
||||
tags:
|
||||
- name: Public
|
||||
description: No authentication required.
|
||||
- name: Auth
|
||||
description: Requires a valid bearer token.
|
||||
- name: Admin — Auth
|
||||
description: Requires admin role.
|
||||
- name: Admin — Tokens
|
||||
description: Requires admin role.
|
||||
- name: Admin — Accounts
|
||||
description: Requires admin role.
|
||||
- name: Admin — Credentials
|
||||
description: Requires admin role.
|
||||
- name: Admin — Audit
|
||||
description: Requires admin role.
|
||||
23
web/static/docs.html
Normal file
23
web/static/docs.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MCIAS API Reference</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
SwaggerUIBundle({
|
||||
url: "/docs/openapi.yaml",
|
||||
dom_id: "#swagger-ui",
|
||||
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
|
||||
layout: "BaseLayout",
|
||||
deepLinking: true,
|
||||
persistAuthorization: true,
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,7 +3,7 @@
|
||||
hx-post="/login" hx-target="#login-form" hx-swap="outerHTML">
|
||||
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
|
||||
<input type="hidden" name="username" value="{{.Username}}">
|
||||
<input type="hidden" name="password" value="{{.Password}}">
|
||||
<input type="hidden" name="totp_nonce" value="{{.Nonce}}">
|
||||
<input type="hidden" name="totp_step" value="1">
|
||||
<div class="form-group">
|
||||
<label for="totp_code">Authenticator Code</label>
|
||||
|
||||
Reference in New Issue
Block a user