mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-29 11:07:18 -04:00
* feat(usage): add Source, APIKeyID, APIKeyName columns to UsageRecord Adds three additive columns plus UsageSource* constants. The columns are auto-migrated by InitDB. APIKeyID is a nullable foreign reference to UserAPIKey.ID; APIKeyName is snapshotted on each row so revoked keys keep showing their name in history. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(usage): backfill Source on pre-feature usage rows InitDB now classifies any pre-existing usage_record with an empty source: 'legacy-api-key' user -> legacy, everything else -> web. The backfill is idempotent (only touches NULL/empty rows). Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(usage): add GetUserUsageBySource aggregator Groups by (bucket, source, api_key_id, api_key_name). Filters out legacy by default. Returns both per-bucket detail and roll-ups (by_source, by_key sorted desc and capped at 200, grand_total). The MAX(created_at) projection is iterated via Rows().Scan into a string column and parsed manually because the SQLite driver surfaces the aggregated timestamp as a string, which database/sql refuses to scan directly into time.Time. Postgres returns a real timestamp; the same string path handles its RFC3339 form too. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(usage): log Rows() errors and assert LastUsed in tests Adds rows.Err() and Rows() open-failure logging in computeSourceTotals so silent data drops surface in logs. Logs on parseLastUsedString format misses for the same reason. Strengthens the snapshot-survival test to assert LastUsed is a recent timestamp, locking the SQLite time-string parser behaviour. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(usage): add admin GetAllUsageBySource with filters and truncation Optional user_id and api_key_id filters (composed with AND). Legacy bucket is included for admin callers. truncated=true when more than 200 distinct keys would be in the by_key roll-up. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(auth): plumb auth_source and auth_apikey through Echo context tryAuthenticate now sets auth_source on every successful branch (web for session/Bearer-session, apikey for Bearer-key/x-api-key/ token-cookie, legacy for legacy env key match). For named-key branches it also stores the resolved *UserAPIKey under auth_apikey so downstream middlewares can snapshot id+name without re-validating. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(auth): expand tryAuthenticate godoc and cover Bearer-session branch Documents all three context-keys side effects (auth_source, auth_apikey, _auth_session) plus the split of responsibilities with the parent Middleware. Adds a test for the Bearer-as-session-token classification so future regressions there fail loudly. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(usage): UsageMiddleware records source + snapshots key name Reads auth_source and auth_apikey from the Echo context (set by auth.Middleware in the previous task). Snapshots UserAPIKey.ID and Name onto each row so revoked keys remain readable in history. Falls back to source=web when no auth_source is set (auth disabled or unrecognised path). Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(usage): add /api/auth/usage/sources and admin variant Self endpoint filters legacy server-side; admin endpoint includes legacy and accepts user_id + api_key_id filters. Response includes buckets, totals.{by_source, by_key, grand_total}, and a truncated flag set when the per-key roll-up was capped at 200. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(routes): mark test mirror handlers as keep-in-sync with production The newTestAuthApp helper duplicates production route handlers inline because it cannot use RegisterAuthRoutes (which requires a *application.Application). Naming the source path on each mirror makes the drift contract explicit for future maintainers. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): add usageApi.getMySources/getAdminSources + i18n strings Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): add Sources tab skeleton with data fetch Adds Usage page tab that fetches /api/auth/usage/sources (or the admin variant). Renders raw totals plus a placeholder key list; real visualisations land in subsequent commits. Restructures the existing tab button block so Models and Sources are visible to non-admins (Users remains admin-only). Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): source mix ribbon + searchable/sortable sources table Replaces the SourcesTab placeholder rendering with two reusable components: SourceMixRibbon (one segmented bar per source class) and SourcesTable (search + sort + revoked-key dim). Pulls the current API key list to detect revoked keys. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): skip revoked-key detection until the key list is known existingKeyIds defaulted to an empty Set, which made every live api_key row render as (revoked) during the brief window before apiKeysApi.list() resolved, and permanently after a fetch failure. Use null as the unknown state and suppress the revoked badge until the parent provides a real Set. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): top-N stacked time chart and drill-in chip for Sources tab Top 7 sources by total tokens get distinct colours; the rest roll up into 'Other'. Clicking a row in the SourcesTable dims everything except that series in the chart; the chip is the canonical clear. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(usage): document per-API-key Sources tab and endpoints Extends features/authentication.md Usage Tracking section with: - A 'Sources' tab description and source-class taxonomy - Endpoint documentation for /api/auth/usage/sources and the admin variant - Response shape example with by_source / by_key / grand_total - Migration note about pre-feature row backfill Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(usage): silence errcheck on deferred rows.Close CI errcheck flagged the bare 'defer rows.Close()' in computeSourceTotals. Wrap in a closure that discards the close error explicitly; an error here is non-actionable since we have already drained the rows and logged any iteration failure. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(usage): bound batcher intake and add Shutdown/FlushNow hooks The pre-existing usage batcher had no cap on its add() path; the usageMaxPending=5000 constant only guarded the re-queue path after a failed write, leaving memory growth unbounded if the DB fell behind. This commit: - Adds the cap to add() so saturation drops new records (rate-limited warn at 1/1024) instead of growing unbounded. - Raises usageMaxPending to 50000 to absorb realistic inference bursts. - Replaces the package-level batcher global with a mutex-guarded pair plus a currentBatcher() accessor so Init / Shutdown cycles are race-free. - Adds ShutdownUsageRecorder() for graceful drain on process exit (not yet wired into app shutdown, just published). - Adds FlushNow() for deterministic tests; the middleware suite no longer needs 6s sleeps per spec and now runs in ~50ms instead of 18s. - Re-queue on failed flush is now cap-aware: prepends as much of the failed batch as fits alongside concurrent arrivals, instead of dropping the whole batch when full. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(usage): drain usage batcher on graceful shutdown Registers ShutdownUsageRecorder with the existing signals.RegisterGracefulTerminationHandler so SIGINT/SIGTERM synchronously flushes any in-memory usage records before the process exits. Without this, up to one flush interval (5s) of recorded usage was lost when LocalAI restarted. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
629 lines
19 KiB
Go
629 lines
19 KiB
Go
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/schema"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
contextKeyUser = "auth_user"
|
|
contextKeyRole = "auth_role"
|
|
contextKeyAPIKey = "auth_apikey"
|
|
contextKeySource = "auth_source"
|
|
)
|
|
|
|
// Middleware returns an Echo middleware that handles authentication.
|
|
//
|
|
// Resolution order:
|
|
// 1. If auth not enabled AND no legacy API keys → pass through
|
|
// 2. Skip auth for exempt paths (PathWithoutAuth + /api/auth/)
|
|
// 3. If auth enabled (db != nil):
|
|
// a. Try "session" cookie → DB lookup
|
|
// b. Try Authorization: Bearer → session ID, then user API key
|
|
// c. Try x-api-key / xi-api-key → user API key
|
|
// d. Try "token" cookie → legacy API key check
|
|
// e. Check all extracted keys against legacy ApiKeys → synthetic admin
|
|
// 4. If auth not enabled → delegate to legacy API key validation
|
|
// 5. If no auth found for /api/ or /v1/ paths → 401
|
|
// 6. Otherwise pass through (static assets, UI pages, etc.)
|
|
func Middleware(db *gorm.DB, appConfig *config.ApplicationConfig) echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
authEnabled := db != nil
|
|
hasLegacyKeys := len(appConfig.ApiKeys) > 0
|
|
|
|
// 1. No auth at all
|
|
if !authEnabled && !hasLegacyKeys {
|
|
return next(c)
|
|
}
|
|
|
|
path := c.Request().URL.Path
|
|
exempt := isExemptPath(path, appConfig)
|
|
authenticated := false
|
|
|
|
// 2. Try to authenticate (populates user in context if possible)
|
|
if authEnabled {
|
|
user := tryAuthenticate(c, db, appConfig)
|
|
if user != nil {
|
|
c.Set(contextKeyUser, user)
|
|
c.Set(contextKeyRole, user.Role)
|
|
authenticated = true
|
|
|
|
// Session rotation for cookie-based sessions
|
|
if session, ok := c.Get("_auth_session").(*Session); ok {
|
|
MaybeRotateSession(c, db, session, appConfig.Auth.APIKeyHMACSecret)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Legacy API key validation (works whether auth is enabled or not)
|
|
if !authenticated && hasLegacyKeys {
|
|
key := extractKey(c)
|
|
if key != "" && isValidLegacyKey(key, appConfig) {
|
|
syntheticUser := &User{
|
|
ID: "legacy-api-key",
|
|
Name: "API Key User",
|
|
Role: RoleAdmin,
|
|
}
|
|
c.Set(contextKeyUser, syntheticUser)
|
|
c.Set(contextKeyRole, RoleAdmin)
|
|
c.Set(contextKeySource, UsageSourceLegacy)
|
|
authenticated = true
|
|
}
|
|
}
|
|
|
|
// 4. If authenticated or exempt path, proceed
|
|
if authenticated || exempt {
|
|
return next(c)
|
|
}
|
|
|
|
// 5. Require auth for API paths
|
|
if isAPIPath(path) {
|
|
// Check GET exemptions for legacy keys
|
|
if hasLegacyKeys && appConfig.DisableApiKeyRequirementForHttpGet && c.Request().Method == http.MethodGet {
|
|
for _, rx := range appConfig.HttpGetExemptedEndpoints {
|
|
if rx.MatchString(c.Path()) {
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
return authError(c, appConfig)
|
|
}
|
|
|
|
// 6. Non-API paths (UI, static assets) pass through.
|
|
// The React UI handles login redirects client-side.
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// RequireAdmin returns middleware that checks the user has admin role.
|
|
func RequireAdmin() echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
user := GetUser(c)
|
|
if user == nil {
|
|
return c.JSON(http.StatusUnauthorized, schema.ErrorResponse{
|
|
Error: &schema.APIError{
|
|
Message: "Authentication required",
|
|
Code: http.StatusUnauthorized,
|
|
Type: "authentication_error",
|
|
},
|
|
})
|
|
}
|
|
if user.Role != RoleAdmin {
|
|
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
|
Error: &schema.APIError{
|
|
Message: "Admin access required",
|
|
Code: http.StatusForbidden,
|
|
Type: "authorization_error",
|
|
},
|
|
})
|
|
}
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// NoopMiddleware returns a middleware that does nothing (pass-through).
|
|
// Used when auth is disabled to satisfy route registration that expects
|
|
// an admin middleware parameter.
|
|
func NoopMiddleware() echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return next
|
|
}
|
|
}
|
|
|
|
// RequireFeature returns middleware that checks the user has access to the given feature.
|
|
// If no auth DB is provided, it passes through (backward compat).
|
|
// Admins always pass. Regular users must have the feature enabled in their permissions.
|
|
func RequireFeature(db *gorm.DB, feature string) echo.MiddlewareFunc {
|
|
if db == nil {
|
|
return NoopMiddleware()
|
|
}
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
user := GetUser(c)
|
|
if user == nil {
|
|
return c.JSON(http.StatusUnauthorized, schema.ErrorResponse{
|
|
Error: &schema.APIError{
|
|
Message: "Authentication required",
|
|
Code: http.StatusUnauthorized,
|
|
Type: "authentication_error",
|
|
},
|
|
})
|
|
}
|
|
if user.Role == RoleAdmin {
|
|
return next(c)
|
|
}
|
|
perm, err := GetCachedUserPermissions(c, db, user.ID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
|
Error: &schema.APIError{
|
|
Message: "feature not enabled for your account",
|
|
Code: http.StatusForbidden,
|
|
Type: "authorization_error",
|
|
},
|
|
})
|
|
}
|
|
val, exists := perm.Permissions[feature]
|
|
if !exists {
|
|
if !isDefaultOnFeature(feature) {
|
|
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
|
Error: &schema.APIError{
|
|
Message: "feature not enabled for your account",
|
|
Code: http.StatusForbidden,
|
|
Type: "authorization_error",
|
|
},
|
|
})
|
|
}
|
|
} else if !val {
|
|
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
|
Error: &schema.APIError{
|
|
Message: "feature not enabled for your account",
|
|
Code: http.StatusForbidden,
|
|
Type: "authorization_error",
|
|
},
|
|
})
|
|
}
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetUser returns the authenticated user from the echo context, or nil.
|
|
func GetUser(c echo.Context) *User {
|
|
u, ok := c.Get(contextKeyUser).(*User)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return u
|
|
}
|
|
|
|
// GetUserRole returns the role of the authenticated user, or empty string.
|
|
func GetUserRole(c echo.Context) string {
|
|
role, _ := c.Get(contextKeyRole).(string)
|
|
return role
|
|
}
|
|
|
|
// GetAPIKey returns the resolved API key from the echo context, or nil.
|
|
// Nil for session-cookie and legacy-env-key authentication.
|
|
func GetAPIKey(c echo.Context) *UserAPIKey {
|
|
k, _ := c.Get(contextKeyAPIKey).(*UserAPIKey)
|
|
return k
|
|
}
|
|
|
|
// GetSource returns the request's authentication source: UsageSourceAPIKey,
|
|
// UsageSourceWeb, UsageSourceLegacy, or empty if no authentication was performed.
|
|
func GetSource(c echo.Context) string {
|
|
s, _ := c.Get(contextKeySource).(string)
|
|
return s
|
|
}
|
|
|
|
// RequireRouteFeature returns a global middleware that checks the user has access
|
|
// to the feature required by the matched route. It uses the RouteFeatureRegistry
|
|
// to look up the required feature for each route pattern + HTTP method.
|
|
// If no entry matches, the request passes through (no restriction).
|
|
func RequireRouteFeature(db *gorm.DB) echo.MiddlewareFunc {
|
|
if db == nil {
|
|
return NoopMiddleware()
|
|
}
|
|
// Pre-build lookup map: "METHOD:pattern" -> feature
|
|
lookup := map[string]string{}
|
|
for _, rf := range RouteFeatureRegistry {
|
|
lookup[rf.Method+":"+rf.Pattern] = rf.Feature
|
|
}
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
path := c.Path() // Echo route pattern (e.g. "/v1/engines/:model/completions")
|
|
method := c.Request().Method
|
|
feature := lookup[method+":"+path]
|
|
if feature == "" {
|
|
feature = lookup["*:"+path]
|
|
}
|
|
if feature == "" {
|
|
return next(c) // no restriction for this route
|
|
}
|
|
user := GetUser(c)
|
|
if user == nil {
|
|
return next(c) // auth middleware handles unauthenticated
|
|
}
|
|
if user.Role == RoleAdmin {
|
|
return next(c)
|
|
}
|
|
perm, err := GetCachedUserPermissions(c, db, user.ID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, schema.ErrorResponse{
|
|
Error: &schema.APIError{
|
|
Message: "failed to check permissions",
|
|
Code: http.StatusInternalServerError,
|
|
Type: "server_error",
|
|
},
|
|
})
|
|
}
|
|
val, exists := perm.Permissions[feature]
|
|
if !exists {
|
|
if !isDefaultOnFeature(feature) {
|
|
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
|
Error: &schema.APIError{
|
|
Message: "feature not enabled for your account: " + feature,
|
|
Code: http.StatusForbidden,
|
|
Type: "authorization_error",
|
|
},
|
|
})
|
|
}
|
|
} else if !val {
|
|
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
|
Error: &schema.APIError{
|
|
Message: "feature not enabled for your account: " + feature,
|
|
Code: http.StatusForbidden,
|
|
Type: "authorization_error",
|
|
},
|
|
})
|
|
}
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// RequireModelAccess returns a global middleware that checks the user is allowed
|
|
// to use the resolved model. It extracts the model name directly from the request
|
|
// (path param, query param, JSON body, or form value) rather than relying on a
|
|
// context key set by downstream route-specific middleware.
|
|
func RequireModelAccess(db *gorm.DB) echo.MiddlewareFunc {
|
|
if db == nil {
|
|
return NoopMiddleware()
|
|
}
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
user := GetUser(c)
|
|
if user == nil {
|
|
return next(c)
|
|
}
|
|
if user.Role == RoleAdmin {
|
|
return next(c)
|
|
}
|
|
|
|
// Check if this user even has a model allowlist enabled before
|
|
// doing the expensive body read. Most users won't have restrictions.
|
|
// Uses request-scoped cache to avoid duplicate DB hit when
|
|
// RequireRouteFeature already fetched permissions.
|
|
perm, err := GetCachedUserPermissions(c, db, user.ID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, schema.ErrorResponse{
|
|
Error: &schema.APIError{
|
|
Message: "failed to check permissions",
|
|
Code: http.StatusInternalServerError,
|
|
Type: "server_error",
|
|
},
|
|
})
|
|
}
|
|
allowlist := perm.AllowedModels
|
|
if !allowlist.Enabled {
|
|
return next(c)
|
|
}
|
|
|
|
modelName := extractModelFromRequest(c)
|
|
if modelName == "" {
|
|
return next(c)
|
|
}
|
|
|
|
for _, m := range allowlist.Models {
|
|
if m == modelName {
|
|
return next(c)
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
|
Error: &schema.APIError{
|
|
Message: "access denied to model: " + modelName,
|
|
Code: http.StatusForbidden,
|
|
Type: "authorization_error",
|
|
},
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// extractModelFromRequest extracts the model name from various request sources.
|
|
// It checks URL path params, query params, JSON body, and form values.
|
|
// For JSON bodies, it peeks at the body and resets it so downstream handlers
|
|
// can still read it.
|
|
func extractModelFromRequest(c echo.Context) string {
|
|
// 1. URL path param (e.g. /v1/engines/:model/completions)
|
|
if model := c.Param("model"); model != "" {
|
|
return model
|
|
}
|
|
// 2. Query param
|
|
if model := c.QueryParam("model"); model != "" {
|
|
return model
|
|
}
|
|
// 3. Peek at JSON body
|
|
if strings.HasPrefix(c.Request().Header.Get("Content-Type"), "application/json") {
|
|
body, err := io.ReadAll(c.Request().Body)
|
|
c.Request().Body = io.NopCloser(bytes.NewReader(body)) // always reset
|
|
if err == nil && len(body) > 0 {
|
|
var m struct {
|
|
Model string `json:"model"`
|
|
}
|
|
if json.Unmarshal(body, &m) == nil && m.Model != "" {
|
|
return m.Model
|
|
}
|
|
}
|
|
}
|
|
// 4. Form value (multipart/form-data)
|
|
if model := c.FormValue("model"); model != "" {
|
|
return model
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// RequireQuota returns a global middleware that enforces per-user quota rules.
|
|
// If no auth DB is provided, it's a no-op. Admin users always bypass quotas.
|
|
// Only inference routes (those listed in RouteFeatureRegistry) count toward quota.
|
|
func RequireQuota(db *gorm.DB) echo.MiddlewareFunc {
|
|
if db == nil {
|
|
return NoopMiddleware()
|
|
}
|
|
// Pre-build lookup set from RouteFeatureRegistry — only these routes
|
|
// should count toward quota. Mirrors RequireRouteFeature's approach.
|
|
inferenceRoutes := map[string]bool{}
|
|
for _, rf := range RouteFeatureRegistry {
|
|
inferenceRoutes[rf.Method+":"+rf.Pattern] = true
|
|
}
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
// Only enforce quotas on inference routes
|
|
path := c.Path()
|
|
method := c.Request().Method
|
|
if !inferenceRoutes[method+":"+path] && !inferenceRoutes["*:"+path] {
|
|
return next(c)
|
|
}
|
|
|
|
user := GetUser(c)
|
|
if user == nil {
|
|
return next(c)
|
|
}
|
|
if user.Role == RoleAdmin {
|
|
return next(c)
|
|
}
|
|
|
|
model := extractModelFromRequest(c)
|
|
|
|
exceeded, retryAfter, msg := QuotaExceeded(db, user.ID, model)
|
|
if exceeded {
|
|
c.Response().Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
|
|
return c.JSON(http.StatusTooManyRequests, schema.ErrorResponse{
|
|
Error: &schema.APIError{
|
|
Message: msg,
|
|
Code: http.StatusTooManyRequests,
|
|
Type: "quota_exceeded",
|
|
},
|
|
})
|
|
}
|
|
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// tryAuthenticate attempts to authenticate the request using the database.
|
|
//
|
|
// On success it returns the user and, as a side effect, sets the following
|
|
// values on the Echo context:
|
|
// - contextKeySource ("auth_source"): always set, one of UsageSourceWeb /
|
|
// UsageSourceAPIKey. UsageSourceLegacy is set elsewhere by the parent
|
|
// Middleware when a legacy env key matches.
|
|
// - contextKeyAPIKey ("auth_apikey"): set to the resolved *UserAPIKey for
|
|
// named-key branches (Bearer, x-api-key, xi-api-key, token cookie).
|
|
// - "_auth_session": session record, used by Middleware to drive cookie
|
|
// rotation. Only set on the session-cookie branch.
|
|
//
|
|
// contextKeyUser and contextKeyRole are populated by the parent Middleware
|
|
// after this function returns.
|
|
func tryAuthenticate(c echo.Context, db *gorm.DB, appConfig *config.ApplicationConfig) *User {
|
|
hmacSecret := appConfig.Auth.APIKeyHMACSecret
|
|
|
|
// a. Session cookie -> web UI
|
|
if cookie, err := c.Cookie(sessionCookie); err == nil && cookie.Value != "" {
|
|
if user, session := ValidateSession(db, cookie.Value, hmacSecret); user != nil {
|
|
// Store session for rotation check in middleware
|
|
c.Set("_auth_session", session)
|
|
c.Set(contextKeySource, UsageSourceWeb)
|
|
return user
|
|
}
|
|
}
|
|
|
|
// b. Authorization: Bearer
|
|
authHeader := c.Request().Header.Get("Authorization")
|
|
if strings.HasPrefix(authHeader, "Bearer ") {
|
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
|
|
|
// b1. Session token via Bearer -> still web UI
|
|
if user, _ := ValidateSession(db, token, hmacSecret); user != nil {
|
|
c.Set(contextKeySource, UsageSourceWeb)
|
|
return user
|
|
}
|
|
|
|
// b2. Named API key
|
|
if key, err := ValidateAPIKey(db, token, hmacSecret); err == nil {
|
|
c.Set(contextKeySource, UsageSourceAPIKey)
|
|
c.Set(contextKeyAPIKey, key)
|
|
return &key.User
|
|
}
|
|
}
|
|
|
|
// c. x-api-key / xi-api-key -> named API key
|
|
for _, header := range []string{"x-api-key", "xi-api-key"} {
|
|
if k := c.Request().Header.Get(header); k != "" {
|
|
if apiKey, err := ValidateAPIKey(db, k, hmacSecret); err == nil {
|
|
c.Set(contextKeySource, UsageSourceAPIKey)
|
|
c.Set(contextKeyAPIKey, apiKey)
|
|
return &apiKey.User
|
|
}
|
|
}
|
|
}
|
|
|
|
// d. token cookie -> named API key
|
|
if cookie, err := c.Cookie("token"); err == nil && cookie.Value != "" {
|
|
if key, err := ValidateAPIKey(db, cookie.Value, hmacSecret); err == nil {
|
|
c.Set(contextKeySource, UsageSourceAPIKey)
|
|
c.Set(contextKeyAPIKey, key)
|
|
return &key.User
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// extractKey extracts an API key from the request (all sources).
|
|
func extractKey(c echo.Context) string {
|
|
// Authorization header
|
|
auth := c.Request().Header.Get("Authorization")
|
|
if strings.HasPrefix(auth, "Bearer ") {
|
|
return strings.TrimPrefix(auth, "Bearer ")
|
|
}
|
|
if auth != "" {
|
|
return auth
|
|
}
|
|
|
|
// x-api-key
|
|
if key := c.Request().Header.Get("x-api-key"); key != "" {
|
|
return key
|
|
}
|
|
|
|
// xi-api-key
|
|
if key := c.Request().Header.Get("xi-api-key"); key != "" {
|
|
return key
|
|
}
|
|
|
|
// token cookie
|
|
if cookie, err := c.Cookie("token"); err == nil && cookie.Value != "" {
|
|
return cookie.Value
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// isValidLegacyKey checks if the key matches any configured API key
|
|
// using constant-time comparison to prevent timing attacks.
|
|
func isValidLegacyKey(key string, appConfig *config.ApplicationConfig) bool {
|
|
for _, validKey := range appConfig.ApiKeys {
|
|
if subtle.ConstantTimeCompare([]byte(key), []byte(validKey)) == 1 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isExemptPath returns true if the path should skip authentication.
|
|
func isExemptPath(path string, appConfig *config.ApplicationConfig) bool {
|
|
// Auth endpoints are always public
|
|
if strings.HasPrefix(path, "/api/auth/") {
|
|
return true
|
|
}
|
|
|
|
// Node self-service endpoints — authenticated via registration token, not global auth.
|
|
// Only exempt the specific known endpoints, not the entire prefix.
|
|
if strings.HasPrefix(path, "/api/node/") {
|
|
if path == "/api/node/register" ||
|
|
strings.HasSuffix(path, "/heartbeat") ||
|
|
strings.HasSuffix(path, "/drain") ||
|
|
strings.HasSuffix(path, "/deregister") {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Check configured exempt paths
|
|
for _, p := range appConfig.PathWithoutAuth {
|
|
if strings.HasPrefix(path, p) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// isAPIPath returns true for paths that always require authentication.
|
|
func isAPIPath(path string) bool {
|
|
return strings.HasPrefix(path, "/api/") ||
|
|
strings.HasPrefix(path, "/v1/") ||
|
|
strings.HasPrefix(path, "/models/") ||
|
|
strings.HasPrefix(path, "/backends/") ||
|
|
strings.HasPrefix(path, "/backend/") ||
|
|
strings.HasPrefix(path, "/tts") ||
|
|
strings.HasPrefix(path, "/vad") ||
|
|
strings.HasPrefix(path, "/video") ||
|
|
strings.HasPrefix(path, "/stores/") ||
|
|
strings.HasPrefix(path, "/system") ||
|
|
strings.HasPrefix(path, "/ws/") ||
|
|
strings.HasPrefix(path, "/generated-") ||
|
|
strings.HasPrefix(path, "/chat/") ||
|
|
strings.HasPrefix(path, "/completions") ||
|
|
strings.HasPrefix(path, "/edits") ||
|
|
strings.HasPrefix(path, "/embeddings") ||
|
|
strings.HasPrefix(path, "/audio/") ||
|
|
strings.HasPrefix(path, "/images/") ||
|
|
strings.HasPrefix(path, "/messages") ||
|
|
strings.HasPrefix(path, "/responses") ||
|
|
path == "/metrics"
|
|
}
|
|
|
|
// authError returns an appropriate error response.
|
|
func authError(c echo.Context, appConfig *config.ApplicationConfig) error {
|
|
c.Response().Header().Set("WWW-Authenticate", "Bearer")
|
|
|
|
if appConfig.OpaqueErrors {
|
|
return c.NoContent(http.StatusUnauthorized)
|
|
}
|
|
|
|
contentType := c.Request().Header.Get("Content-Type")
|
|
if strings.Contains(contentType, "application/json") {
|
|
return c.JSON(http.StatusUnauthorized, schema.ErrorResponse{
|
|
Error: &schema.APIError{
|
|
Message: "An authentication key is required",
|
|
Code: http.StatusUnauthorized,
|
|
Type: "invalid_request_error",
|
|
},
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusUnauthorized, schema.ErrorResponse{
|
|
Error: &schema.APIError{
|
|
Message: "An authentication key is required",
|
|
Code: http.StatusUnauthorized,
|
|
Type: "invalid_request_error",
|
|
},
|
|
})
|
|
}
|