mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-17 13:10:23 -04:00
* 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>
1214 lines
39 KiB
Go
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
|
|
}
|