Skip to content

Commit aa3017e

Browse files
authored
feat: add users and authentication support (mudler#9061)
* feat(ui): add users and authentication support Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat: allow the admin user to impersonificate users Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore: ui improvements, disable 'Users' button in navbar when no auth is configured Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat: add OIDC support Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix: gate models Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore: cache requests to optimize speed Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * small UI enhancements Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore(ui): style improvements Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix: cover other paths by auth Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore: separate local auth, refactor Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * security hardening, approval mode Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix: fix tests and expectations Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore: update localagi/localrecall Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Signed-off-by: localai-bot <bot@localai.com>
1 parent 0e2902b commit aa3017e

102 files changed

Lines changed: 13373 additions & 1425 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ RUN apt-get update && \
256256

257257
FROM build-requirements AS builder-base
258258

259-
ARG GO_TAGS=""
259+
ARG GO_TAGS="auth"
260260
ARG GRPC_BACKENDS
261261
ARG MAKEFLAGS
262262
ARG LD_FLAGS="-s -w"

core/application/application.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/mudler/LocalAI/core/templates"
1212
"github.com/mudler/LocalAI/pkg/model"
1313
"github.com/mudler/xlog"
14+
"gorm.io/gorm"
1415
)
1516

1617
type Application struct {
@@ -22,6 +23,7 @@ type Application struct {
2223
galleryService *services.GalleryService
2324
agentJobService *services.AgentJobService
2425
agentPoolService atomic.Pointer[services.AgentPoolService]
26+
authDB *gorm.DB
2527
watchdogMutex sync.Mutex
2628
watchdogStop chan bool
2729
p2pMutex sync.Mutex
@@ -74,6 +76,11 @@ func (a *Application) AgentPoolService() *services.AgentPoolService {
7476
return a.agentPoolService.Load()
7577
}
7678

79+
// AuthDB returns the auth database connection, or nil if auth is not enabled.
80+
func (a *Application) AuthDB() *gorm.DB {
81+
return a.authDB
82+
}
83+
7784
// StartupConfig returns the original startup configuration (from env vars, before file loading)
7885
func (a *Application) StartupConfig() *config.ApplicationConfig {
7986
return a.startupConfig
@@ -118,9 +125,23 @@ func (a *Application) StartAgentPool() {
118125
xlog.Error("Failed to create agent pool service", "error", err)
119126
return
120127
}
128+
if a.authDB != nil {
129+
aps.SetAuthDB(a.authDB)
130+
}
121131
if err := aps.Start(a.applicationConfig.Context); err != nil {
122132
xlog.Error("Failed to start agent pool", "error", err)
123133
return
124134
}
135+
136+
// Wire per-user scoped services so collections, skills, and jobs are isolated per user
137+
usm := services.NewUserServicesManager(
138+
aps.UserStorage(),
139+
a.applicationConfig,
140+
a.modelLoader,
141+
a.backendLoader,
142+
a.templatesEvaluator,
143+
)
144+
aps.SetUserServicesManager(usm)
145+
125146
a.agentPoolService.Store(aps)
126147
}

core/application/config_file_watcher.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ func readRuntimeSettingsJson(startupAppConfig config.ApplicationConfig) fileHand
207207
envF16 := appConfig.F16 == startupAppConfig.F16
208208
envDebug := appConfig.Debug == startupAppConfig.Debug
209209
envCORS := appConfig.CORS == startupAppConfig.CORS
210-
envCSRF := appConfig.CSRF == startupAppConfig.CSRF
210+
envCSRF := appConfig.DisableCSRF == startupAppConfig.DisableCSRF
211211
envCORSAllowOrigins := appConfig.CORSAllowOrigins == startupAppConfig.CORSAllowOrigins
212212
envP2PToken := appConfig.P2PToken == startupAppConfig.P2PToken
213213
envP2PNetworkID := appConfig.P2PNetworkID == startupAppConfig.P2PNetworkID
@@ -313,7 +313,7 @@ func readRuntimeSettingsJson(startupAppConfig config.ApplicationConfig) fileHand
313313
appConfig.CORS = *settings.CORS
314314
}
315315
if settings.CSRF != nil && !envCSRF {
316-
appConfig.CSRF = *settings.CSRF
316+
appConfig.DisableCSRF = *settings.CSRF
317317
}
318318
if settings.CORSAllowOrigins != nil && !envCORSAllowOrigins {
319319
appConfig.CORSAllowOrigins = *settings.CORSAllowOrigins

core/application/startup.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package application
22

33
import (
4+
"crypto/rand"
5+
"encoding/hex"
46
"encoding/json"
57
"fmt"
68
"os"
@@ -10,6 +12,7 @@ import (
1012
"github.com/mudler/LocalAI/core/backend"
1113
"github.com/mudler/LocalAI/core/config"
1214
"github.com/mudler/LocalAI/core/gallery"
15+
"github.com/mudler/LocalAI/core/http/auth"
1316
"github.com/mudler/LocalAI/core/services"
1417
coreStartup "github.com/mudler/LocalAI/core/startup"
1518
"github.com/mudler/LocalAI/internal"
@@ -81,6 +84,45 @@ func New(opts ...config.AppOption) (*Application, error) {
8184
}
8285
}
8386

87+
// Initialize auth database if auth is enabled
88+
if options.Auth.Enabled {
89+
// Auto-generate HMAC secret if not provided
90+
if options.Auth.APIKeyHMACSecret == "" {
91+
secretFile := filepath.Join(options.DataPath, ".hmac_secret")
92+
secret, err := loadOrGenerateHMACSecret(secretFile)
93+
if err != nil {
94+
return nil, fmt.Errorf("failed to initialize HMAC secret: %w", err)
95+
}
96+
options.Auth.APIKeyHMACSecret = secret
97+
}
98+
99+
authDB, err := auth.InitDB(options.Auth.DatabaseURL)
100+
if err != nil {
101+
return nil, fmt.Errorf("failed to initialize auth database: %w", err)
102+
}
103+
application.authDB = authDB
104+
xlog.Info("Auth enabled", "database", options.Auth.DatabaseURL)
105+
106+
// Start session and expired API key cleanup goroutine
107+
go func() {
108+
ticker := time.NewTicker(1 * time.Hour)
109+
defer ticker.Stop()
110+
for {
111+
select {
112+
case <-options.Context.Done():
113+
return
114+
case <-ticker.C:
115+
if err := auth.CleanExpiredSessions(authDB); err != nil {
116+
xlog.Error("failed to clean expired sessions", "error", err)
117+
}
118+
if err := auth.CleanExpiredAPIKeys(authDB); err != nil {
119+
xlog.Error("failed to clean expired API keys", "error", err)
120+
}
121+
}
122+
}
123+
}()
124+
}
125+
84126
if err := coreStartup.InstallModels(options.Context, application.GalleryService(), options.Galleries, options.BackendGalleries, options.SystemState, application.ModelLoader(), options.EnforcePredownloadScans, options.AutoloadBackendGalleries, nil, options.ModelsURL...); err != nil {
85127
xlog.Error("error installing models", "error", err)
86128
}
@@ -434,6 +476,31 @@ func initializeWatchdog(application *Application, options *config.ApplicationCon
434476
}
435477
}
436478

479+
// loadOrGenerateHMACSecret loads an HMAC secret from the given file path,
480+
// or generates a random 32-byte secret and persists it if the file doesn't exist.
481+
func loadOrGenerateHMACSecret(path string) (string, error) {
482+
data, err := os.ReadFile(path)
483+
if err == nil {
484+
secret := string(data)
485+
if len(secret) >= 32 {
486+
return secret, nil
487+
}
488+
}
489+
490+
b := make([]byte, 32)
491+
if _, err := rand.Read(b); err != nil {
492+
return "", fmt.Errorf("failed to generate HMAC secret: %w", err)
493+
}
494+
secret := hex.EncodeToString(b)
495+
496+
if err := os.WriteFile(path, []byte(secret), 0600); err != nil {
497+
return "", fmt.Errorf("failed to persist HMAC secret: %w", err)
498+
}
499+
500+
xlog.Info("Generated new HMAC secret for API key hashing", "path", path)
501+
return secret, nil
502+
}
503+
437504
// migrateDataFiles moves persistent data files from the old config directory
438505
// to the new data directory. Only moves files that exist in src but not in dst.
439506
func migrateDataFiles(srcDir, dstDir string) {

core/cli/run.go

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ type RunCMD struct {
5858
Address string `env:"LOCALAI_ADDRESS,ADDRESS" default:":8080" help:"Bind address for the API server" group:"api"`
5959
CORS bool `env:"LOCALAI_CORS,CORS" help:"" group:"api"`
6060
CORSAllowOrigins string `env:"LOCALAI_CORS_ALLOW_ORIGINS,CORS_ALLOW_ORIGINS" group:"api"`
61-
CSRF bool `env:"LOCALAI_CSRF" help:"Enables fiber CSRF middleware" group:"api"`
61+
DisableCSRF bool `env:"LOCALAI_DISABLE_CSRF" help:"Disable CSRF middleware (enabled by default)" group:"api"`
6262
UploadLimit int `env:"LOCALAI_UPLOAD_LIMIT,UPLOAD_LIMIT" default:"15" help:"Default upload-limit in MB" group:"api"`
6363
APIKeys []string `env:"LOCALAI_API_KEY,API_KEY" help:"List of API Keys to enable API authentication. When this is set, all the requests must be authenticated with one of these API keys" group:"api"`
6464
DisableWebUI bool `env:"LOCALAI_DISABLE_WEBUI,DISABLE_WEBUI" default:"false" help:"Disables the web user interface. When set to true, the server will only expose API endpoints without serving the web interface" group:"api"`
@@ -121,6 +121,21 @@ type RunCMD struct {
121121
AgentPoolCollectionDBPath string `env:"LOCALAI_AGENT_POOL_COLLECTION_DB_PATH" help:"Database path for agent collections" group:"agents"`
122122
AgentHubURL string `env:"LOCALAI_AGENT_HUB_URL" default:"https://agenthub.localai.io" help:"URL for the agent hub where users can browse and download agent configurations" group:"agents"`
123123

124+
// Authentication
125+
AuthEnabled bool `env:"LOCALAI_AUTH" default:"false" help:"Enable user authentication and authorization" group:"auth"`
126+
AuthDatabaseURL string `env:"LOCALAI_AUTH_DATABASE_URL,DATABASE_URL" help:"Database URL for auth (postgres:// or file path for SQLite). Defaults to {DataPath}/database.db" group:"auth"`
127+
GitHubClientID string `env:"GITHUB_CLIENT_ID" help:"GitHub OAuth App Client ID (auto-enables auth when set)" group:"auth"`
128+
GitHubClientSecret string `env:"GITHUB_CLIENT_SECRET" help:"GitHub OAuth App Client Secret" group:"auth"`
129+
OIDCIssuer string `env:"LOCALAI_OIDC_ISSUER" help:"OIDC issuer URL for auto-discovery" group:"auth"`
130+
OIDCClientID string `env:"LOCALAI_OIDC_CLIENT_ID" help:"OIDC Client ID (auto-enables auth)" group:"auth"`
131+
OIDCClientSecret string `env:"LOCALAI_OIDC_CLIENT_SECRET" help:"OIDC Client Secret" group:"auth"`
132+
AuthBaseURL string `env:"LOCALAI_BASE_URL" help:"Base URL for OAuth callbacks (e.g. http://localhost:8080)" group:"auth"`
133+
AuthAdminEmail string `env:"LOCALAI_ADMIN_EMAIL" help:"Email address to auto-promote to admin role" group:"auth"`
134+
AuthRegistrationMode string `env:"LOCALAI_REGISTRATION_MODE" default:"open" help:"Registration mode: 'open' (default), 'approval', or 'invite' (invite code required)" group:"auth"`
135+
DisableLocalAuth bool `env:"LOCALAI_DISABLE_LOCAL_AUTH" default:"false" help:"Disable local email/password registration and login (use with OAuth/OIDC-only setups)" group:"auth"`
136+
AuthAPIKeyHMACSecret string `env:"LOCALAI_AUTH_HMAC_SECRET" help:"HMAC secret for API key hashing (auto-generated if empty)" group:"auth"`
137+
DefaultAPIKeyExpiry string `env:"LOCALAI_DEFAULT_API_KEY_EXPIRY" help:"Default expiry for API keys (e.g. 90d, 1y; empty = no expiry)" group:"auth"`
138+
124139
Version bool
125140
}
126141

@@ -165,7 +180,7 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
165180
config.WithBackendGalleries(r.BackendGalleries),
166181
config.WithCors(r.CORS),
167182
config.WithCorsAllowOrigins(r.CORSAllowOrigins),
168-
config.WithCsrf(r.CSRF),
183+
config.WithDisableCSRF(r.DisableCSRF),
169184
config.WithThreads(r.Threads),
170185
config.WithUploadLimitMB(r.UploadLimit),
171186
config.WithApiKeys(r.APIKeys),
@@ -311,6 +326,46 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
311326
opts = append(opts, config.WithAgentHubURL(r.AgentHubURL))
312327
}
313328

329+
// Authentication
330+
authEnabled := r.AuthEnabled || r.GitHubClientID != "" || r.OIDCClientID != ""
331+
if authEnabled {
332+
opts = append(opts, config.WithAuthEnabled(true))
333+
334+
dbURL := r.AuthDatabaseURL
335+
if dbURL == "" {
336+
dbURL = filepath.Join(r.DataPath, "database.db")
337+
}
338+
opts = append(opts, config.WithAuthDatabaseURL(dbURL))
339+
340+
if r.GitHubClientID != "" {
341+
opts = append(opts, config.WithAuthGitHubClientID(r.GitHubClientID))
342+
opts = append(opts, config.WithAuthGitHubClientSecret(r.GitHubClientSecret))
343+
}
344+
if r.OIDCClientID != "" {
345+
opts = append(opts, config.WithAuthOIDCIssuer(r.OIDCIssuer))
346+
opts = append(opts, config.WithAuthOIDCClientID(r.OIDCClientID))
347+
opts = append(opts, config.WithAuthOIDCClientSecret(r.OIDCClientSecret))
348+
}
349+
if r.AuthBaseURL != "" {
350+
opts = append(opts, config.WithAuthBaseURL(r.AuthBaseURL))
351+
}
352+
if r.AuthAdminEmail != "" {
353+
opts = append(opts, config.WithAuthAdminEmail(r.AuthAdminEmail))
354+
}
355+
if r.AuthRegistrationMode != "" {
356+
opts = append(opts, config.WithAuthRegistrationMode(r.AuthRegistrationMode))
357+
}
358+
if r.DisableLocalAuth {
359+
opts = append(opts, config.WithAuthDisableLocalAuth(true))
360+
}
361+
if r.AuthAPIKeyHMACSecret != "" {
362+
opts = append(opts, config.WithAuthAPIKeyHMACSecret(r.AuthAPIKeyHMACSecret))
363+
}
364+
if r.DefaultAPIKeyExpiry != "" {
365+
opts = append(opts, config.WithAuthDefaultAPIKeyExpiry(r.DefaultAPIKeyExpiry))
366+
}
367+
}
368+
314369
if idleWatchDog || busyWatchDog {
315370
opts = append(opts, config.EnableWatchDog)
316371
if idleWatchDog {

0 commit comments

Comments
 (0)