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 |
|
| Fixed? | ID | Severity | Title | Effort |
|
||||||
|--------|----|----------|-------|--------|
|
|--------|----|----------|-------|--------|
|
||||||
| Yes | F-01 | MEDIUM | TOTP enrollment sets required=1 before confirmation | Small |
|
| 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-03 | MEDIUM | Token renewal not atomic (race window) | Small |
|
||||||
| Yes | F-04 | MEDIUM | Rate limiter not applied to REST login endpoint | 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 |
|
| 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))
|
`, 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).
|
// GetAccountByUsername retrieves an account by username (case-insensitive).
|
||||||
// Returns ErrNotFound if no matching account exists.
|
// Returns ErrNotFound if no matching account exists.
|
||||||
func (db *DB) GetAccountByUsername(username string) (*model.Account, error) {
|
func (db *DB) GetAccountByUsername(username string) (*model.Account, error) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/ui"
|
"git.wntrmute.dev/kyle/mcias/internal/ui"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server holds the dependencies injected into all handlers.
|
// 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/auth/login", loginRateLimit(http.HandlerFunc(s.handleLogin)))
|
||||||
mux.Handle("POST /v1/token/validate", loginRateLimit(http.HandlerFunc(s.handleTokenValidate)))
|
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.
|
// Authenticated endpoints.
|
||||||
requireAuth := middleware.RequireAuth(s.pubKey, s.db, s.cfg.Tokens.Issuer)
|
requireAuth := middleware.RequireAuth(s.pubKey, s.db, s.cfg.Tokens.Issuer)
|
||||||
requireAdmin := func(h http.Handler) http.Handler {
|
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{})
|
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:
|
// Security design (F-02 fix):
|
||||||
// - Password is verified via Argon2id on every request, including the TOTP
|
// - Step 1: username+password submitted. Password verified via Argon2id.
|
||||||
// second step, to prevent credential-bypass by jumping to TOTP directly.
|
// On success with TOTP required, a 90-second single-use server-side nonce
|
||||||
// - Timing is held constant regardless of whether the account exists, by
|
// is issued and its account ID stored in pendingLogins. Only the nonce
|
||||||
// always running a dummy Argon2 check for unknown accounts.
|
// (not the password) is embedded in the TOTP step HTML form, so the
|
||||||
// - On TOTP required: returns the totp_step fragment (200) so HTMX swaps the
|
// plaintext password is never sent over the wire a second time and never
|
||||||
// form in place. The username and password are included as hidden fields;
|
// appears in the DOM during the TOTP step.
|
||||||
// they are re-verified on the TOTP submission.
|
// - Step 2: totp_step=1 form submitted. The nonce is consumed (single-use,
|
||||||
// - On success: issues a JWT, stores it as an HttpOnly session cookie, sets
|
// expiry checked) to retrieve the account ID; no password is needed.
|
||||||
// CSRF tokens, then redirects via HX-Redirect (HTMX) or 302 (browser).
|
// 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) {
|
func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||||
if err := r.ParseForm(); err != nil {
|
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
|
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")
|
username := r.FormValue("username")
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
totpCode := r.FormValue("totp_code")
|
|
||||||
|
|
||||||
if username == "" || password == "" {
|
if username == "" || password == "" {
|
||||||
u.render(w, "login", LoginData{Error: "username and password are required"})
|
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)
|
acct, err := u.db.GetAccountByUsername(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Security: always run dummy Argon2 to prevent timing-based user enumeration.
|
// 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,
|
u.writeAudit(r, model.EventLoginFail, nil, nil,
|
||||||
fmt.Sprintf(`{"username":%q,"reason":"unknown_user"}`, username))
|
fmt.Sprintf(`{"username":%q,"reason":"unknown_user"}`, username))
|
||||||
u.render(w, "login", LoginData{Error: "invalid credentials"})
|
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.
|
// Security: check account status before credential verification.
|
||||||
if acct.Status != model.AccountStatusActive {
|
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.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_inactive"}`)
|
||||||
u.render(w, "login", LoginData{Error: "invalid credentials"})
|
u.render(w, "login", LoginData{Error: "invalid credentials"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify password. Always run even if TOTP step, to prevent bypass.
|
// Verify password.
|
||||||
ok, err := auth.VerifyPassword(password, acct.PasswordHash)
|
ok, err := auth.VerifyPassword(password, acct.PasswordHash)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"wrong_password"}`)
|
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"wrong_password"}`)
|
||||||
@@ -70,18 +79,55 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 acct.TOTPRequired {
|
||||||
if totpCode == "" {
|
nonce, err := u.issueTOTPNonce(acct.ID)
|
||||||
// Return TOTP step fragment so HTMX swaps the form.
|
if err != nil {
|
||||||
|
u.logger.Error("issue TOTP nonce", "error", err)
|
||||||
|
u.render(w, "login", LoginData{Error: "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
u.render(w, "totp_step", LoginData{
|
u.render(w, "totp_step", LoginData{
|
||||||
Username: username,
|
Username: username,
|
||||||
// Security: password is embedded as a hidden form field so the
|
Nonce: nonce,
|
||||||
// second submission can re-verify it. It is never logged.
|
|
||||||
Password: password,
|
|
||||||
})
|
})
|
||||||
return
|
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.
|
// Decrypt and validate TOTP secret.
|
||||||
secret, err := crypto.OpenAESGCM(u.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
secret, err := crypto.OpenAESGCM(u.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -92,15 +138,25 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
valid, err := auth.ValidateTOTP(secret, totpCode)
|
valid, err := auth.ValidateTOTP(secret, totpCode)
|
||||||
if err != nil || !valid {
|
if err != nil || !valid {
|
||||||
u.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
|
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{
|
u.render(w, "totp_step", LoginData{
|
||||||
Error: "invalid TOTP code",
|
Error: "invalid TOTP code",
|
||||||
Username: username,
|
Username: username,
|
||||||
Password: password,
|
Nonce: newNonce,
|
||||||
})
|
})
|
||||||
return
|
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.
|
// Determine token expiry based on admin role.
|
||||||
expiry := u.cfg.DefaultExpiry()
|
expiry := u.cfg.DefaultExpiry()
|
||||||
roles, err := u.db.GetRoles(acct.ID)
|
roles, err := u.db.GetRoles(acct.ID)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
@@ -22,6 +24,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||||
@@ -33,20 +36,75 @@ import (
|
|||||||
const (
|
const (
|
||||||
sessionCookieName = "mcias_session"
|
sessionCookieName = "mcias_session"
|
||||||
csrfCookieName = "mcias_csrf"
|
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.
|
// UIServer serves the HTMX-based management UI.
|
||||||
type UIServer struct {
|
type UIServer struct {
|
||||||
|
pendingLogins sync.Map // nonce (string) → *pendingLogin
|
||||||
|
tmpls map[string]*template.Template // page name → template set
|
||||||
db *db.DB
|
db *db.DB
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
csrf *CSRFManager
|
csrf *CSRFManager
|
||||||
tmpls map[string]*template.Template // page name → template set
|
|
||||||
pubKey ed25519.PublicKey
|
pubKey ed25519.PublicKey
|
||||||
privKey ed25519.PrivateKey
|
privKey ed25519.PrivateKey
|
||||||
masterKey []byte
|
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.
|
// New constructs a UIServer, parses all templates, and returns it.
|
||||||
// Returns an error if template parsing fails.
|
// Returns an error if template parsing fails.
|
||||||
func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed25519.PublicKey, masterKey []byte, logger *slog.Logger) (*UIServer, error) {
|
func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed25519.PublicKey, masterKey []byte, logger *slog.Logger) (*UIServer, error) {
|
||||||
@@ -423,7 +481,10 @@ type PageData struct {
|
|||||||
type LoginData struct {
|
type LoginData struct {
|
||||||
Error string
|
Error string
|
||||||
Username string // pre-filled on TOTP step
|
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.
|
// DashboardData is the view model for the dashboard page.
|
||||||
|
|||||||
@@ -9,16 +9,17 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testIssuer = "https://auth.example.com"
|
const testIssuer = "https://auth.example.com"
|
||||||
|
|
||||||
// newTestMux creates a UIServer and returns the http.Handler used in production
|
// newTestUIServer creates a UIServer backed by an in-memory DB.
|
||||||
// (a ServeMux with all UI routes registered, wrapped with securityHeaders).
|
func newTestUIServer(t *testing.T) *UIServer {
|
||||||
func newTestMux(t *testing.T) http.Handler {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
@@ -47,7 +48,14 @@ func newTestMux(t *testing.T) http.Handler {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("new UIServer: %v", err)
|
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()
|
mux := http.NewServeMux()
|
||||||
uiSrv.Register(mux)
|
uiSrv.Register(mux)
|
||||||
return mux
|
return mux
|
||||||
@@ -181,3 +189,113 @@ func TestSecurityHeadersMiddlewareUnit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assertSecurityHeaders(t, rr.Result().Header, "unit test")
|
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">
|
hx-post="/login" hx-target="#login-form" hx-swap="outerHTML">
|
||||||
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
|
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
|
||||||
<input type="hidden" name="username" value="{{.Username}}">
|
<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">
|
<input type="hidden" name="totp_step" value="1">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="totp_code">Authenticator Code</label>
|
<label for="totp_code">Authenticator Code</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user