diff --git a/core/http/middleware/auth.go b/core/http/middleware/auth.go deleted file mode 100644 index c4ab6b1f7256..000000000000 --- a/core/http/middleware/auth.go +++ /dev/null @@ -1,179 +0,0 @@ -package middleware - -import ( - "crypto/subtle" - "errors" - "net/http" - "strings" - - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - "github.com/mudler/LocalAI/core/config" - "github.com/mudler/LocalAI/core/schema" -) - -var ErrMissingOrMalformedAPIKey = errors.New("missing or malformed API Key") - -// GetKeyAuthConfig returns Echo's KeyAuth middleware configuration -func GetKeyAuthConfig(applicationConfig *config.ApplicationConfig) (echo.MiddlewareFunc, error) { - // Create validator function - validator := getApiKeyValidationFunction(applicationConfig) - - // Create error handler - errorHandler := getApiKeyErrorHandler(applicationConfig) - - // Create Next function (skip middleware for certain requests) - skipper := getApiKeyRequiredFilterFunction(applicationConfig) - - // Wrap it with our custom key lookup that checks multiple sources - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - if len(applicationConfig.ApiKeys) == 0 { - return next(c) - } - - // Skip if skipper says so - if skipper != nil && skipper(c) { - return next(c) - } - - // Try to extract key from multiple sources - key, err := extractKeyFromMultipleSources(c) - if err != nil { - return errorHandler(err, c) - } - - // Validate the key - valid, err := validator(key, c) - if err != nil || !valid { - return errorHandler(ErrMissingOrMalformedAPIKey, c) - } - - // Store key in context for later use - c.Set("api_key", key) - - return next(c) - } - }, nil -} - -// extractKeyFromMultipleSources checks multiple sources for the API key -// in order: Authorization header, x-api-key header, xi-api-key header, token cookie -func extractKeyFromMultipleSources(c echo.Context) (string, error) { - // Check Authorization header first - auth := c.Request().Header.Get("Authorization") - if auth != "" { - // Check for Bearer scheme - if strings.HasPrefix(auth, "Bearer ") { - return strings.TrimPrefix(auth, "Bearer "), nil - } - // If no Bearer prefix, return as-is (for backward compatibility) - return auth, nil - } - - // Check x-api-key header - if key := c.Request().Header.Get("x-api-key"); key != "" { - return key, nil - } - - // Check xi-api-key header - if key := c.Request().Header.Get("xi-api-key"); key != "" { - return key, nil - } - - // Check token cookie - cookie, err := c.Cookie("token") - if err == nil && cookie != nil && cookie.Value != "" { - return cookie.Value, nil - } - - return "", ErrMissingOrMalformedAPIKey -} - -func getApiKeyErrorHandler(applicationConfig *config.ApplicationConfig) func(error, echo.Context) error { - return func(err error, c echo.Context) error { - if errors.Is(err, ErrMissingOrMalformedAPIKey) { - if len(applicationConfig.ApiKeys) == 0 { - return nil // if no keys are set up, any error we get here is not an error. - } - c.Response().Header().Set("WWW-Authenticate", "Bearer") - if applicationConfig.OpaqueErrors { - return c.NoContent(http.StatusUnauthorized) - } - - // Check if the request content type is JSON - 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: 401, - Type: "invalid_request_error", - }, - }) - } - - return c.Render(http.StatusUnauthorized, "views/login", map[string]any{ - "BaseURL": BaseURL(c), - }) - } - if applicationConfig.OpaqueErrors { - return c.NoContent(http.StatusInternalServerError) - } - return err - } -} - -func getApiKeyValidationFunction(applicationConfig *config.ApplicationConfig) func(string, echo.Context) (bool, error) { - if applicationConfig.UseSubtleKeyComparison { - return func(key string, c echo.Context) (bool, error) { - if len(applicationConfig.ApiKeys) == 0 { - return true, nil // If no keys are setup, accept everything - } - for _, validKey := range applicationConfig.ApiKeys { - if subtle.ConstantTimeCompare([]byte(key), []byte(validKey)) == 1 { - return true, nil - } - } - return false, ErrMissingOrMalformedAPIKey - } - } - - return func(key string, c echo.Context) (bool, error) { - if len(applicationConfig.ApiKeys) == 0 { - return true, nil // If no keys are setup, accept everything - } - for _, validKey := range applicationConfig.ApiKeys { - if key == validKey { - return true, nil - } - } - return false, ErrMissingOrMalformedAPIKey - } -} - -func getApiKeyRequiredFilterFunction(applicationConfig *config.ApplicationConfig) middleware.Skipper { - return func(c echo.Context) bool { - path := c.Request().URL.Path - - for _, p := range applicationConfig.PathWithoutAuth { - if strings.HasPrefix(path, p) { - return true - } - } - - // Handle GET request exemptions if enabled - if applicationConfig.DisableApiKeyRequirementForHttpGet { - if c.Request().Method != http.MethodGet { - return false - } - for _, rx := range applicationConfig.HttpGetExemptedEndpoints { - if rx.MatchString(c.Path()) { - return true - } - } - } - - return false - } -} diff --git a/core/http/middleware/auth_test.go b/core/http/middleware/auth_test.go deleted file mode 100644 index 6e75b80a0580..000000000000 --- a/core/http/middleware/auth_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package middleware_test - -import ( - "net/http" - "net/http/httptest" - - "github.com/labstack/echo/v4" - "github.com/mudler/LocalAI/core/config" - . "github.com/mudler/LocalAI/core/http/middleware" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// ok is a simple handler that returns 200 OK. -func ok(c echo.Context) error { - return c.String(http.StatusOK, "ok") -} - -// newAuthApp creates a minimal Echo app with auth middleware applied. -// Requests that fail auth with Content-Type: application/json get a JSON 401 -// (no template renderer needed). -func newAuthApp(appConfig *config.ApplicationConfig) *echo.Echo { - e := echo.New() - - mw, err := GetKeyAuthConfig(appConfig) - Expect(err).ToNot(HaveOccurred()) - e.Use(mw) - - // Sensitive API routes - e.GET("/v1/models", ok) - e.POST("/v1/chat/completions", ok) - - // UI routes - e.GET("/app", ok) - e.GET("/app/*", ok) - e.GET("/browse", ok) - e.GET("/browse/*", ok) - e.GET("/login", ok) - e.GET("/explorer", ok) - e.GET("/assets/*", ok) - e.POST("/app", ok) - - return e -} - -// doRequest performs an HTTP request against the given Echo app and returns the recorder. -func doRequest(e *echo.Echo, method, path string, opts ...func(*http.Request)) *httptest.ResponseRecorder { - req := httptest.NewRequest(method, path, nil) - req.Header.Set("Content-Type", "application/json") - for _, opt := range opts { - opt(req) - } - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - return rec -} - -func withBearerToken(token string) func(*http.Request) { - return func(req *http.Request) { - req.Header.Set("Authorization", "Bearer "+token) - } -} - -func withXApiKey(key string) func(*http.Request) { - return func(req *http.Request) { - req.Header.Set("x-api-key", key) - } -} - -func withXiApiKey(key string) func(*http.Request) { - return func(req *http.Request) { - req.Header.Set("xi-api-key", key) - } -} - -func withTokenCookie(token string) func(*http.Request) { - return func(req *http.Request) { - req.AddCookie(&http.Cookie{Name: "token", Value: token}) - } -} - -var _ = Describe("Auth Middleware", func() { - - Context("when API keys are configured", func() { - var app *echo.Echo - const validKey = "sk-test-key-123" - - BeforeEach(func() { - appConfig := config.NewApplicationConfig() - appConfig.ApiKeys = []string{validKey} - app = newAuthApp(appConfig) - }) - - It("returns 401 for GET request without a key", func() { - rec := doRequest(app, http.MethodGet, "/v1/models") - Expect(rec.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("returns 401 for POST request without a key", func() { - rec := doRequest(app, http.MethodPost, "/v1/chat/completions") - Expect(rec.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("returns 401 for request with an invalid key", func() { - rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken("wrong-key")) - Expect(rec.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("passes through with valid Bearer token in Authorization header", func() { - rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken(validKey)) - Expect(rec.Code).To(Equal(http.StatusOK)) - }) - - It("passes through with valid x-api-key header", func() { - rec := doRequest(app, http.MethodGet, "/v1/models", withXApiKey(validKey)) - Expect(rec.Code).To(Equal(http.StatusOK)) - }) - - It("passes through with valid xi-api-key header", func() { - rec := doRequest(app, http.MethodGet, "/v1/models", withXiApiKey(validKey)) - Expect(rec.Code).To(Equal(http.StatusOK)) - }) - - It("passes through with valid token cookie", func() { - rec := doRequest(app, http.MethodGet, "/v1/models", withTokenCookie(validKey)) - Expect(rec.Code).To(Equal(http.StatusOK)) - }) - }) - - Context("when no API keys are configured", func() { - var app *echo.Echo - - BeforeEach(func() { - appConfig := config.NewApplicationConfig() - app = newAuthApp(appConfig) - }) - - It("passes through without any key", func() { - rec := doRequest(app, http.MethodGet, "/v1/models") - Expect(rec.Code).To(Equal(http.StatusOK)) - }) - }) - - Context("GET exempted endpoints (feature enabled)", func() { - var app *echo.Echo - const validKey = "sk-test-key-456" - - BeforeEach(func() { - appConfig := config.NewApplicationConfig( - config.WithApiKeys([]string{validKey}), - config.WithDisableApiKeyRequirementForHttpGet(true), - config.WithHttpGetExemptedEndpoints([]string{ - "^/$", - "^/app(/.*)?$", - "^/browse(/.*)?$", - "^/login/?$", - "^/explorer/?$", - "^/assets/.*$", - "^/static/.*$", - "^/swagger.*$", - }), - ) - app = newAuthApp(appConfig) - }) - - It("allows GET to /app without a key", func() { - rec := doRequest(app, http.MethodGet, "/app") - Expect(rec.Code).To(Equal(http.StatusOK)) - }) - - It("allows GET to /app/chat/model sub-route without a key", func() { - rec := doRequest(app, http.MethodGet, "/app/chat/llama3") - Expect(rec.Code).To(Equal(http.StatusOK)) - }) - - It("allows GET to /browse/models without a key", func() { - rec := doRequest(app, http.MethodGet, "/browse/models") - Expect(rec.Code).To(Equal(http.StatusOK)) - }) - - It("allows GET to /login without a key", func() { - rec := doRequest(app, http.MethodGet, "/login") - Expect(rec.Code).To(Equal(http.StatusOK)) - }) - - It("allows GET to /explorer without a key", func() { - rec := doRequest(app, http.MethodGet, "/explorer") - Expect(rec.Code).To(Equal(http.StatusOK)) - }) - - It("allows GET to /assets/main.js without a key", func() { - rec := doRequest(app, http.MethodGet, "/assets/main.js") - Expect(rec.Code).To(Equal(http.StatusOK)) - }) - - It("rejects POST to /app without a key", func() { - rec := doRequest(app, http.MethodPost, "/app") - Expect(rec.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("rejects GET to /v1/models without a key", func() { - rec := doRequest(app, http.MethodGet, "/v1/models") - Expect(rec.Code).To(Equal(http.StatusUnauthorized)) - }) - }) - - Context("GET exempted endpoints (feature disabled)", func() { - var app *echo.Echo - const validKey = "sk-test-key-789" - - BeforeEach(func() { - appConfig := config.NewApplicationConfig( - config.WithApiKeys([]string{validKey}), - // DisableApiKeyRequirementForHttpGet defaults to false - config.WithHttpGetExemptedEndpoints([]string{ - "^/$", - "^/app(/.*)?$", - }), - ) - app = newAuthApp(appConfig) - }) - - It("requires auth for GET to /app even though it matches exempted pattern", func() { - rec := doRequest(app, http.MethodGet, "/app") - Expect(rec.Code).To(Equal(http.StatusUnauthorized)) - }) - }) -}) diff --git a/core/http/routes/auth.go b/core/http/routes/auth.go index e66b3c69ed1a..22e17fc01376 100644 --- a/core/http/routes/auth.go +++ b/core/http/routes/auth.go @@ -157,11 +157,11 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) { } resp := map[string]any{ - "authEnabled": authEnabled, - "staticApiKeyRequired": !authEnabled && len(appConfig.ApiKeys) > 0, - "providers": providers, - "hasUsers": hasUsers, - "registrationMode": registrationMode, + "authEnabled": authEnabled, + "staticApiKeyRequired": !authEnabled && len(appConfig.ApiKeys) > 0, + "providers": providers, + "hasUsers": hasUsers, + "registrationMode": registrationMode, } // Include current user if authenticated @@ -186,7 +186,73 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) { return c.JSON(http.StatusOK, resp) }) - // OAuth routes - only registered when auth is enabled + // Rate limiter for auth endpoints: 5 attempts per minute per IP + authRL := newRateLimiter(1*time.Minute, 5) + authRateLimitMw := rateLimitMiddleware(authRL) + + // 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() + } + } + }() + + // 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 } @@ -219,24 +285,6 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) { } } - // Rate limiter for auth endpoints: 5 attempts per minute per IP - authRL := newRateLimiter(1*time.Minute, 5) - authRateLimitMw := rateLimitMiddleware(authRL) - - // Start background goroutine to periodically prune stale IP entries (#12) - go func() { - ticker := time.NewTicker(10 * time.Minute) - defer ticker.Stop() - for { - select { - case <-appConfig.Context.Done(): - return - case <-ticker.C: - authRL.cleanup() - } - } - }() - // POST /api/auth/register - public, email/password registration e.POST("/api/auth/register", func(c echo.Context) error { if appConfig.Auth.DisableLocalAuth { @@ -427,53 +475,6 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) { }) }, authRateLimitMw) - // POST /api/auth/token-login - public, authenticate with API key/token (#3) - 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) - hmacSecret := appConfig.Auth.APIKeyHMACSecret - - // Try as user API key - if apiKey, err := auth.ValidateAPIKey(db, token, hmacSecret); 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) { - // Create a synthetic session cookie with the token for legacy mode - 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) - // POST /api/auth/logout - requires auth e.POST("/api/auth/logout", func(c echo.Context) error { user := auth.GetUser(c)