Files
LocalAI/core/http/routes/auth.go
Richard Palethorpe 670259ce43 chore: Security hardening (#9719)
* fix(http): close 0.0.0.0/[::] SSRF bypass in /api/cors-proxy

The CORS proxy carried its own private-network blocklist (RFC 1918 + a
handful of IPv6 ranges) instead of using the same classification as
pkg/utils/urlfetch.go. The hand-rolled list missed 0.0.0.0/8 and ::/128,
both of which Linux routes to localhost — so any user with FeatureMCP
(default-on for new users) could reach LocalAI's own listener and any
other service bound to 0.0.0.0:port via:

  GET /api/cors-proxy?url=http://0.0.0.0:8080/...
  GET /api/cors-proxy?url=http://[::]:8080/...

Replace the custom check with utils.IsPublicIP (Go stdlib IsLoopback /
IsLinkLocalUnicast / IsPrivate / IsUnspecified, plus IPv4-mapped IPv6
unmasking) and add an upfront hostname rejection for localhost, *.local,
and the cloud metadata aliases so split-horizon DNS can't paper over the
IP check.

The IP-pinning DialContext is unchanged: the validated IP from the
single resolution is reused for the connection, so DNS rebinding still
cannot swap a public answer for a private one between validate and dial.

Regression tests cover 0.0.0.0, 0.0.0.0:PORT, [::], ::ffff:127.0.0.1,
::ffff:10.0.0.1, file://, gopher://, ftp://, localhost, 127.0.0.1,
10.0.0.1, 169.254.169.254, metadata.google.internal.

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(downloader): verify SHA before promoting temp file to final path

DownloadFileWithContext renamed the .partial file to its final name
*before* checking the streamed SHA, so a hash mismatch returned an
error but left the tampered file at filePath. Subsequent code that
operated on filePath (a backend launcher, a YAML loader, a re-download
that finds the file already present and skips) would consume the
attacker-supplied bytes.

Reorder: verify the streamed hash first, remove the .partial on
mismatch, then rename. The streamed hash is computed during io.Copy
so no second read is needed.

While here, raise the empty-SHA case from a Debug log to a Warn so
"this download had no integrity check" is visible at the default log
level. Backend installs currently pass through with no digest; the
warning makes that footprint observable without changing behaviour.

Regression test asserts os.IsNotExist on the destination after a
deliberate SHA mismatch.

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(auth): require email_verified for OIDC admin promotion

extractOIDCUserInfo read the ID token's "email" claim but never
inspected "email_verified". With LOCALAI_ADMIN_EMAIL set, an attacker
who could register on the configured OIDC IdP under that email (some
IdPs accept self-supplied unverified emails) inherited admin role:

  - first login:  AssignRole(tx, email, adminEmail) → RoleAdmin
  - re-login:     MaybePromote(db, user, adminEmail) → flip to RoleAdmin

Add EmailVerified to oauthUserInfo, parse email_verified from the OIDC
claims (default false on absence so an IdP that omits the claim cannot
short-circuit the gate), and substitute "" for the role-decision email
when verified=false via emailForRoleDecision. The user record still
stores the unverified email for display.

GitHub's path defaults EmailVerified=true: GitHub only returns a public
profile email after verification, and fetchGitHubPrimaryEmail explicitly
filters to Verified=true.

Regression tests cover both the helper contract and integration with
AssignRole, including the bootstrap "first user" branch that would
otherwise mask the gate.

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(cli): refuse public bind when no auth backend is configured

When neither an auth DB nor a static API key is set, the auth
middleware passes every request through. That is fine for a developer
laptop, a home LAN, or a Tailnet — the network itself is the trust
boundary. It is not fine on a public IP, where every model install,
settings change, and admin endpoint becomes reachable from the
internet.

Refuse to start in that exact configuration. Loopback, RFC 1918,
RFC 4193 ULA, link-local, and RFC 6598 CGNAT (Tailscale's default
range) all count as trusted; wildcard binds (`:port`, `0.0.0.0`,
`[::]`) are accepted only when every host interface is in one of those
ranges. Hostnames are resolved and treated as trusted only when every
answer is.

A new --allow-insecure-public-bind / LOCALAI_ALLOW_INSECURE_PUBLIC_BIND
flag opts out for deployments that gate access externally (a reverse
proxy enforcing auth, a mesh ACL, etc.). The error message lists this
plus the three constructive alternatives (bind a private interface,
enable --auth, set --api-keys).

The interface enumeration goes through a package-level interfaceAddrsFn
var so tests can simulate cloud-VM, home-LAN, Tailscale-only, and
enumeration-failure topologies without poking at the real network
stack.

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* test(http): regression-test the localai_assistant admin gate

ChatEndpoint already rejects metadata.localai_assistant=true from a
non-admin caller, but the gate was open-coded inline with no direct
test coverage. The chat route is FeatureChat-gated (default-on), and
the assistant's in-process MCP server can install/delete models and
edit configs — the wrong handler change would silently turn the LLM
into a confused deputy.

Extract the gate into requireAssistantAccess(c, authEnabled) and pin
its behaviour: auth disabled is a no-op, unauthenticated is 403,
RoleUser is 403, RoleAdmin and the synthetic legacy-key admin are
admitted.

No behaviour change in the production path.

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* test(http): assert every API route is auth-classified

The auth middleware classifies path prefixes (/api/, /v1/, /models/,
etc.) as protected and treats anything else as a static-asset
passthrough. A new endpoint shipped under a brand-new prefix — or a
new path that simply isn't on the prefix allowlist — would be
reachable anonymously.

Walk every route registered by API() with auth enabled and a fresh
in-memory database (no users, no keys), and assert each API-prefixed
route returns 401 / 404 / 405 to an anonymous request. Public surfaces
(/api/auth/*, /api/branding, /api/node/* token-authenticated routes,
/healthz, branding asset server, generated-content server, static
assets) are explicit allowlist entries with comments justifying them.

Build-tagged 'auth' so it runs against the SQLite-backed auth DB
(matches the existing auth suite).

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* test(http): pin agent endpoint per-user isolation contract

agents.go's getUserID / effectiveUserID / canImpersonateUser /
wantsAllUsers helpers are the single trust boundary for cross-user
access on agent, agent-jobs, collections, and skills routes. A
regression there is the difference between "regular user reads their
own data" and "regular user reads anyone's data via ?user_id=victim".

Lock in the contract:
  - effectiveUserID ignores ?user_id= for unauthenticated and RoleUser
  - effectiveUserID honours it for RoleAdmin and ProviderAgentWorker
  - wantsAllUsers requires admin AND the literal "true" string
  - canImpersonateUser is admin OR agent-worker, never plain RoleUser

No production change — this commit only adds tests.

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(downloader): drop redundant stat in removePartialFile

The stat-then-remove pattern is a TOCTOU window and a wasted syscall —
os.Remove already returns ErrNotExist for the missing-file case, so trust
that and treat it as a no-op.

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(http): redact secrets from trace buffer and distribution-token logs

The /api/traces buffer captured Authorization, Cookie, Set-Cookie, and
API-key headers verbatim from every request when tracing was enabled. The
endpoint is admin-only but the buffer is reachable via any heap-style
introspection and the captured tokens otherwise outlive the request.
Strip those header values at capture time. Body redaction is left to a
follow-up — the prompts are usually the operator's own and JSON-walking
is invasive.

Distribution tokens were also logged in plaintext from
core/explorer/discovery.go; logs forward to syslog/journald and outlive
the token. Redact those to a short prefix/suffix instead.

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(auth): rate-limit OAuth callbacks separately from password endpoints

The shared 5/min/IP limit on auth endpoints is right for password-style
flows but too tight for OAuth callbacks: corporate SSO funnels many real
users through one outbound IP and would trip the limit. Add a separate
60/min/IP limiter for /api/auth/{github,oidc}/callback so callbacks are
bounded against floods without breaking shared-IP deployments.

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(gallery): verify backend tarball sha256 when set in gallery entry

GalleryBackend gained an optional sha256 field; the install path now
threads it through to the existing downloader hash-verify (which already
streams, verifies, and rolls back on mismatch). Galleries without sha256
keep working; the empty-SHA path still emits the existing
"downloading without integrity check" warning.

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* test(http): pin CSRF coverage on multipart endpoints

The CSRF middleware in app.go is global (e.Use) so it covers every
multipart upload route — branding assets, fine-tune datasets, audio
transforms, agent collections. Pin that contract: cross-site multipart
POSTs are rejected; same-origin / same-site / API-key clients are not.
Also pins the SameSite=Lax fallback path the skipper relies on when
Sec-Fetch-Site is absent.

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(http): XSS hardening — CSP headers, safe href, base-href escape, SVG sandbox

Several closely related XSS-prevention changes spanning the SPA shell, the
React UI, and the branding asset server:

- New SecurityHeaders middleware sets CSP, X-Content-Type-Options,
  X-Frame-Options, and Referrer-Policy on every response. The CSP keeps
  script-src permissive because the Vite bundle relies on inline + eval'd
  scripts; tightening that requires moving to a nonce-based policy.

- The <base href> injection in the SPA shell escaped attacker-controllable
  Host / X-Forwarded-Host headers — a single quote in the host header
  broke out of the attribute. Pass through SecureBaseHref (html.EscapeString).

- Three React sinks rendering untrusted content via dangerouslySetInnerHTML
  switch to text-node rendering with whiteSpace: pre-wrap: user message
  bodies in Chat.jsx and AgentChat.jsx, and the agent activity log in
  AgentChat.jsx. The hand-rolled escape on the agent user-message variant
  is replaced by the same plain-text path.

- New safeHref util collapses non-allowlisted URI schemes (most
  importantly javascript:) to '#'. Applied to gallery `<a href={url}>`
  links in Models / Backends / Manage and to canvas artifact links —
  these come from gallery JSON or assistant tool calls and must be treated
  as untrusted.

- The branding asset server attaches a sandbox CSP plus same-origin CORP
  to .svg responses. The React UI loads logos via <img>, but the same URL
  is also reachable via direct navigation; this prevents script
  execution if a hostile SVG slipped past upload validation.

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(http): bound HTTP server with read-header and idle timeouts

A net/http server with no timeouts is trivially Slowloris-able and leaks
idle keep-alive connections. Set ReadHeaderTimeout (30s) to plug the
slow-headers attack and IdleTimeout (120s) to cap keep-alive sockets.

ReadTimeout and WriteTimeout stay at 0 because request bodies can be
multi-GB model uploads and SSE / chat completions stream for many
minutes; operators who need tighter per-request bounds should terminate
slow clients at a reverse proxy.

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* test(auth): pin PUT /api/auth/profile field-tampering contract

The handler uses an explicit local body struct (only name and avatar_url)
plus a gorm Updates(map) with a column allowlist, so an attacker posting
{"role":"admin","email":"...","password_hash":"..."} can't mass-assign
those fields. Lock that down with a regression test so a future
"let's just c.Bind(&user)" refactor breaks loudly.

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(services): strip directory components from multipart upload filenames

UploadDataset and UploadToCollectionForUser took the raw multipart
file.Filename and joined it into a destination path. The fine-tune
upload was incidentally safe because of a UUID prefix that fused any
leading '..' to a literal segment, but the protection is fragile.
UploadToCollectionForUser handed the filename to a vendored backend
without sanitising at all.

Strip to filepath.Base at both boundaries and reject the trivial
unsafe values ("", ".", "..", "/").

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(react-ui): validate persisted MCP server entries on load

localStorage is shared across same-origin pages; an XSS that lands once
can poison persisted MCP server config to attempt header injection or
to feed a non-http URL into the fetch path on subsequent loads.
Validate every entry: types must match, URL must parse with http(s)
scheme, header keys/values must be control-char-free. Drop anything
that doesn't fit.

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(http): close X-Forwarded-Prefix open redirect

The reverse-proxy support concatenated X-Forwarded-Prefix into the
redirect target without validation, so a forged header value of
"//evil.com" turned the SPA-shell redirect helper at /, /browse, and
/browse/* into a 301 to //evil.com/app. The path-strip middleware had
the same shape on its prefix-trailing-slash redirect.

Add SafeForwardedPrefix at the middleware boundary: must start with
a single '/', no protocol-relative '//' opener, no scheme, no
backslash, no control characters. Apply at both consumers; misconfig
trips the validator and the header is dropped.

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(http): refuse wildcard CORS when LOCALAI_CORS=true with empty allowlist

When LOCALAI_CORS=true but LOCALAI_CORS_ALLOW_ORIGINS was empty, Echo's
CORSWithConfig saw an empty allow-list and fell back to its default
AllowOrigins=["*"]. An operator who flipped the strict-CORS feature
flag without populating the list got the opposite of what they asked
for. Echo never sets Allow-Credentials: true so this isn't directly
exploitable (cookies aren't sent under wildcard CORS), but the
misconfiguration trap is worth closing. Skip the registration and warn.

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(auth): zxcvbn password strength check with user-acknowledged override

The previous policy was len < 8, which let through "Password1" and the
rest of the credential-stuffing corpus. LocalAI has no second factor
yet, so the bar needs to sit higher.

Add ValidatePasswordStrength using github.com/timbutler/zxcvbn (an
actively-maintained fork of the trustelem port; v1.0.4, April 2024):
- min 12 chars, max 72 (bcrypt's truncation point)
- reject NUL bytes (some bcrypt callers truncate at the first NUL)
- require zxcvbn score >= 3 ("safely unguessable, ~10^8 guesses to
  break"); the hint list ["localai", "local-ai", "admin"] penalises
  passwords built from the app's own branding

zxcvbn produces false positives sometimes (a strong-looking password
that happens to match a dictionary word) and operators occasionally
need to set a known-weak password (kiosk demos, CI rigs). Add an
acknowledgement path: PasswordPolicy{AllowWeak: true} skips the
entropy check while still enforcing the hard rules. The structured
PasswordErrorResponse marks weak-password rejections as Overridable
so the UI can surface a "use this anyway" checkbox.

Wired through register, self-service password change, and admin
password reset on both the server and the React UI.

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(react-ui): drop HTML5 minLength on new-password inputs

minLength={12} on the new-password input let the browser block the
form submit silently before any JS or network call ran. The browser
focused the field, showed a brief native tooltip, and that was that —
no toast, no fetch, no clue. Reproducible by typing fewer than 12
chars on the second password change of a session.

The JS-level length check in handleSubmit already shows a toast and
the server rejects with a structured error, so the HTML5 attribute
was redundant defence anyway. Drop it.

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(react-ui): bundle Geist fonts locally instead of fetching from Google

The new CSP correctly refused to apply styles from
fonts.googleapis.com because style-src is locked to 'self' and
'unsafe-inline'. Loosening the CSP would defeat its purpose; the
right fix is to stop reaching out to a third-party CDN for fonts on
every page load.

Add @fontsource-variable/geist and @fontsource-variable/geist-mono as
npm deps and import them once at boot. Drop the <link rel="preconnect">
and external stylesheet from index.html.

Side benefit: no third-party tracking via Referer / IP on every UI
load, no failure mode when offline / behind a captive portal.

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(react-ui): refresh i18n strings to reflect 12-char password minimum

The translations still said "at least 8 characters" everywhere — the
client-side toast on a too-short password change told the user the
wrong floor. Update tooShort and newPasswordPlaceholder /
newPasswordDescription across all five locales (en, es, it, de,
zh-CN) to match the real ValidatePasswordStrength rule.

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(auth): make password length-floor overridable like the entropy check

The 12-char minimum was a policy choice, not a technical invariant —
only "non-empty", "<= 72 bytes", and "no NUL bytes" are real bcrypt
constraints. Treating length-12 as a hard rule was inconsistent with
the entropy check (already overridable) and friction for use cases
where the account is just a name on a session, not a security
boundary (single-user kiosk, CI rig, lab demo).

Restructure ValidatePasswordStrength:
- Hard rules (always enforced): non-empty, <= MaxPasswordLength, no NUL byte
- Policy rules (skipped when AllowWeak=true): length >= 12, zxcvbn score >= 3

PasswordError now marks password_too_short as Overridable too. The
React forms generalised from `error_code === 'password_too_weak'` to
`overridable === true`, and the JS-side preflight length checks were
removed (server is source of truth, returns the same checkbox flow).

Assisted-by: Claude:claude-opus-4-7 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

---------

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-05-08 16:25:45 +02:00

1214 lines
39 KiB
Go

package routes
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/http"
"net/mail"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/services/galleryop"
"gorm.io/gorm"
)
// rateLimiter implements a simple per-IP rate limiter for auth endpoints.
type rateLimiter struct {
mu sync.Mutex
attempts map[string][]time.Time
window time.Duration
max int
}
func newRateLimiter(window time.Duration, max int) *rateLimiter {
return &rateLimiter{
attempts: make(map[string][]time.Time),
window: window,
max: max,
}
}
func (rl *rateLimiter) allow(key string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
cutoff := now.Add(-rl.window)
// Prune old entries
recent := rl.attempts[key][:0]
for _, t := range rl.attempts[key] {
if t.After(cutoff) {
recent = append(recent, t)
}
}
if len(recent) >= rl.max {
rl.attempts[key] = recent
return false
}
rl.attempts[key] = append(recent, now)
return true
}
// cleanup removes stale IP entries that have no recent attempts.
func (rl *rateLimiter) cleanup() {
rl.mu.Lock()
defer rl.mu.Unlock()
cutoff := time.Now().Add(-rl.window)
for ip, attempts := range rl.attempts {
recent := attempts[:0]
for _, t := range attempts {
if t.After(cutoff) {
recent = append(recent, t)
}
}
if len(recent) == 0 {
delete(rl.attempts, ip)
} else {
rl.attempts[ip] = recent
}
}
}
func rateLimitMiddleware(rl *rateLimiter) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if !rl.allow(c.RealIP()) {
return c.JSON(http.StatusTooManyRequests, map[string]string{
"error": "too many requests, please try again later",
})
}
return next(c)
}
}
}
// parseDuration parses a duration string like "30d", "90d", "1y", "24h".
func parseDuration(s string) (time.Duration, error) {
if strings.HasSuffix(s, "d") {
s = strings.TrimSuffix(s, "d")
var days int
for _, c := range s {
if c < '0' || c > '9' {
return 0, &time.ParseError{}
}
days = days*10 + int(c-'0')
}
return time.Duration(days) * 24 * time.Hour, nil
}
if strings.HasSuffix(s, "y") {
s = strings.TrimSuffix(s, "y")
var years int
for _, c := range s {
if c < '0' || c > '9' {
return 0, &time.ParseError{}
}
years = years*10 + int(c-'0')
}
return time.Duration(years) * 365 * 24 * time.Hour, nil
}
return time.ParseDuration(s)
}
// RegisterAuthRoutes registers authentication-related API routes.
func RegisterAuthRoutes(e *echo.Echo, app *application.Application) {
appConfig := app.ApplicationConfig()
db := app.AuthDB()
// GET /api/auth/status - public, returns auth state
e.GET("/api/auth/status", func(c echo.Context) error {
authEnabled := db != nil
providers := []string{}
hasUsers := false
if authEnabled {
var count int64
db.Model(&auth.User{}).Count(&count)
hasUsers = count > 0
if !appConfig.Auth.DisableLocalAuth {
providers = append(providers, auth.ProviderLocal)
}
if appConfig.Auth.GitHubClientID != "" {
providers = append(providers, auth.ProviderGitHub)
}
if appConfig.Auth.OIDCClientID != "" {
providers = append(providers, auth.ProviderOIDC)
}
}
registrationMode := ""
if authEnabled {
registrationMode = appConfig.Auth.RegistrationMode
if registrationMode == "" {
registrationMode = "approval"
}
}
resp := map[string]any{
"authEnabled": authEnabled,
"staticApiKeyRequired": !authEnabled && len(appConfig.ApiKeys) > 0,
"providers": providers,
"hasUsers": hasUsers,
"registrationMode": registrationMode,
}
// Include current user if authenticated
user := auth.GetUser(c)
if user != nil {
userResp := map[string]any{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"avatarUrl": user.AvatarURL,
"role": user.Role,
"provider": user.Provider,
}
if authEnabled {
userResp["permissions"] = auth.GetPermissionMapForUser(db, user)
}
resp["user"] = userResp
} else {
resp["user"] = nil
}
return c.JSON(http.StatusOK, resp)
})
// Rate limiter for auth endpoints: 5 attempts per minute per IP
authRL := newRateLimiter(1*time.Minute, 5)
authRateLimitMw := rateLimitMiddleware(authRL)
// Separate, more permissive limiter for OAuth/OIDC callbacks. Corporate
// SSO often funnels many real users through one outbound IP, so the 5/min
// password-style cap is too tight here; 60/min still bounds a flood that
// would otherwise pin token-exchange traffic to the IdP.
oauthRL := newRateLimiter(1*time.Minute, 60)
oauthRateLimitMw := rateLimitMiddleware(oauthRL)
// Start background goroutine to periodically prune stale IP entries
go func() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for {
select {
case <-appConfig.Context.Done():
return
case <-ticker.C:
authRL.cleanup()
oauthRL.cleanup()
}
}
}()
// POST /api/auth/token-login - authenticate with API key/token.
// Registered when auth DB or legacy API keys are configured.
if db != nil || len(appConfig.ApiKeys) > 0 {
e.POST("/api/auth/token-login", func(c echo.Context) error {
var body struct {
Token string `json:"token"`
}
if err := c.Bind(&body); err != nil || strings.TrimSpace(body.Token) == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "token is required"})
}
token := strings.TrimSpace(body.Token)
// Try as user API key (only when auth DB is available)
if db != nil {
if apiKey, err := auth.ValidateAPIKey(db, token, appConfig.Auth.APIKeyHMACSecret); err == nil {
sessionID, err := auth.CreateSession(db, apiKey.User.ID, appConfig.Auth.APIKeyHMACSecret)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create session"})
}
auth.SetSessionCookie(c, sessionID)
return c.JSON(http.StatusOK, map[string]any{
"user": map[string]any{
"id": apiKey.User.ID,
"email": apiKey.User.Email,
"name": apiKey.User.Name,
"role": apiKey.User.Role,
},
})
}
}
// Try as legacy API key
if len(appConfig.ApiKeys) > 0 && isValidLegacyKey(token, appConfig) {
auth.SetTokenCookie(c, token)
return c.JSON(http.StatusOK, map[string]any{
"user": map[string]any{
"id": "legacy-api-key",
"name": "API Key User",
"role": auth.RoleAdmin,
},
})
}
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid token"})
}, authRateLimitMw)
}
// Remaining routes require auth DB
if db == nil {
return
}
// Set up OAuth manager when any OAuth/OIDC provider is configured
if appConfig.Auth.GitHubClientID != "" || appConfig.Auth.OIDCClientID != "" {
oauthMgr, err := auth.NewOAuthManager(
appConfig.Auth.BaseURL,
auth.OAuthParams{
GitHubClientID: appConfig.Auth.GitHubClientID,
GitHubClientSecret: appConfig.Auth.GitHubClientSecret,
OIDCIssuer: appConfig.Auth.OIDCIssuer,
OIDCClientID: appConfig.Auth.OIDCClientID,
OIDCClientSecret: appConfig.Auth.OIDCClientSecret,
},
)
if err == nil {
if appConfig.Auth.GitHubClientID != "" {
e.GET("/api/auth/github/login", oauthMgr.LoginHandler(auth.ProviderGitHub))
e.GET("/api/auth/github/callback", oauthMgr.CallbackHandler(
auth.ProviderGitHub, db, appConfig.Auth.AdminEmail, appConfig.Auth.RegistrationMode, appConfig.Auth.APIKeyHMACSecret,
), oauthRateLimitMw)
}
if appConfig.Auth.OIDCClientID != "" {
e.GET("/api/auth/oidc/login", oauthMgr.LoginHandler(auth.ProviderOIDC))
e.GET("/api/auth/oidc/callback", oauthMgr.CallbackHandler(
auth.ProviderOIDC, db, appConfig.Auth.AdminEmail, appConfig.Auth.RegistrationMode, appConfig.Auth.APIKeyHMACSecret,
), oauthRateLimitMw)
}
}
}
// POST /api/auth/register - public, email/password registration
e.POST("/api/auth/register", func(c echo.Context) error {
if appConfig.Auth.DisableLocalAuth {
return c.JSON(http.StatusForbidden, map[string]string{"error": "local registration is disabled"})
}
var body struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
InviteCode string `json:"inviteCode"`
AcknowledgeWeakPassword bool `json:"acknowledge_weak_password"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
body.Email = strings.ToLower(strings.TrimSpace(body.Email))
body.Name = strings.TrimSpace(body.Name)
body.InviteCode = strings.TrimSpace(body.InviteCode)
if body.Email == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "email is required"})
}
if _, err := mail.ParseAddress(body.Email); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid email address"})
}
if err := auth.ValidatePasswordStrength(body.Password, auth.PasswordPolicy{AllowWeak: body.AcknowledgeWeakPassword}); err != nil {
return c.JSON(http.StatusBadRequest, auth.PasswordError(err))
}
hash, err := auth.HashPassword(body.Password)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
}
name := body.Name
if name == "" {
name = body.Email
}
// Wrap user creation in a transaction to prevent admin bootstrap race (#2)
var user *auth.User
var validInvite *auth.InviteCode
var status string
txErr := db.Transaction(func(tx *gorm.DB) error {
// Check for duplicate email with local provider (#6: return generic response)
var existing auth.User
if err := tx.Where("email = ? AND provider = ?", body.Email, auth.ProviderLocal).First(&existing).Error; err == nil {
// Account exists — return nil to signal generic success
user = nil
return nil
}
role := auth.AssignRole(tx, body.Email, appConfig.Auth.AdminEmail)
// Determine status based on registration mode and invite code
status = auth.StatusActive
if auth.NeedsInviteOrApproval(tx, body.Email, appConfig.Auth.AdminEmail, appConfig.Auth.RegistrationMode) {
if appConfig.Auth.RegistrationMode == "invite" && body.InviteCode == "" {
return fmt.Errorf("invite_required")
}
if body.InviteCode != "" {
invite, err := auth.ValidateInvite(tx, body.InviteCode, appConfig.Auth.APIKeyHMACSecret)
if err != nil {
return fmt.Errorf("invalid_invite")
}
validInvite = invite
status = auth.StatusActive
} else {
status = auth.StatusPending
}
}
user = &auth.User{
ID: uuid.New().String(),
Email: body.Email,
Name: name,
Provider: auth.ProviderLocal,
Subject: body.Email,
PasswordHash: hash,
Role: role,
Status: status,
}
if err := tx.Create(user).Error; err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
if validInvite != nil {
auth.ConsumeInvite(tx, validInvite, user.ID)
}
return nil
})
if txErr != nil {
msg := txErr.Error()
if msg == "invite_required" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "an invite code is required to register"})
}
if msg == "invalid_invite" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid or expired invite code"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create user"})
}
// user == nil means duplicate email — return generic success (#6)
if user == nil {
return c.JSON(http.StatusCreated, map[string]any{
"message": "registration processed",
})
}
if status == auth.StatusPending {
return c.JSON(http.StatusOK, map[string]any{
"message": "registration successful, awaiting admin approval",
"pending": true,
})
}
sessionID, err := auth.CreateSession(db, user.ID, appConfig.Auth.APIKeyHMACSecret)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create session"})
}
auth.SetSessionCookie(c, sessionID)
return c.JSON(http.StatusCreated, map[string]any{
"user": map[string]any{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"role": user.Role,
},
})
}, authRateLimitMw)
// POST /api/auth/login - public, email/password login
e.POST("/api/auth/login", func(c echo.Context) error {
if appConfig.Auth.DisableLocalAuth {
return c.JSON(http.StatusForbidden, map[string]string{"error": "local login is disabled, please use OAuth"})
}
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
body.Email = strings.ToLower(strings.TrimSpace(body.Email))
if body.Email == "" || body.Password == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "email and password are required"})
}
var user auth.User
if err := db.Where("email = ? AND provider = ?", body.Email, auth.ProviderLocal).First(&user).Error; err != nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid email or password"})
}
if !auth.CheckPassword(user.PasswordHash, body.Password) {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid email or password"})
}
if user.Status == auth.StatusPending {
return c.JSON(http.StatusForbidden, map[string]string{"error": "account pending admin approval"})
}
// Maybe promote on login
auth.MaybePromote(db, &user, appConfig.Auth.AdminEmail)
sessionID, err := auth.CreateSession(db, user.ID, appConfig.Auth.APIKeyHMACSecret)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create session"})
}
auth.SetSessionCookie(c, sessionID)
return c.JSON(http.StatusOK, map[string]any{
"user": map[string]any{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"role": user.Role,
},
})
}, authRateLimitMw)
// POST /api/auth/logout - requires auth
e.POST("/api/auth/logout", func(c echo.Context) error {
user := auth.GetUser(c)
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
// Delete session from cookie
if cookie, err := c.Cookie("session"); err == nil && cookie.Value != "" {
auth.DeleteSession(db, cookie.Value, appConfig.Auth.APIKeyHMACSecret)
}
auth.ClearSessionCookie(c)
return c.JSON(http.StatusOK, map[string]string{"message": "logged out"})
})
// GET /api/auth/me - requires auth
e.GET("/api/auth/me", func(c echo.Context) error {
user := auth.GetUser(c)
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
resp := map[string]any{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"avatarUrl": user.AvatarURL,
"role": user.Role,
"provider": user.Provider,
"permissions": auth.GetPermissionMapForUser(db, user),
}
if quotas, err := auth.GetQuotaStatuses(db, user.ID); err == nil {
resp["quotas"] = quotas
}
return c.JSON(http.StatusOK, resp)
})
// GET /api/auth/quota - view own quota status
e.GET("/api/auth/quota", func(c echo.Context) error {
user := auth.GetUser(c)
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
quotas, err := auth.GetQuotaStatuses(db, user.ID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get quota status"})
}
return c.JSON(http.StatusOK, map[string]any{"quotas": quotas})
})
// PUT /api/auth/profile - update user profile (name, avatar_url)
e.PUT("/api/auth/profile", func(c echo.Context) error {
user := auth.GetUser(c)
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
var body struct {
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
name := strings.TrimSpace(body.Name)
if name == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name is required"})
}
avatarURL := strings.TrimSpace(body.AvatarURL)
if len(avatarURL) > 512 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "avatar URL must be at most 512 characters"})
}
updates := map[string]any{
"name": name,
"avatar_url": avatarURL,
}
if err := db.Model(&auth.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update profile"})
}
return c.JSON(http.StatusOK, map[string]any{
"message": "profile updated",
"name": name,
"avatarUrl": avatarURL,
})
})
// PUT /api/auth/password - change password (local users only) (#4: add rate limiting)
e.PUT("/api/auth/password", func(c echo.Context) error {
user := auth.GetUser(c)
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
if user.Provider != auth.ProviderLocal {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "password change is only available for local accounts"})
}
var body struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
AcknowledgeWeakPassword bool `json:"acknowledge_weak_password"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if body.CurrentPassword == "" || body.NewPassword == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "current and new passwords are required"})
}
if err := auth.ValidatePasswordStrength(body.NewPassword, auth.PasswordPolicy{AllowWeak: body.AcknowledgeWeakPassword}); err != nil {
return c.JSON(http.StatusBadRequest, auth.PasswordError(err))
}
// Verify current password
if !auth.CheckPassword(user.PasswordHash, body.CurrentPassword) {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "current password is incorrect"})
}
hash, err := auth.HashPassword(body.NewPassword)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
}
if err := db.Model(&auth.User{}).Where("id = ?", user.ID).Update("password_hash", hash).Error; err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update password"})
}
// Invalidate all existing sessions for this user
auth.DeleteUserSessions(db, user.ID)
// Create a fresh session for the current request
newSessionID, err := auth.CreateSession(db, user.ID, appConfig.Auth.APIKeyHMACSecret)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create session"})
}
auth.SetSessionCookie(c, newSessionID)
return c.JSON(http.StatusOK, map[string]string{"message": "password updated"})
}, authRateLimitMw)
// DELETE /api/auth/sessions - revoke all sessions for the current user
e.DELETE("/api/auth/sessions", func(c echo.Context) error {
user := auth.GetUser(c)
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
// Delete all sessions
auth.DeleteUserSessions(db, user.ID)
// Create a fresh session for the current request
newSessionID, err := auth.CreateSession(db, user.ID, appConfig.Auth.APIKeyHMACSecret)
if err != nil {
auth.ClearSessionCookie(c)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create session"})
}
auth.SetSessionCookie(c, newSessionID)
return c.JSON(http.StatusOK, map[string]string{"message": "all other sessions revoked"})
})
// POST /api/auth/api-keys - create API key (#8: expiration support)
e.POST("/api/auth/api-keys", func(c echo.Context) error {
user := auth.GetUser(c)
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
var body struct {
Name string `json:"name"`
ExpiresIn string `json:"expiresIn"` // duration like "30d", "90d", "1y"
ExpiresAt string `json:"expiresAt"` // ISO timestamp
}
if err := c.Bind(&body); err != nil || body.Name == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name is required"})
}
// Determine expiration
var expiresAt *time.Time
if body.ExpiresAt != "" {
t, err := time.Parse(time.RFC3339, body.ExpiresAt)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid expiresAt format, use RFC3339"})
}
expiresAt = &t
} else if body.ExpiresIn != "" {
dur, err := parseDuration(body.ExpiresIn)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid expiresIn format"})
}
t := time.Now().Add(dur)
expiresAt = &t
} else if appConfig.Auth.DefaultAPIKeyExpiry != "" {
dur, err := parseDuration(appConfig.Auth.DefaultAPIKeyExpiry)
if err == nil {
t := time.Now().Add(dur)
expiresAt = &t
}
}
plaintext, record, err := auth.CreateAPIKey(db, user.ID, body.Name, user.Role, appConfig.Auth.APIKeyHMACSecret, expiresAt)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create API key"})
}
resp := map[string]any{
"key": plaintext, // shown once
"id": record.ID,
"name": record.Name,
"keyPrefix": record.KeyPrefix,
"role": record.Role,
"createdAt": record.CreatedAt,
}
if record.ExpiresAt != nil {
resp["expiresAt"] = record.ExpiresAt
}
return c.JSON(http.StatusCreated, resp)
})
// GET /api/auth/api-keys - list user's API keys
e.GET("/api/auth/api-keys", func(c echo.Context) error {
user := auth.GetUser(c)
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
keys, err := auth.ListAPIKeys(db, user.ID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to list API keys"})
}
result := make([]map[string]any, 0, len(keys))
for _, k := range keys {
entry := map[string]any{
"id": k.ID,
"name": k.Name,
"keyPrefix": k.KeyPrefix,
"role": k.Role,
"createdAt": k.CreatedAt,
"lastUsed": k.LastUsed,
}
if k.ExpiresAt != nil {
entry["expiresAt"] = k.ExpiresAt
}
result = append(result, entry)
}
return c.JSON(http.StatusOK, map[string]any{"keys": result})
})
// DELETE /api/auth/api-keys/:id - revoke API key
e.DELETE("/api/auth/api-keys/:id", func(c echo.Context) error {
user := auth.GetUser(c)
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
keyID := c.Param("id")
if err := auth.RevokeAPIKey(db, keyID, user.ID); err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "API key not found"})
}
return c.JSON(http.StatusOK, map[string]string{"message": "API key revoked"})
})
// Usage endpoints
// GET /api/auth/usage - user's own usage
e.GET("/api/auth/usage", func(c echo.Context) error {
user := auth.GetUser(c)
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
period := c.QueryParam("period")
if period == "" {
period = "month"
}
buckets, err := auth.GetUserUsage(db, user.ID, period)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get usage"})
}
totals := auth.UsageTotals{}
for _, b := range buckets {
totals.PromptTokens += b.PromptTokens
totals.CompletionTokens += b.CompletionTokens
totals.TotalTokens += b.TotalTokens
totals.RequestCount += b.RequestCount
}
return c.JSON(http.StatusOK, map[string]any{
"usage": buckets,
"totals": totals,
})
})
// Admin endpoints
adminMw := auth.RequireAdmin()
// GET /api/auth/admin/features - returns feature metadata and available models
e.GET("/api/auth/admin/features", func(c echo.Context) error {
// Get available models
modelNames := []string{}
if app.ModelConfigLoader() != nil && app.ModelLoader() != nil {
names, err := galleryop.ListModels(
app.ModelConfigLoader(), app.ModelLoader(), nil, galleryop.SKIP_IF_CONFIGURED,
)
if err == nil {
modelNames = names
}
}
return c.JSON(http.StatusOK, map[string]any{
"agent_features": auth.AgentFeatureMetas(),
"general_features": auth.GeneralFeatureMetas(),
"api_features": auth.APIFeatureMetas(),
"models": modelNames,
})
}, adminMw)
// GET /api/auth/admin/users - list all users
e.GET("/api/auth/admin/users", func(c echo.Context) error {
var users []auth.User
if err := db.Order("created_at ASC").Find(&users).Error; err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to list users"})
}
result := make([]map[string]any, 0, len(users))
for _, u := range users {
entry := map[string]any{
"id": u.ID,
"email": u.Email,
"name": u.Name,
"avatarUrl": u.AvatarURL,
"role": u.Role,
"status": u.Status,
"provider": u.Provider,
"createdAt": u.CreatedAt,
}
entry["permissions"] = auth.GetPermissionMapForUser(db, &u)
entry["allowed_models"] = auth.GetModelAllowlist(db, u.ID)
if quotas, err := auth.GetQuotaStatuses(db, u.ID); err == nil && len(quotas) > 0 {
entry["quotas"] = quotas
}
result = append(result, entry)
}
return c.JSON(http.StatusOK, map[string]any{"users": result})
}, adminMw)
// PUT /api/auth/admin/users/:id/role - change user role
e.PUT("/api/auth/admin/users/:id/role", func(c echo.Context) error {
currentUser := auth.GetUser(c)
targetID := c.Param("id")
if currentUser.ID == targetID {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot change your own role"})
}
var body struct {
Role string `json:"role"`
}
if err := c.Bind(&body); err != nil || (body.Role != auth.RoleAdmin && body.Role != auth.RoleUser) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "role must be 'admin' or 'user'"})
}
result := db.Model(&auth.User{}).Where("id = ?", targetID).Update("role", body.Role)
if result.RowsAffected == 0 {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
}
return c.JSON(http.StatusOK, map[string]string{"message": "role updated"})
}, adminMw)
// PUT /api/auth/admin/users/:id/status - change user status (approve/disable)
e.PUT("/api/auth/admin/users/:id/status", func(c echo.Context) error {
currentUser := auth.GetUser(c)
targetID := c.Param("id")
if currentUser.ID == targetID {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot change your own status"})
}
var body struct {
Status string `json:"status"`
}
if err := c.Bind(&body); err != nil || (body.Status != auth.StatusActive && body.Status != auth.StatusDisabled) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "status must be 'active' or 'disabled'"})
}
result := db.Model(&auth.User{}).Where("id = ?", targetID).Update("status", body.Status)
if result.RowsAffected == 0 {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
}
return c.JSON(http.StatusOK, map[string]string{"message": "status updated"})
}, adminMw)
// PUT /api/auth/admin/users/:id/password - admin reset user password
e.PUT("/api/auth/admin/users/:id/password", func(c echo.Context) error {
currentUser := auth.GetUser(c)
targetID := c.Param("id")
if currentUser.ID == targetID {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot reset your own password via this endpoint, use self-service password change"})
}
var target auth.User
if err := db.First(&target, "id = ?", targetID).Error; err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
}
if target.Provider != auth.ProviderLocal {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "password reset is only available for local accounts"})
}
var body struct {
Password string `json:"password"`
AcknowledgeWeakPassword bool `json:"acknowledge_weak_password"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := auth.ValidatePasswordStrength(body.Password, auth.PasswordPolicy{AllowWeak: body.AcknowledgeWeakPassword}); err != nil {
return c.JSON(http.StatusBadRequest, auth.PasswordError(err))
}
hash, err := auth.HashPassword(body.Password)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
}
if err := db.Model(&auth.User{}).Where("id = ?", targetID).Update("password_hash", hash).Error; err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update password"})
}
auth.DeleteUserSessions(db, targetID)
return c.JSON(http.StatusOK, map[string]string{"message": "password reset successfully"})
}, adminMw)
// DELETE /api/auth/admin/users/:id - delete user
e.DELETE("/api/auth/admin/users/:id", func(c echo.Context) error {
currentUser := auth.GetUser(c)
targetID := c.Param("id")
if currentUser.ID == targetID {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot delete yourself"})
}
if err := auth.DeleteUserCascade(db, targetID); err != nil {
if err == gorm.ErrRecordNotFound {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete user: " + err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "user deleted"})
}, adminMw)
// GET /api/auth/admin/users/:id/permissions - get user permissions
e.GET("/api/auth/admin/users/:id/permissions", func(c echo.Context) error {
targetID := c.Param("id")
var target auth.User
if err := db.First(&target, "id = ?", targetID).Error; err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
}
perms := auth.GetPermissionMapForUser(db, &target)
return c.JSON(http.StatusOK, map[string]any{
"user_id": targetID,
"permissions": perms,
})
}, adminMw)
// PUT /api/auth/admin/users/:id/permissions - update user permissions
e.PUT("/api/auth/admin/users/:id/permissions", func(c echo.Context) error {
targetID := c.Param("id")
var target auth.User
if err := db.First(&target, "id = ?", targetID).Error; err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
}
var perms auth.PermissionMap
if err := c.Bind(&perms); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
if err := auth.UpdateUserPermissions(db, targetID, perms); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update permissions"})
}
return c.JSON(http.StatusOK, map[string]any{
"message": "permissions updated",
"user_id": targetID,
"permissions": perms,
})
}, adminMw)
// PUT /api/auth/admin/users/:id/models - update user model allowlist
e.PUT("/api/auth/admin/users/:id/models", func(c echo.Context) error {
targetID := c.Param("id")
var target auth.User
if err := db.First(&target, "id = ?", targetID).Error; err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
}
var allowlist auth.ModelAllowlist
if err := c.Bind(&allowlist); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
if err := auth.UpdateModelAllowlist(db, targetID, allowlist); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update model allowlist"})
}
return c.JSON(http.StatusOK, map[string]any{
"message": "model allowlist updated",
"user_id": targetID,
"allowed_models": allowlist,
})
}, adminMw)
// GET /api/auth/admin/users/:id/quotas - list user's quota rules
e.GET("/api/auth/admin/users/:id/quotas", func(c echo.Context) error {
targetID := c.Param("id")
var target auth.User
if err := db.First(&target, "id = ?", targetID).Error; err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
}
quotas, err := auth.GetQuotaStatuses(db, targetID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get quotas"})
}
return c.JSON(http.StatusOK, map[string]any{"quotas": quotas})
}, adminMw)
// PUT /api/auth/admin/users/:id/quotas - upsert quota rule (by user+model)
e.PUT("/api/auth/admin/users/:id/quotas", func(c echo.Context) error {
targetID := c.Param("id")
var target auth.User
if err := db.First(&target, "id = ?", targetID).Error; err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
}
var body struct {
Model string `json:"model"`
MaxRequests *int64 `json:"max_requests"`
MaxTotalTokens *int64 `json:"max_total_tokens"`
Window string `json:"window"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
if body.Window == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "window is required"})
}
windowSecs, err := auth.ParseWindowDuration(body.Window)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
rule, err := auth.CreateOrUpdateQuotaRule(db, targetID, body.Model, body.MaxRequests, body.MaxTotalTokens, windowSecs)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save quota rule"})
}
return c.JSON(http.StatusOK, map[string]any{
"message": "quota rule saved",
"quota": rule,
})
}, adminMw)
// DELETE /api/auth/admin/users/:id/quotas/:quota_id - delete a quota rule
e.DELETE("/api/auth/admin/users/:id/quotas/:quota_id", func(c echo.Context) error {
targetID := c.Param("id")
quotaID := c.Param("quota_id")
if err := auth.DeleteQuotaRule(db, quotaID, targetID); err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "quota rule not found"})
}
return c.JSON(http.StatusOK, map[string]string{"message": "quota rule deleted"})
}, adminMw)
// GET /api/auth/admin/usage - all users' usage (admin only)
e.GET("/api/auth/admin/usage", func(c echo.Context) error {
period := c.QueryParam("period")
if period == "" {
period = "month"
}
userID := c.QueryParam("user_id")
buckets, err := auth.GetAllUsage(db, period, userID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get usage"})
}
totals := auth.UsageTotals{}
for _, b := range buckets {
totals.PromptTokens += b.PromptTokens
totals.CompletionTokens += b.CompletionTokens
totals.TotalTokens += b.TotalTokens
totals.RequestCount += b.RequestCount
}
return c.JSON(http.StatusOK, map[string]any{
"usage": buckets,
"totals": totals,
})
}, adminMw)
// --- Invite management endpoints ---
// POST /api/auth/admin/invites - create invite (admin only)
e.POST("/api/auth/admin/invites", func(c echo.Context) error {
admin := auth.GetUser(c)
if admin == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
var body struct {
ExpiresInHours int `json:"expiresInHours"`
}
_ = c.Bind(&body)
if body.ExpiresInHours <= 0 {
body.ExpiresInHours = 168 // 7 days default
}
codeBytes := make([]byte, 32)
if _, err := rand.Read(codeBytes); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate invite code"})
}
plaintext := hex.EncodeToString(codeBytes)
codeHash := auth.HashAPIKey(plaintext, appConfig.Auth.APIKeyHMACSecret)
invite := &auth.InviteCode{
ID: uuid.New().String(),
Code: codeHash,
CodePrefix: plaintext[:8],
CreatedBy: admin.ID,
ExpiresAt: time.Now().Add(time.Duration(body.ExpiresInHours) * time.Hour),
}
if err := db.Create(invite).Error; err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create invite"})
}
return c.JSON(http.StatusCreated, map[string]any{
"id": invite.ID,
"code": plaintext,
"expiresAt": invite.ExpiresAt,
"createdAt": invite.CreatedAt,
})
}, adminMw)
// GET /api/auth/admin/invites - list all invites (admin only)
e.GET("/api/auth/admin/invites", func(c echo.Context) error {
var invites []auth.InviteCode
if err := db.Preload("Creator").Preload("Consumer").Order("created_at DESC").Find(&invites).Error; err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to list invites"})
}
result := make([]map[string]any, 0, len(invites))
for _, inv := range invites {
entry := map[string]any{
"id": inv.ID,
"codePrefix": inv.CodePrefix,
"expiresAt": inv.ExpiresAt,
"createdAt": inv.CreatedAt,
"usedAt": inv.UsedAt,
"createdBy": map[string]any{
"id": inv.Creator.ID,
"name": inv.Creator.Name,
},
}
if inv.UsedBy != nil && inv.Consumer != nil {
entry["usedBy"] = map[string]any{
"id": inv.Consumer.ID,
"name": inv.Consumer.Name,
}
} else {
entry["usedBy"] = nil
}
result = append(result, entry)
}
return c.JSON(http.StatusOK, map[string]any{"invites": result})
}, adminMw)
// DELETE /api/auth/admin/invites/:id - revoke unused invite (admin only)
e.DELETE("/api/auth/admin/invites/:id", func(c echo.Context) error {
inviteID := c.Param("id")
var invite auth.InviteCode
if err := db.First(&invite, "id = ?", inviteID).Error; err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "invite not found"})
}
if invite.UsedBy != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot revoke a used invite"})
}
db.Delete(&invite)
return c.JSON(http.StatusOK, map[string]string{"message": "invite revoked"})
}, adminMw)
// Note: GET /api/auth/invite/:code/check endpoint removed (#5) —
// invite codes are validated only during registration.
}
// isValidLegacyKey checks if the key matches any configured API key
// using constant-time comparison.
func isValidLegacyKey(token string, appConfig *config.ApplicationConfig) bool {
for _, validKey := range appConfig.ApiKeys {
if subtle.ConstantTimeCompare([]byte(token), []byte(validKey)) == 1 {
return true
}
}
return false
}