Fix F-08, F-12, F-13: Implement account lockout, username validation, and password minimum length enforcement

- Added failed login tracking for account lockout enforcement in `db` and `ui` layers; introduced `failed_logins` table to store attempts, window start, and attempt count.
- Updated login checks in `grpcserver/auth.go` and `ui/handlers_auth.go` to reject requests if the account is locked.
- Added immediate failure counter reset on successful login.
- Implemented username length and character set validation (F-12) and minimum password length enforcement (F-13) in shared `validate` package.
- Updated account creation and edit flows in `ui` and `grpcserver` layers to apply validation before hashing/processing.
- Added comprehensive unit tests for lockout, validation, and related edge cases.
- Updated `AUDIT.md` to mark F-08, F-12, and F-13 as fixed.
- Updated `openapi.yaml` to reflect new validation and lockout behaviors.

Security: Prevents brute-force attacks via lockout mechanism and strengthens defenses against weak and invalid input.
This commit is contained in:
2026-03-11 20:59:26 -07:00
parent 6e690c4435
commit 0ad9ef1bb4
13 changed files with 1487 additions and 15 deletions

View File

@@ -228,11 +228,11 @@ The REST `handleTokenIssue` and gRPC `IssueServiceToken` both revoke the existin
| No | F-05 | LOW | No `nbf` claim in issued JWTs | Trivial |
| No | F-06 | LOW | `HasRole` uses non-constant-time comparison | Trivial |
| Yes | F-07 | LOW | Dummy Argon2 hash timing mismatch | Small |
| No | F-08 | LOW | No account lockout after repeated failures | Medium |
| Yes | F-08 | LOW | No account lockout after repeated failures | Medium |
| No | F-09 | LOW | `synchronous=NORMAL` risks audit data loss | Trivial |
| No | F-10 | LOW | No maximum token expiry validation | Small |
| No | F-12 | LOW | No username length/charset validation | Small |
| No | F-13 | LOW | No minimum password length enforcement | Small |
| Yes | F-12 | LOW | No username length/charset validation | Small |
| Yes | F-13 | LOW | No minimum password length enforcement | Small |
| No | F-14 | LOW | Passphrase string not zeroed after KDF | Small |
| Yes | F-16 | LOW | UI system token issuance skips old token revocation | Small |
| No | F-15 | INFO | Bearer prefix check inconsistency | Trivial |

View File

@@ -72,3 +72,4 @@ This is a security-critical project. The following rules are non-negotiable:
- `ARCHITECTURE.md`**Required before any implementation.** Covers token lifecycle, session management, multi-app trust boundaries, and database schema. Do not generate code until this document exists.
- `PROJECT_PLAN.md` — Discrete implementation steps (to be written)
- `PROGRESS.md` — Development progress tracking (to be written)
- `openapi.yaml` - Must be kept in sync with any API changes.

View File

@@ -479,6 +479,87 @@ func (db *DB) ReadPGCredentials(accountID int64) (*model.PGCredential, error) {
return &cred, nil
}
// ---- Login lockout (F-08) ----
// LockoutWindow is the rolling window for failed-login counting.
// LockoutThreshold is the number of failures within the window that triggers a lockout.
// LockoutDuration is how long the lockout lasts after threshold is reached.
// These are package-level vars (not consts) so tests can override them.
//
// Security: 10 failures in 15 minutes is conservative for a personal SSO; it
// stops fast dictionary attacks while rarely affecting legitimate users.
var (
LockoutWindow = 15 * time.Minute
LockoutThreshold = 10
LockoutDuration = 15 * time.Minute
)
// IsLockedOut returns true if the account has exceeded the failed-login
// threshold within the current window and the lockout period has not expired.
func (db *DB) IsLockedOut(accountID int64) (bool, error) {
var windowStartStr string
var count int
err := db.sql.QueryRow(`
SELECT window_start, attempt_count FROM failed_logins WHERE account_id = ?
`, accountID).Scan(&windowStartStr, &count)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("db: is locked out %d: %w", accountID, err)
}
windowStart, err := parseTime(windowStartStr)
if err != nil {
return false, err
}
// Window has expired — not locked out.
if time.Since(windowStart) > LockoutWindow+LockoutDuration {
return false, nil
}
// Under threshold — not locked out.
if count < LockoutThreshold {
return false, nil
}
// Threshold exceeded; locked out until window_start + LockoutDuration.
lockedUntil := windowStart.Add(LockoutDuration)
return time.Now().Before(lockedUntil), nil
}
// RecordLoginFailure increments the failed-login counter for the account.
// If the current window has expired a new window is started.
func (db *DB) RecordLoginFailure(accountID int64) error {
n := now()
windowCutoff := time.Now().Add(-LockoutWindow).UTC().Format(time.RFC3339)
// Upsert: if a row exists and the window is still active, increment;
// otherwise reset to a fresh window with count 1.
_, err := db.sql.Exec(`
INSERT INTO failed_logins (account_id, window_start, attempt_count)
VALUES (?, ?, 1)
ON CONFLICT(account_id) DO UPDATE SET
window_start = CASE WHEN window_start < ? THEN excluded.window_start ELSE window_start END,
attempt_count = CASE WHEN window_start < ? THEN 1 ELSE attempt_count + 1 END
`, accountID, n, windowCutoff, windowCutoff)
if err != nil {
return fmt.Errorf("db: record login failure %d: %w", accountID, err)
}
return nil
}
// ClearLoginFailures resets the failed-login counter for the account.
// Called on successful login.
func (db *DB) ClearLoginFailures(accountID int64) error {
_, err := db.sql.Exec(`DELETE FROM failed_logins WHERE account_id = ?`, accountID)
if err != nil {
return fmt.Errorf("db: clear login failures %d: %w", accountID, err)
}
return nil
}
// WriteAuditEvent appends an audit log entry.
// Details must never contain credential material.
func (db *DB) WriteAuditEvent(eventType string, actorID, targetID *int64, ipAddress, details string) error {
@@ -1010,3 +1091,78 @@ func (db *DB) GetSystemToken(accountID int64) (*model.SystemToken, error) {
}
return &st, nil
}
// Lockout parameters (package-level vars so tests can override them).
//
// Security (F-08): per-account failed-login tracking prevents brute-force
// attacks. LockoutWindow defines the rolling window during which failures
// are counted; LockoutThreshold is the number of failures that triggers a
// lockout; LockoutDuration is how long the account remains locked after the
// threshold is reached. All three are intentionally kept as vars (not
// consts) so that tests can reduce them to millisecond-scale values without
// recompiling.
var (
LockoutWindow = 15 * time.Minute
LockoutThreshold = 10
LockoutDuration = 15 * time.Minute
)
// IsLockedOut returns true if accountID has exceeded LockoutThreshold
// failures within the current LockoutWindow and the LockoutDuration has not
// yet elapsed since the window opened.
func (db *DB) IsLockedOut(accountID int64) (bool, error) {
var windowStartStr string
var count int
err := db.sql.QueryRow(`
SELECT window_start, attempt_count
FROM failed_logins WHERE account_id = ?
`, accountID).Scan(&windowStartStr, &count)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("db: is locked out %d: %w", accountID, err)
}
windowStart, err := parseTime(windowStartStr)
if err != nil {
return false, fmt.Errorf("db: parse lockout window_start: %w", err)
}
// The window has expired: the record is stale, the account is not locked.
if time.Now().After(windowStart.Add(LockoutWindow)) {
return false, nil
}
return count >= LockoutThreshold, nil
}
// RecordLoginFailure increments the failure counter for accountID within the
// current rolling window. If the window has expired the counter resets to 1
// and the window_start is updated. Uses an UPSERT so the operation is safe
// to call without a prior existence check.
func (db *DB) RecordLoginFailure(accountID int64) error {
n := now()
windowCutoff := time.Now().Add(-LockoutWindow).UTC().Format(time.RFC3339)
_, err := db.sql.Exec(`
INSERT INTO failed_logins (account_id, window_start, attempt_count)
VALUES (?, ?, 1)
ON CONFLICT(account_id) DO UPDATE SET
window_start = CASE WHEN window_start < ? THEN excluded.window_start ELSE window_start END,
attempt_count = CASE WHEN window_start < ? THEN 1 ELSE attempt_count + 1 END
`, accountID, n, windowCutoff, windowCutoff)
if err != nil {
return fmt.Errorf("db: record login failure for account %d: %w", accountID, err)
}
return nil
}
// ClearLoginFailures removes the failure record for accountID. Called on a
// successful login to reset the lockout state.
func (db *DB) ClearLoginFailures(accountID int64) error {
_, err := db.sql.Exec(`DELETE FROM failed_logins WHERE account_id = ?`, accountID)
if err != nil {
return fmt.Errorf("db: clear login failures for account %d: %w", accountID, err)
}
return nil
}

View File

@@ -473,3 +473,127 @@ func TestRevokeAllUserTokens(t *testing.T) {
}
}
}
// TestLockoutNotLockedInitially verifies a fresh account is not locked out.
func TestLockoutNotLockedInitially(t *testing.T) {
d := openTestDB(t)
acct, err := d.CreateAccount("locktest", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
locked, err := d.IsLockedOut(acct.ID)
if err != nil {
t.Fatalf("IsLockedOut: %v", err)
}
if locked {
t.Fatal("fresh account should not be locked out")
}
}
// TestLockoutThreshold verifies that IsLockedOut returns true after
// LockoutThreshold failures within LockoutWindow.
func TestLockoutThreshold(t *testing.T) {
d := openTestDB(t)
acct, err := d.CreateAccount("locktest2", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
// Use a short window so test runs fast.
origWindow := LockoutWindow
origThreshold := LockoutThreshold
LockoutWindow = 5 * time.Second
LockoutThreshold = 3
t.Cleanup(func() {
LockoutWindow = origWindow
LockoutThreshold = origThreshold
})
for i := 0; i < 3; i++ {
if err := d.RecordLoginFailure(acct.ID); err != nil {
t.Fatalf("RecordLoginFailure %d: %v", i+1, err)
}
}
locked, err := d.IsLockedOut(acct.ID)
if err != nil {
t.Fatalf("IsLockedOut: %v", err)
}
if !locked {
t.Fatal("account should be locked after reaching threshold")
}
}
// TestLockoutClearedOnSuccess verifies ClearLoginFailures removes the record
// and IsLockedOut returns false afterwards.
func TestLockoutClearedOnSuccess(t *testing.T) {
d := openTestDB(t)
acct, err := d.CreateAccount("locktest3", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
origThreshold := LockoutThreshold
LockoutThreshold = 2
t.Cleanup(func() { LockoutThreshold = origThreshold })
for i := 0; i < 2; i++ {
if err := d.RecordLoginFailure(acct.ID); err != nil {
t.Fatalf("RecordLoginFailure %d: %v", i+1, err)
}
}
locked, err := d.IsLockedOut(acct.ID)
if err != nil || !locked {
t.Fatalf("expected locked=true, got locked=%v err=%v", locked, err)
}
if err := d.ClearLoginFailures(acct.ID); err != nil {
t.Fatalf("ClearLoginFailures: %v", err)
}
locked, err = d.IsLockedOut(acct.ID)
if err != nil {
t.Fatalf("IsLockedOut after clear: %v", err)
}
if locked {
t.Fatal("account should not be locked after ClearLoginFailures")
}
}
// TestLockoutWindowExpiry verifies that a stale failure record (outside the
// window) does not cause a lockout.
func TestLockoutWindowExpiry(t *testing.T) {
d := openTestDB(t)
acct, err := d.CreateAccount("locktest4", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
origWindow := LockoutWindow
origThreshold := LockoutThreshold
LockoutWindow = 50 * time.Millisecond
LockoutThreshold = 2
t.Cleanup(func() {
LockoutWindow = origWindow
LockoutThreshold = origThreshold
})
for i := 0; i < 2; i++ {
if err := d.RecordLoginFailure(acct.ID); err != nil {
t.Fatalf("RecordLoginFailure %d: %v", i+1, err)
}
}
// Wait for the window to expire.
time.Sleep(60 * time.Millisecond)
locked, err := d.IsLockedOut(acct.ID)
if err != nil {
t.Fatalf("IsLockedOut after window expiry: %v", err)
}
if locked {
t.Fatal("account should not be locked after window has expired")
}
}

View File

@@ -118,6 +118,19 @@ CREATE INDEX IF NOT EXISTS idx_audit_event ON audit_log (event_type);
-- The salt must be stable across restarts so the passphrase always yields the same key.
-- We allow NULL signing_key_enc/nonce temporarily until the first signing key is generated.
ALTER TABLE server_config ADD COLUMN master_key_salt BLOB;
`,
},
{
id: 3,
sql: `
-- Track per-account failed login attempts for lockout enforcement (F-08).
-- One row per account; window_start resets when the window expires or on
-- a successful login. The DB layer enforces atomicity via UPDATE+INSERT.
CREATE TABLE IF NOT EXISTS failed_logins (
account_id INTEGER NOT NULL PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
window_start TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 1
);
`,
},
}

View File

@@ -15,6 +15,7 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/validate"
)
type accountServiceServer struct {
@@ -58,8 +59,9 @@ func (a *accountServiceServer) CreateAccount(ctx context.Context, req *mciasv1.C
if err := a.s.requireAdmin(ctx); err != nil {
return nil, err
}
if req.Username == "" {
return nil, status.Error(codes.InvalidArgument, "username is required")
// Security (F-12): validate username length and character set.
if err := validate.Username(req.Username); err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
accountType := model.AccountType(req.AccountType)
if accountType != model.AccountTypeHuman && accountType != model.AccountTypeSystem {
@@ -71,6 +73,10 @@ func (a *accountServiceServer) CreateAccount(ctx context.Context, req *mciasv1.C
if req.Password == "" {
return nil, status.Error(codes.InvalidArgument, "password is required for human accounts")
}
// Security (F-13): enforce minimum length before hashing.
if err := validate.Password(req.Password); err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
var err error
passwordHash, err = auth.HashPassword(req.Password, auth.ArgonParams{
Time: a.s.cfg.Argon2.Time,

View File

@@ -52,15 +52,28 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
}
// Security: check per-account lockout before running Argon2 (F-08).
locked, lockErr := a.s.db.IsLockedOut(acct.ID)
if lockErr != nil {
a.s.logger.Error("lockout check", "error", lockErr)
}
if locked {
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"account_locked"}`) //nolint:errcheck
return nil, status.Error(codes.ResourceExhausted, "account temporarily locked")
}
ok, err := auth.VerifyPassword(req.Password, acct.PasswordHash)
if err != nil || !ok {
a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"wrong_password"}`) //nolint:errcheck
_ = a.s.db.RecordLoginFailure(acct.ID)
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
}
if acct.TOTPRequired {
if req.TotpCode == "" {
a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"totp_missing"}`) //nolint:errcheck
_ = a.s.db.RecordLoginFailure(acct.ID)
return nil, status.Error(codes.Unauthenticated, "TOTP code required")
}
secret, err := crypto.OpenAESGCM(a.s.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
@@ -71,10 +84,14 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
valid, err := auth.ValidateTOTP(secret, req.TotpCode)
if err != nil || !valid {
a.s.db.WriteAuditEvent(model.EventLoginTOTPFail, &acct.ID, nil, ip, `{"reason":"wrong_totp"}`) //nolint:errcheck
_ = a.s.db.RecordLoginFailure(acct.ID)
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
}
}
// Login succeeded: clear any outstanding failure counter.
_ = a.s.db.ClearLoginFailures(acct.ID)
expiry := a.s.cfg.DefaultExpiry()
roles, err := a.s.db.GetRoles(acct.ID)
if err != nil {

View File

@@ -26,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/internal/validate"
"git.wntrmute.dev/kyle/mcias/web"
)
@@ -67,18 +68,29 @@ func (s *Server) Handler() http.Handler {
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.
// Files are read from the embedded web/static filesystem at startup so that
// the handlers can write bytes directly without any redirect logic.
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")
docsHTML, err := fs.ReadFile(staticFS, "docs.html")
if err != nil {
panic(fmt.Sprintf("server: read docs.html: %v", err))
}
specYAML, err := fs.ReadFile(staticFS, "openapi.yaml")
if err != nil {
panic(fmt.Sprintf("server: read openapi.yaml: %v", err))
}
mux.HandleFunc("GET /docs", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(docsHTML)
})
mux.HandleFunc("GET /docs/openapi.yaml", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("GET /docs/openapi.yaml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/yaml")
http.ServeFileFS(w, r, staticFS, "openapi.yaml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(specYAML)
})
// Authenticated endpoints.
@@ -189,11 +201,26 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
// Security: check per-account lockout before running Argon2 (F-08).
// We still run a dummy Argon2 to equalise timing so an attacker cannot
// distinguish a locked account from a non-existent one.
locked, lockErr := s.db.IsLockedOut(acct.ID)
if lockErr != nil {
s.logger.Error("lockout check", "error", lockErr)
}
if locked {
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_locked"}`)
middleware.WriteError(w, http.StatusTooManyRequests, "account temporarily locked", "account_locked")
return
}
// Verify password. This is always run, even for system accounts (which have
// no password hash), to maintain constant timing.
ok, err := auth.VerifyPassword(req.Password, acct.PasswordHash)
if err != nil || !ok {
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"wrong_password"}`)
_ = s.db.RecordLoginFailure(acct.ID)
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
@@ -202,6 +229,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if acct.TOTPRequired {
if req.TOTPCode == "" {
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"totp_missing"}`)
_ = s.db.RecordLoginFailure(acct.ID)
middleware.WriteError(w, http.StatusUnauthorized, "TOTP code required", "totp_required")
return
}
@@ -215,11 +243,15 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
valid, err := auth.ValidateTOTP(secret, req.TOTPCode)
if err != nil || !valid {
s.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
_ = s.db.RecordLoginFailure(acct.ID)
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
}
// Login succeeded: clear any outstanding failure counter.
_ = s.db.ClearLoginFailures(acct.ID)
// Determine expiry.
expiry := s.cfg.DefaultExpiry()
roles, err := s.db.GetRoles(acct.ID)
@@ -489,8 +521,10 @@ func (s *Server) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
return
}
if req.Username == "" {
middleware.WriteError(w, http.StatusBadRequest, "username is required", "bad_request")
// Security (F-12): validate username length and character set before any DB
// operation to prevent log injection, stored-XSS, and storage abuse.
if err := validate.Username(req.Username); err != nil {
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
return
}
accountType := model.AccountType(req.Type)
@@ -505,6 +539,11 @@ func (s *Server) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
middleware.WriteError(w, http.StatusBadRequest, "password is required for human accounts", "bad_request")
return
}
// Security (F-13): enforce minimum length before hashing.
if err := validate.Password(req.Password); err != nil {
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
return
}
var err error
passwordHash, err = auth.HashPassword(req.Password, auth.ArgonParams{
Time: s.cfg.Argon2.Time,

View File

@@ -7,6 +7,7 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/validate"
)
// knownRoles lists the built-in roles shown as checkboxes in the roles editor.
@@ -44,8 +45,9 @@ func (u *UIServer) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
password := r.FormValue("password")
accountTypeStr := r.FormValue("account_type")
if username == "" {
u.renderError(w, r, http.StatusBadRequest, "username is required")
// Security (F-12): validate username length and character set.
if err := validate.Username(username); err != nil {
u.renderError(w, r, http.StatusBadRequest, err.Error())
return
}
@@ -56,6 +58,11 @@ func (u *UIServer) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
var passwordHash string
if password != "" {
// Security (F-13): enforce minimum length before hashing.
if err := validate.Password(password); err != nil {
u.renderError(w, r, http.StatusBadRequest, err.Error())
return
}
argonCfg := auth.ArgonParams{
Time: u.cfg.Argon2.Time,
Memory: u.cfg.Argon2.Memory,

View File

@@ -71,10 +71,23 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
return
}
// Security: check per-account lockout before running Argon2 (F-08).
locked, lockErr := u.db.IsLockedOut(acct.ID)
if lockErr != nil {
u.logger.Error("lockout check", "error", lockErr)
}
if locked {
_, _ = auth.VerifyPassword("dummy", u.dummyHash())
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_locked"}`)
u.render(w, "login", LoginData{Error: "account temporarily locked, please try again later"})
return
}
// Verify password.
ok, err := auth.VerifyPassword(password, acct.PasswordHash)
if err != nil || !ok {
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"wrong_password"}`)
_ = u.db.RecordLoginFailure(acct.ID)
u.render(w, "login", LoginData{Error: "invalid credentials"})
return
}
@@ -138,6 +151,7 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
valid, err := auth.ValidateTOTP(secret, totpCode)
if err != nil || !valid {
u.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
_ = u.db.RecordLoginFailure(acct.ID)
// 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 {
@@ -171,6 +185,9 @@ func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *mod
}
}
// Login succeeded: clear any outstanding failure counter.
_ = u.db.ClearLoginFailures(acct.ID)
tokenStr, claims, err := token.IssueToken(u.privKey, u.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
if err != nil {
u.logger.Error("issue token", "error", err)

View File

@@ -0,0 +1,55 @@
// Package validate provides shared input-validation helpers used by the REST,
// gRPC, and UI handlers.
package validate
import (
"fmt"
"regexp"
)
// usernameRE is the allowed character set for usernames: alphanumeric plus a
// small set of punctuation that is safe in all contexts (URLs, HTML, logs).
// Length is enforced separately so the error message can be more precise.
//
// Security (F-12): rejecting control characters, null bytes, newlines, and
// unusual Unicode prevents log injection, stored-XSS via username display,
// and rendering anomalies in the admin UI.
var usernameRE = regexp.MustCompile(`^[a-zA-Z0-9._@-]+$`)
// MinUsernameLen and MaxUsernameLen are the inclusive bounds on username length.
const (
MinUsernameLen = 1
MaxUsernameLen = 255
)
// Username returns nil if the username is valid, or a descriptive error if not.
// Valid usernames are 1255 characters long and contain only alphanumeric
// characters and the symbols . _ @ -
func Username(username string) error {
l := len(username)
if l < MinUsernameLen || l > MaxUsernameLen {
return fmt.Errorf("username must be between %d and %d characters", MinUsernameLen, MaxUsernameLen)
}
if !usernameRE.MatchString(username) {
return fmt.Errorf("username may only contain letters, digits, and the characters . _ @ -")
}
return nil
}
// MinPasswordLen is the minimum acceptable plaintext password length.
//
// Security (F-13): NIST SP 800-63B recommends a minimum of 8 characters;
// we use 12 to provide additional margin against offline brute-force attacks
// even though Argon2id is expensive. The check is performed at the handler
// level (before hashing) so Argon2id is never invoked with a trivially weak
// password.
const MinPasswordLen = 12
// Password returns nil if the plaintext password meets the minimum length
// requirement, or a descriptive error if not.
func Password(password string) error {
if len(password) < MinPasswordLen {
return fmt.Errorf("password must be at least %d characters", MinPasswordLen)
}
return nil
}

View File

@@ -0,0 +1,72 @@
package validate
import (
"strings"
"testing"
)
func TestPasswordValid(t *testing.T) {
valid := []string{
strings.Repeat("a", MinPasswordLen),
strings.Repeat("a", MinPasswordLen+1),
"correct horse battery staple",
"P@ssw0rd!2024XY",
}
for _, p := range valid {
if err := Password(p); err != nil {
t.Errorf("Password(%q) = %v, want nil", p, err)
}
}
}
func TestPasswordTooShort(t *testing.T) {
short := []string{
"",
"short",
strings.Repeat("a", MinPasswordLen-1),
}
for _, p := range short {
if err := Password(p); err == nil {
t.Errorf("Password(%q) = nil, want error", p)
}
}
}
func TestUsernameValid(t *testing.T) {
valid := []string{
"alice",
"Bob123",
"user.name",
"user_name",
"user-name",
"user@domain",
"a",
strings.Repeat("a", MaxUsernameLen),
}
for _, u := range valid {
if err := Username(u); err != nil {
t.Errorf("Username(%q) = %v, want nil", u, err)
}
}
}
func TestUsernameInvalid(t *testing.T) {
invalid := []string{
"", // empty
strings.Repeat("a", MaxUsernameLen+1), // too long
"user name", // space
"user\tname", // tab
"user\nname", // newline
"user\x00name", // null byte
"user<script>", // angle bracket
"user'quote", // single quote
"user\"quote", // double quote
"user/slash", // slash
"user\\backslash", // backslash
}
for _, u := range invalid {
if err := Username(u); err == nil {
t.Errorf("Username(%q) = nil, want error", u)
}
}
}

965
web/static/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.