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:
2026-03-11 20:33:04 -07:00
parent bf9002a31c
commit d42f51fc83
10 changed files with 1877 additions and 54 deletions

View File

@@ -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
View 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.

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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.

View File

@@ -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
View 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
View 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>

View File

@@ -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>