From 9228951afc490a4310030c28cc2f5e02d0065d10 Mon Sep 17 00:00:00 2001 From: Zzde Date: Wed, 25 Mar 2026 23:45:10 +0800 Subject: [PATCH] feat: ldap Signed-off-by: Zzde --- go.mod | 3 + go.sum | 22 + main.go | 7 + pkg/auth/handler.go | 144 +++++- pkg/auth/ldap.go | 232 +++++++++ pkg/auth/ldap_setting_handler.go | 122 +++++ pkg/model/ldap_setting.go | 197 ++++++++ pkg/model/model.go | 1 + pkg/model/oauth.go | 41 +- pkg/model/user.go | 77 +++ ui/src/components/global-search.tsx | 4 +- ui/src/components/settings-hint.tsx | 15 +- .../settings/authentication-management.tsx | 440 ++++++++++++++++++ .../settings/general-management.tsx | 18 +- ui/src/contexts/auth-context.tsx | 53 ++- ui/src/i18n/locales/en.json | 43 +- ui/src/i18n/locales/zh.json | 43 +- ui/src/lib/api.ts | 81 +++- ui/src/pages/login.tsx | 228 +++++---- ui/src/pages/settings.tsx | 6 +- 20 files changed, 1649 insertions(+), 128 deletions(-) create mode 100644 pkg/auth/ldap.go create mode 100644 pkg/auth/ldap_setting_handler.go create mode 100644 pkg/model/ldap_setting.go create mode 100644 ui/src/components/settings/authentication-management.tsx diff --git a/go.mod b/go.mod index c393c8b7..d0e83956 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/gin-contrib/gzip v1.2.5 github.com/gin-gonic/gin v1.12.0 github.com/glebarez/sqlite v1.11.0 + github.com/go-ldap/ldap/v3 v3.4.12 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/openai/openai-go v1.12.0 @@ -36,6 +37,7 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect @@ -52,6 +54,7 @@ require ( github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-errors/errors v1.4.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-openapi/jsonpointer v0.21.2 // indirect diff --git a/go.sum b/go.sum index 2ea9f452..cc0ad15e 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,12 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= @@ -60,8 +64,12 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= +github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= @@ -110,6 +118,8 @@ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5T github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= @@ -123,6 +133,18 @@ github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= diff --git a/main.go b/main.go index fa71bb30..6e0ca479 100644 --- a/main.go +++ b/main.go @@ -94,6 +94,7 @@ func setupAPIRouter(r *gin.RouterGroup, cm *cluster.ClusterManager) { { authGroup.GET("/providers", authHandler.GetProviders) authGroup.POST("/login/password", authHandler.PasswordLogin) + authGroup.POST("/login/ldap", authHandler.LDAPLogin) authGroup.GET("/login", authHandler.Login) authGroup.GET("/callback", authHandler.Callback) authGroup.POST("/logout", authHandler.Logout) @@ -124,6 +125,12 @@ func setupAPIRouter(r *gin.RouterGroup, cm *cluster.ClusterManager) { oauthProviderAPI.DELETE("/:id", authHandler.DeleteOAuthProvider) } + ldapSettingAPI := adminAPI.Group("/ldap-setting") + { + ldapSettingAPI.GET("/", authHandler.GetLDAPSetting) + ldapSettingAPI.PUT("/", authHandler.UpdateLDAPSetting) + } + clusterAPI := adminAPI.Group("/clusters") { clusterAPI.GET("/", cm.GetClusterList) diff --git a/pkg/auth/handler.go b/pkg/auth/handler.go index 624e60d1..7c8ca602 100644 --- a/pkg/auth/handler.go +++ b/pkg/auth/handler.go @@ -1,6 +1,8 @@ package auth import ( + "errors" + "fmt" "net/http" "strconv" "strings" @@ -9,24 +11,44 @@ import ( "github.com/zxh326/kite/pkg/common" "github.com/zxh326/kite/pkg/model" "github.com/zxh326/kite/pkg/rbac" + "gorm.io/gorm" "k8s.io/klog/v2" ) type AuthHandler struct { manager *OAuthManager + ldap *LDAPAuthenticator } +type credentialAuthenticator func(username, password string) (*model.User, error) + +var errInvalidCredentials = errors.New("invalid credentials") + func NewAuthHandler() *AuthHandler { return &AuthHandler{ manager: NewOAuthManager(), + ldap: NewLDAPAuthenticator(), } } func (h *AuthHandler) GetProviders(c *gin.Context) { - providers := h.manager.GetAvailableProviders() - providers = append(providers, "password") + credentialProviders := []string{model.AuthProviderPassword} + oauthProviders := uniqueStrings(h.manager.GetAvailableProviders()) + + setting, err := model.GetLDAPSetting() + if err != nil { + klog.Warningf("Failed to load ldap setting for providers: %v", err) + } else if setting.Enabled { + credentialProviders = append(credentialProviders, model.AuthProviderLDAP) + } + + credentialProviders = uniqueStrings(credentialProviders) + providers := append(append([]string{}, credentialProviders...), oauthProviders...) + c.JSON(http.StatusOK, gin.H{ - "providers": providers, + "providers": providers, + "credentialProviders": credentialProviders, + "oauthProviders": oauthProviders, }) } @@ -63,23 +85,42 @@ func (h *AuthHandler) Login(c *gin.Context) { } func (h *AuthHandler) PasswordLogin(c *gin.Context) { + h.handleCredentialLogin(c, model.AuthProviderPassword, h.authenticatePasswordUser) +} + +func (h *AuthHandler) LDAPLogin(c *gin.Context) { + h.handleCredentialLogin(c, model.AuthProviderLDAP, h.authenticateAndSyncLDAPUser) +} + +func (h *AuthHandler) handleCredentialLogin(c *gin.Context, provider string, authenticate credentialAuthenticator) { var req common.PasswordLoginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"}) return } - user, err := model.GetUserByUsername(req.Username) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + username := strings.TrimSpace(req.Username) + if username == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"}) return } - if !model.CheckPassword(user.Password, req.Password) { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + user, err := authenticate(username, req.Password) + if err != nil { + errMsg := fmt.Sprintf("%s login failed for %s: %v", strings.ToUpper(provider), username, err) + klog.Warning(errMsg) + if isCredentialFailure(err) { + c.JSON(http.StatusUnauthorized, gin.H{"error": errMsg}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg}) return } + h.completePasswordLikeLogin(c, user) +} + +func (h *AuthHandler) completePasswordLikeLogin(c *gin.Context, user *model.User) { if !user.Enabled { c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"}) return @@ -107,6 +148,66 @@ func (h *AuthHandler) PasswordLogin(c *gin.Context) { c.Status(http.StatusNoContent) } +func (h *AuthHandler) authenticateAndSyncLDAPUser(username, password string) (*model.User, error) { + setting, err := model.GetLDAPSetting() + if err != nil { + return nil, err + } + + ldapUser, err := h.ldap.Authenticate(setting, username, password) + if err != nil { + return nil, err + } + + syncedUser, err := model.UpsertLDAPUser(ldapUser) + if err != nil { + if errors.Is(err, model.ErrUserProviderConflict) { + return nil, ErrLDAPInvalidCredentials + } + return nil, err + } + + return syncedUser, nil +} + +func (h *AuthHandler) authenticatePasswordUser(username, password string) (*model.User, error) { + user, err := model.GetUserByUsername(username) + switch { + case err == nil: + if user.Provider != "" && user.Provider != model.AuthProviderPassword { + return nil, errInvalidCredentials + } + if !model.CheckPassword(user.Password, password) { + return nil, errInvalidCredentials + } + return user, nil + case errors.Is(err, gorm.ErrRecordNotFound): + return nil, errInvalidCredentials + default: + return nil, err + } +} + +func uniqueStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + unique := make([]string, 0, len(values)) + for _, value := range values { + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + unique = append(unique, value) + } + return unique +} + +func isCredentialFailure(err error) bool { + return errors.Is(err, errInvalidCredentials) || + errors.Is(err, ErrLDAPInvalidCredentials) || + errors.Is(err, ErrLDAPDisabled) || + errors.Is(err, ErrLDAPNotConfigured) +} + func (h *AuthHandler) Callback(c *gin.Context) { base := common.Base code := c.Query("code") @@ -418,6 +519,8 @@ func (h *AuthHandler) CreateOAuthProvider(c *gin.Context) { return } + provider.Name = model.LowerCaseString(model.NormalizeOAuthProviderName(string(provider.Name))) + // Validate required fields if provider.Name == "" || provider.ClientID == "" || string(provider.ClientSecret) == "" { c.JSON(http.StatusBadRequest, gin.H{ @@ -425,8 +528,20 @@ func (h *AuthHandler) CreateOAuthProvider(c *gin.Context) { }) return } + if model.IsReservedOAuthProviderName(string(provider.Name)) { + c.JSON(http.StatusBadRequest, gin.H{ + "error": model.ErrReservedOAuthProviderName.Error(), + }) + return + } if err := model.CreateOAuthProvider(&provider); err != nil { + if errors.Is(err, model.ErrReservedOAuthProviderName) { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to create OAuth provider: " + err.Error(), }) @@ -461,6 +576,7 @@ func (h *AuthHandler) UpdateOAuthProvider(c *gin.Context) { return } provider.ID = uint(dbID) + provider.Name = model.LowerCaseString(model.NormalizeOAuthProviderName(string(provider.Name))) // Validate required fields if provider.Name == "" || provider.ClientID == "" { @@ -469,6 +585,12 @@ func (h *AuthHandler) UpdateOAuthProvider(c *gin.Context) { }) return } + if model.IsReservedOAuthProviderName(string(provider.Name)) { + c.JSON(http.StatusBadRequest, gin.H{ + "error": model.ErrReservedOAuthProviderName.Error(), + }) + return + } updates := map[string]interface{}{ "name": provider.Name, @@ -485,6 +607,12 @@ func (h *AuthHandler) UpdateOAuthProvider(c *gin.Context) { } if err := model.UpdateOAuthProvider(&provider, updates); err != nil { + if errors.Is(err, model.ErrReservedOAuthProviderName) { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to update OAuth provider: " + err.Error(), }) diff --git a/pkg/auth/ldap.go b/pkg/auth/ldap.go new file mode 100644 index 00000000..edaadf44 --- /dev/null +++ b/pkg/auth/ldap.go @@ -0,0 +1,232 @@ +package auth + +import ( + "crypto/tls" + "errors" + "fmt" + "net" + "net/url" + "sort" + "strings" + "time" + + "github.com/go-ldap/ldap/v3" + "github.com/zxh326/kite/pkg/model" +) + +var ( + ErrLDAPDisabled = errors.New("ldap authentication is disabled") + ErrLDAPInvalidCredentials = errors.New("invalid ldap credentials") + ErrLDAPNotConfigured = errors.New("ldap authentication is not configured") +) + +type ldapConfig struct { + ServerURL string + UseStartTLS bool + BindDN string + BindPassword string + UserBaseDN string + UserFilter string + UsernameAttribute string + DisplayNameAttribute string + GroupBaseDN string + GroupFilter string + GroupNameAttribute string +} + +type LDAPAuthenticator struct{} + +func NewLDAPAuthenticator() *LDAPAuthenticator { + return &LDAPAuthenticator{} +} + +func (a *LDAPAuthenticator) Authenticate(setting *model.LDAPSetting, username, password string) (*model.User, error) { + cfg, err := newLDAPConfig(setting) + if err != nil { + return nil, err + } + if password == "" { + return nil, ErrLDAPInvalidCredentials + } + + conn, parsedURL, err := dialLDAP(cfg) + if err != nil { + return nil, err + } + defer func() { + _ = conn.Close() + }() + + if cfg.UseStartTLS && parsedURL.Scheme == "ldap" { + if err := conn.StartTLS(&tls.Config{ + MinVersion: tls.VersionTLS12, + ServerName: parsedURL.Hostname(), + }); err != nil { + return nil, fmt.Errorf("failed to start ldap tls: %w", err) + } + } + + if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil { + return nil, fmt.Errorf("failed to bind ldap service account: %w", err) + } + + entry, err := findLDAPUser(conn, cfg, username) + if err != nil { + return nil, err + } + + if err := conn.Bind(entry.DN, password); err != nil { + return nil, ErrLDAPInvalidCredentials + } + + if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil { + return nil, fmt.Errorf("failed to rebind ldap service account: %w", err) + } + + groups, err := findLDAPGroups(conn, cfg, entry.DN) + if err != nil { + return nil, err + } + + canonicalUsername := strings.TrimSpace(entry.GetAttributeValue(cfg.UsernameAttribute)) + if canonicalUsername == "" { + canonicalUsername = strings.TrimSpace(username) + } + + displayName := strings.TrimSpace(entry.GetAttributeValue(cfg.DisplayNameAttribute)) + if displayName == "" { + displayName = canonicalUsername + } + + return &model.User{ + Username: canonicalUsername, + Name: displayName, + Provider: model.AuthProviderLDAP, + Password: "", + OIDCGroups: groups, + Enabled: true, + }, nil +} + +func newLDAPConfig(setting *model.LDAPSetting) (ldapConfig, error) { + if setting == nil || !setting.Enabled { + return ldapConfig{}, ErrLDAPDisabled + } + + normalized := setting.Normalized() + if err := normalized.Validate(); err != nil { + return ldapConfig{}, ErrLDAPNotConfigured + } + + return ldapConfig{ + ServerURL: normalized.ServerURL, + UseStartTLS: normalized.UseStartTLS, + BindDN: normalized.BindDN, + BindPassword: string(normalized.BindPassword), + UserBaseDN: normalized.UserBaseDN, + UserFilter: normalized.UserFilter, + UsernameAttribute: normalized.UsernameAttribute, + DisplayNameAttribute: normalized.DisplayNameAttribute, + GroupBaseDN: normalized.GroupBaseDN, + GroupFilter: normalized.GroupFilter, + GroupNameAttribute: normalized.GroupNameAttribute, + }, nil +} + +func dialLDAP(cfg ldapConfig) (*ldap.Conn, *url.URL, error) { + parsedURL, err := url.Parse(cfg.ServerURL) + if err != nil { + return nil, nil, fmt.Errorf("invalid ldap url: %w", err) + } + if parsedURL.Scheme != "ldap" && parsedURL.Scheme != "ldaps" { + return nil, nil, fmt.Errorf("unsupported ldap scheme: %s", parsedURL.Scheme) + } + if parsedURL.Host == "" { + return nil, nil, errors.New("ldap host is empty") + } + + conn, err := ldap.DialURL( + cfg.ServerURL, + ldap.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}), + ) + if err != nil { + return nil, nil, fmt.Errorf("failed to connect to ldap: %w", err) + } + + return conn, parsedURL, nil +} + +func findLDAPUser(conn *ldap.Conn, cfg ldapConfig, username string) (*ldap.Entry, error) { + filter, err := formatLDAPFilter(cfg.UserFilter, ldap.EscapeFilter(strings.TrimSpace(username))) + if err != nil { + return nil, err + } + request := ldap.NewSearchRequest( + cfg.UserBaseDN, + ldap.ScopeWholeSubtree, + ldap.NeverDerefAliases, + 2, + 0, + false, + filter, + []string{cfg.UsernameAttribute, cfg.DisplayNameAttribute}, + nil, + ) + + result, err := conn.Search(request) + if err != nil { + return nil, fmt.Errorf("failed to search ldap user: %w", err) + } + if len(result.Entries) != 1 { + return nil, ErrLDAPInvalidCredentials + } + + return result.Entries[0], nil +} + +func findLDAPGroups(conn *ldap.Conn, cfg ldapConfig, userDN string) (model.SliceString, error) { + filter, err := formatLDAPFilter(cfg.GroupFilter, ldap.EscapeFilter(strings.TrimSpace(userDN))) + if err != nil { + return nil, err + } + request := ldap.NewSearchRequest( + cfg.GroupBaseDN, + ldap.ScopeWholeSubtree, + ldap.NeverDerefAliases, + 0, + 0, + false, + filter, + []string{cfg.GroupNameAttribute}, + nil, + ) + + result, err := conn.Search(request) + if err != nil { + return nil, fmt.Errorf("failed to search ldap groups: %w", err) + } + + seen := make(map[string]struct{}, len(result.Entries)) + groups := make([]string, 0, len(result.Entries)) + for _, entry := range result.Entries { + groupName := strings.TrimSpace(entry.GetAttributeValue(cfg.GroupNameAttribute)) + if groupName == "" { + continue + } + if _, exists := seen[groupName]; exists { + continue + } + seen[groupName] = struct{}{} + groups = append(groups, groupName) + } + sort.Strings(groups) + + return model.SliceString(groups), nil +} + +func formatLDAPFilter(template, value string) (string, error) { + if !model.HasExactlyOneLDAPPlaceholder(template) { + return "", ErrLDAPNotConfigured + } + return fmt.Sprintf(template, value), nil +} diff --git a/pkg/auth/ldap_setting_handler.go b/pkg/auth/ldap_setting_handler.go new file mode 100644 index 00000000..e534fc3b --- /dev/null +++ b/pkg/auth/ldap_setting_handler.go @@ -0,0 +1,122 @@ +package auth + +import ( + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/zxh326/kite/pkg/model" +) + +type UpdateLDAPSettingRequest struct { + Enabled *bool `json:"enabled"` + ServerURL *string `json:"serverUrl"` + UseStartTLS *bool `json:"useStartTLS"` + BindDN *string `json:"bindDn"` + BindPassword *string `json:"bindPassword"` + UserBaseDN *string `json:"userBaseDn"` + UserFilter *string `json:"userFilter"` + UsernameAttribute *string `json:"usernameAttribute"` + DisplayNameAttribute *string `json:"displayNameAttribute"` + GroupBaseDN *string `json:"groupBaseDn"` + GroupFilter *string `json:"groupFilter"` + GroupNameAttribute *string `json:"groupNameAttribute"` +} + +func (h *AuthHandler) GetLDAPSetting(c *gin.Context) { + setting, err := model.GetLDAPSetting() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to load ldap setting: %v", err)}) + return + } + + c.JSON(http.StatusOK, ldapSettingResponse(setting)) +} + +func (h *AuthHandler) UpdateLDAPSetting(c *gin.Context) { + var req UpdateLDAPSettingRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request: %v", err)}) + return + } + + currentSetting, err := model.GetLDAPSetting() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to load ldap setting: %v", err)}) + return + } + + updatedSetting := mergeLDAPSetting(currentSetting, req) + if err := updatedSetting.Validate(); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + updated, err := model.UpdateLDAPSetting(&updatedSetting) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update ldap setting: %v", err)}) + return + } + + c.JSON(http.StatusOK, ldapSettingResponse(updated)) +} + +func ldapSettingResponse(setting *model.LDAPSetting) gin.H { + return gin.H{ + "enabled": setting.Enabled, + "serverUrl": setting.ServerURL, + "useStartTLS": setting.UseStartTLS, + "bindDn": setting.BindDN, + "bindPassword": "", + "bindPasswordConfigured": setting.BindPasswordConfigured(), + "userBaseDn": setting.UserBaseDN, + "userFilter": setting.UserFilter, + "usernameAttribute": setting.UsernameAttribute, + "displayNameAttribute": setting.DisplayNameAttribute, + "groupBaseDn": setting.GroupBaseDN, + "groupFilter": setting.GroupFilter, + "groupNameAttribute": setting.GroupNameAttribute, + } +} + +func mergeLDAPSetting(current *model.LDAPSetting, req UpdateLDAPSettingRequest) model.LDAPSetting { + merged := current.Normalized() + if req.Enabled != nil { + merged.Enabled = *req.Enabled + } + if req.ServerURL != nil { + merged.ServerURL = strings.TrimSpace(*req.ServerURL) + } + if req.UseStartTLS != nil { + merged.UseStartTLS = *req.UseStartTLS + } + if req.BindDN != nil { + merged.BindDN = strings.TrimSpace(*req.BindDN) + } + if req.BindPassword != nil && *req.BindPassword != "" { + merged.BindPassword = model.SecretString(*req.BindPassword) + } + if req.UserBaseDN != nil { + merged.UserBaseDN = strings.TrimSpace(*req.UserBaseDN) + } + if req.UserFilter != nil { + merged.UserFilter = strings.TrimSpace(*req.UserFilter) + } + if req.UsernameAttribute != nil { + merged.UsernameAttribute = strings.TrimSpace(*req.UsernameAttribute) + } + if req.DisplayNameAttribute != nil { + merged.DisplayNameAttribute = strings.TrimSpace(*req.DisplayNameAttribute) + } + if req.GroupBaseDN != nil { + merged.GroupBaseDN = strings.TrimSpace(*req.GroupBaseDN) + } + if req.GroupFilter != nil { + merged.GroupFilter = strings.TrimSpace(*req.GroupFilter) + } + if req.GroupNameAttribute != nil { + merged.GroupNameAttribute = strings.TrimSpace(*req.GroupNameAttribute) + } + return merged.Normalized() +} diff --git a/pkg/model/ldap_setting.go b/pkg/model/ldap_setting.go new file mode 100644 index 00000000..0a7d3bbf --- /dev/null +++ b/pkg/model/ldap_setting.go @@ -0,0 +1,197 @@ +package model + +import ( + "errors" + "net/url" + "strings" + + "gorm.io/gorm" +) + +const DefaultLDAPUserFilter = "(uid=%s)" +const DefaultLDAPUsernameAttribute = "uid" +const DefaultLDAPDisplayNameAttribute = "cn" +const DefaultLDAPGroupFilter = "(member=%s)" +const DefaultLDAPGroupNameAttribute = "cn" + +type LDAPSetting struct { + Model + Enabled bool `json:"enabled" gorm:"column:enabled;type:boolean;not null;default:false"` + ServerURL string `json:"serverUrl" gorm:"column:server_url;type:varchar(500)"` + UseStartTLS bool `json:"useStartTLS" gorm:"column:use_starttls;type:boolean;not null;default:false"` + BindDN string `json:"bindDn" gorm:"column:bind_dn;type:varchar(500)"` + BindPassword SecretString `json:"bindPassword" gorm:"column:bind_password;type:text"` + UserBaseDN string `json:"userBaseDn" gorm:"column:user_base_dn;type:varchar(500)"` + UserFilter string `json:"userFilter" gorm:"column:user_filter;type:varchar(500);default:'(uid=%s)'"` + UsernameAttribute string `json:"usernameAttribute" gorm:"column:username_attribute;type:varchar(100);default:'uid'"` + DisplayNameAttribute string `json:"displayNameAttribute" gorm:"column:display_name_attribute;type:varchar(100);default:'cn'"` + GroupBaseDN string `json:"groupBaseDn" gorm:"column:group_base_dn;type:varchar(500)"` + GroupFilter string `json:"groupFilter" gorm:"column:group_filter;type:varchar(500);default:'(member=%s)'"` + GroupNameAttribute string `json:"groupNameAttribute" gorm:"column:group_name_attribute;type:varchar(100);default:'cn'"` +} + +func DefaultLDAPSetting() LDAPSetting { + return LDAPSetting{ + Model: Model{ID: 1}, + Enabled: false, + UseStartTLS: false, + UserFilter: DefaultLDAPUserFilter, + UsernameAttribute: DefaultLDAPUsernameAttribute, + DisplayNameAttribute: DefaultLDAPDisplayNameAttribute, + GroupFilter: DefaultLDAPGroupFilter, + GroupNameAttribute: DefaultLDAPGroupNameAttribute, + } +} + +func (s LDAPSetting) Normalized() LDAPSetting { + defaults := DefaultLDAPSetting() + normalized := defaults + normalized.Model = s.Model + if normalized.ID == 0 { + normalized.ID = defaults.ID + } + normalized.Enabled = s.Enabled + normalized.ServerURL = strings.TrimSpace(s.ServerURL) + normalized.UseStartTLS = s.UseStartTLS + normalized.BindDN = strings.TrimSpace(s.BindDN) + normalized.BindPassword = s.BindPassword + normalized.UserBaseDN = strings.TrimSpace(s.UserBaseDN) + normalized.UserFilter = normalizeLDAPTextWithDefault(s.UserFilter, DefaultLDAPUserFilter) + normalized.UsernameAttribute = normalizeLDAPTextWithDefault(s.UsernameAttribute, DefaultLDAPUsernameAttribute) + normalized.DisplayNameAttribute = normalizeLDAPTextWithDefault(s.DisplayNameAttribute, DefaultLDAPDisplayNameAttribute) + normalized.GroupBaseDN = strings.TrimSpace(s.GroupBaseDN) + normalized.GroupFilter = normalizeLDAPTextWithDefault(s.GroupFilter, DefaultLDAPGroupFilter) + normalized.GroupNameAttribute = normalizeLDAPTextWithDefault(s.GroupNameAttribute, DefaultLDAPGroupNameAttribute) + return normalized +} + +func (s LDAPSetting) Validate() error { + normalized := s.Normalized() + if !normalized.Enabled { + return nil + } + if normalized.ServerURL == "" { + return errors.New("serverUrl is required when enabled is true") + } + if err := validateLDAPServerURL(normalized.ServerURL); err != nil { + return err + } + if normalized.BindDN == "" { + return errors.New("bindDn is required when enabled is true") + } + if string(normalized.BindPassword) == "" { + return errors.New("bindPassword is required when enabled is true") + } + if normalized.UserBaseDN == "" { + return errors.New("userBaseDn is required when enabled is true") + } + if !HasExactlyOneLDAPPlaceholder(normalized.UserFilter) { + return errors.New("userFilter must contain exactly one %s") + } + if normalized.GroupBaseDN == "" { + return errors.New("groupBaseDn is required when enabled is true") + } + if !HasExactlyOneLDAPPlaceholder(normalized.GroupFilter) { + return errors.New("groupFilter must contain exactly one %s") + } + return nil +} + +func GetLDAPSetting() (*LDAPSetting, error) { + setting, err := getOrCreateLDAPSetting() + if err != nil { + return nil, err + } + normalized := setting.Normalized() + return &normalized, nil +} + +func UpdateLDAPSetting(setting *LDAPSetting) (*LDAPSetting, error) { + if setting == nil { + return nil, errors.New("ldap setting is nil") + } + current, err := getOrCreateLDAPSetting() + if err != nil { + return nil, err + } + normalized := setting.Normalized() + current.Enabled = normalized.Enabled + current.ServerURL = normalized.ServerURL + current.UseStartTLS = normalized.UseStartTLS + current.BindDN = normalized.BindDN + current.BindPassword = normalized.BindPassword + current.UserBaseDN = normalized.UserBaseDN + current.UserFilter = normalized.UserFilter + current.UsernameAttribute = normalized.UsernameAttribute + current.DisplayNameAttribute = normalized.DisplayNameAttribute + current.GroupBaseDN = normalized.GroupBaseDN + current.GroupFilter = normalized.GroupFilter + current.GroupNameAttribute = normalized.GroupNameAttribute + if err := DB.Save(current).Error; err != nil { + return nil, err + } + updated := current.Normalized() + return &updated, nil +} + +func (s *LDAPSetting) BindPasswordConfigured() bool { + if s == nil { + return false + } + return string(s.BindPassword) != "" +} + +func HasExactlyOneLDAPPlaceholder(template string) bool { + count := 0 + for i := 0; i < len(template); i++ { + if template[i] != '%' { + continue + } + if i+1 >= len(template) { + return false + } + switch template[i+1] { + case '%': + i++ + case 's': + count++ + i++ + default: + return false + } + } + return count == 1 +} + +func getOrCreateLDAPSetting() (*LDAPSetting, error) { + var setting LDAPSetting + err := DB.First(&setting, 1).Error + if err == nil { + return &setting, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + setting = DefaultLDAPSetting() + if err := DB.Create(&setting).Error; err != nil { + return nil, err + } + return &setting, nil +} + +func normalizeLDAPTextWithDefault(value, fallback string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return fallback + } + return trimmed +} + +func validateLDAPServerURL(serverURL string) error { + parsedURL, err := url.Parse(serverURL) + if err != nil || (parsedURL.Scheme != "ldap" && parsedURL.Scheme != "ldaps") || parsedURL.Host == "" { + return errors.New("serverUrl must be a valid ldap:// or ldaps:// URL") + } + return nil +} diff --git a/pkg/model/model.go b/pkg/model/model.go index d4c9551f..69a4438a 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -94,6 +94,7 @@ func InitDB() { User{}, Cluster{}, GeneralSetting{}, + LDAPSetting{}, OAuthProvider{}, Role{}, RoleAssignment{}, diff --git a/pkg/model/oauth.go b/pkg/model/oauth.go index 8f88e71c..f79b23ea 100644 --- a/pkg/model/oauth.go +++ b/pkg/model/oauth.go @@ -1,6 +1,30 @@ package model -import "strings" +import ( + "errors" + "strings" +) + +const AuthProviderPassword = "password" +const AuthProviderLDAP = "ldap" + +const ReservedOAuthProviderNamePassword = AuthProviderPassword +const ReservedOAuthProviderNameLDAP = AuthProviderLDAP + +var ErrReservedOAuthProviderName = errors.New("oauth provider names 'password' and 'ldap' are reserved for built-in credential providers") + +func NormalizeOAuthProviderName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +func IsReservedOAuthProviderName(name string) bool { + switch NormalizeOAuthProviderName(name) { + case ReservedOAuthProviderNamePassword, ReservedOAuthProviderNameLDAP: + return true + default: + return false + } +} type OAuthProvider struct { Model @@ -35,7 +59,7 @@ func GetEnabledOAuthProviders() ([]OAuthProvider, error) { // GetOAuthProviderByName returns an OAuth provider by name func GetOAuthProviderByName(name string) (OAuthProvider, error) { var provider OAuthProvider - name = strings.ToLower(name) + name = NormalizeOAuthProviderName(name) err := DB.Where("name = ? AND enabled = ?", name, true).First(&provider).Error if err != nil { return OAuthProvider{}, err @@ -45,11 +69,24 @@ func GetOAuthProviderByName(name string) (OAuthProvider, error) { // CreateOAuthProvider creates a new OAuth provider func CreateOAuthProvider(provider *OAuthProvider) error { + if IsReservedOAuthProviderName(string(provider.Name)) { + return ErrReservedOAuthProviderName + } return DB.Create(provider).Error } // UpdateOAuthProvider updates an existing OAuth provider func UpdateOAuthProvider(provider *OAuthProvider, updates map[string]interface{}) error { + name := string(provider.Name) + switch value := updates["name"].(type) { + case string: + name = value + case LowerCaseString: + name = string(value) + } + if IsReservedOAuthProviderName(name) { + return ErrReservedOAuthProviderName + } return DB.Model(provider).Updates(updates).Error } diff --git a/pkg/model/user.go b/pkg/model/user.go index db97ca2d..233a8aaf 100644 --- a/pkg/model/user.go +++ b/pkg/model/user.go @@ -3,6 +3,7 @@ package model import ( "errors" "fmt" + "strings" "time" expirable "github.com/hashicorp/golang-lru/v2/expirable" @@ -248,6 +249,80 @@ func CheckPassword(hashedPassword, plainPassword string) bool { return utils.CheckPasswordHash(plainPassword, hashedPassword) } +func UpsertLDAPUser(user *User) (*User, error) { + if user == nil { + return nil, errors.New("user is nil") + } + + user.Username = strings.TrimSpace(user.Username) + if user.Username == "" { + return nil, errors.New("username is empty") + } + + now := time.Now() + user.Provider = AuthProviderLDAP + user.Password = "" + user.LastLoginAt = &now + + var existingUser User + if err := DB.Where("username = ?", user.Username).First(&existingUser).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + user.Enabled = true + if strings.TrimSpace(user.Name) == "" { + user.Name = user.Username + } + err = DB.Create(user).Error + if err == nil { + InvalidateUserCache(uint64(user.ID)) + return user, nil + } + if !isUniqueConstraintError(err) { + return nil, err + } + if err := DB.Where("username = ?", user.Username).First(&existingUser).Error; err != nil { + return nil, err + } + } else { + return nil, err + } + } + + if existingUser.Provider != AuthProviderLDAP { + return nil, ErrUserProviderConflict + } + + user.ID = existingUser.ID + user.CreatedAt = existingUser.CreatedAt + user.Enabled = existingUser.Enabled + user.SidebarPreference = existingUser.SidebarPreference + user.Sub = existingUser.Sub + if strings.TrimSpace(user.Name) == "" { + user.Name = existingUser.Name + } + if strings.TrimSpace(user.AvatarURL) == "" { + user.AvatarURL = existingUser.AvatarURL + } + + err := DB.Save(user).Error + if err == nil { + InvalidateUserCache(uint64(user.ID)) + } + return user, err +} + +func isUniqueConstraintError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, gorm.ErrDuplicatedKey) { + return true + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "unique constraint failed") || + strings.Contains(message, "duplicate key value") || + strings.Contains(message, "duplicate entry") +} + func AddSuperUser(user *User) error { if user == nil { return errors.New("user is nil") @@ -277,6 +352,8 @@ func ListAPIKeyUsers() (users []User, err error) { } var ( + ErrUserProviderConflict = errors.New("user exists with different provider") + AnonymousUser = User{ Model: Model{ ID: 0, diff --git a/ui/src/components/global-search.tsx b/ui/src/components/global-search.tsx index 8ae4cd56..c2c65828 100644 --- a/ui/src/components/global-search.tsx +++ b/ui/src/components/global-search.tsx @@ -167,12 +167,12 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) { }, { id: 'oauth', - title: t('settings.tabs.oauth', 'OAuth'), + title: t('settings.tabs.oauth', 'Authentication'), url: '/settings?tab=oauth', Icon: IconSettings, groupLabel: 'Settings', searchText: - `${t('settings.tabs.oauth', 'OAuth')} settings oauth admin`.toLowerCase(), + `${t('settings.tabs.oauth', 'Authentication')} settings authentication ldap oauth admin`.toLowerCase(), isPinned: false, }, { diff --git a/ui/src/components/settings-hint.tsx b/ui/src/components/settings-hint.tsx index ee66674a..0dc875da 100644 --- a/ui/src/components/settings-hint.tsx +++ b/ui/src/components/settings-hint.tsx @@ -9,7 +9,12 @@ import { import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' -import { useClusterList, useOAuthProviderList, useRoleList } from '@/lib/api' +import { + useClusterList, + useLDAPSetting, + useOAuthProviderList, + useRoleList, +} from '@/lib/api' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { @@ -32,10 +37,12 @@ export function SettingsHint({ onDismiss }: SettingsHintProps) { const { data: clusters = [] } = useClusterList() const { data: oauthProviders = [] } = useOAuthProviderList() + const { data: ldapSetting } = useLDAPSetting({ staleTime: 30000 }) const { data: roles = [] } = useRoleList() const hasP8S = clusters.some((cluster) => !!cluster.prometheusURL) - const hasOAuthProviders = oauthProviders.length > 0 + const hasOAuthProviders = + oauthProviders.length > 0 || ldapSetting?.enabled === true const hasRoles = roles.length > 2 if ((hasP8S && hasOAuthProviders && hasRoles) || isDismissed) { @@ -59,10 +66,10 @@ export function SettingsHint({ onDismiss }: SettingsHintProps) { }, { key: 'oauth', - title: t('settings.tabs.oauth', 'OAuth'), + title: t('settings.tabs.oauth', 'Authentication'), description: t( 'settingsHint.oauth.description', - 'Set up OAuth providers for authentication' + 'Set up LDAP or OAuth authentication' ), icon: IconKey, completed: hasOAuthProviders, diff --git a/ui/src/components/settings/authentication-management.tsx b/ui/src/components/settings/authentication-management.tsx new file mode 100644 index 00000000..f24c2bf7 --- /dev/null +++ b/ui/src/components/settings/authentication-management.tsx @@ -0,0 +1,440 @@ +import { useEffect, useState } from 'react' +import { IconKey } from '@tabler/icons-react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +import { + LDAPSetting, + LDAPSettingUpdateRequest, + updateLDAPSetting, + useLDAPSetting, +} from '@/lib/api' +import { translateError } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' + +import { OAuthProviderManagement } from './oauth-provider-management' + +type AuthenticationFormData = LDAPSetting + +function createDefaultSettings(): AuthenticationFormData { + return { + enabled: false, + serverUrl: '', + useStartTLS: false, + bindDn: '', + bindPassword: '', + bindPasswordConfigured: false, + userBaseDn: '', + userFilter: '', + usernameAttribute: '', + displayNameAttribute: '', + groupBaseDn: '', + groupFilter: '', + groupNameAttribute: '', + } +} + +function toFormData(data?: LDAPSetting): AuthenticationFormData { + if (!data) { + return createDefaultSettings() + } + + return { + enabled: data.enabled ?? false, + serverUrl: data.serverUrl || '', + useStartTLS: data.useStartTLS ?? false, + bindDn: data.bindDn || '', + bindPassword: '', + bindPasswordConfigured: data.bindPasswordConfigured ?? false, + userBaseDn: data.userBaseDn || '', + userFilter: data.userFilter || '', + usernameAttribute: data.usernameAttribute || '', + displayNameAttribute: data.displayNameAttribute || '', + groupBaseDn: data.groupBaseDn || '', + groupFilter: data.groupFilter || '', + groupNameAttribute: data.groupNameAttribute || '', + } +} + +export function AuthenticationManagement() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const { data, error, isError, isLoading, refetch } = useLDAPSetting() + const [formData, setFormData] = useState( + createDefaultSettings + ) + + useEffect(() => { + setFormData(toFormData(data)) + }, [data]) + + const mutation = useMutation({ + mutationFn: updateLDAPSetting, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['ldap-setting'], + }) + toast.success( + t( + 'authenticationManagement.messages.updated', + 'Authentication settings updated' + ) + ) + }, + onError: (error) => { + toast.error(translateError(error, t)) + }, + }) + + const handleSave = () => { + if (!data) { + toast.error( + isError + ? translateError(error, t) + : t( + 'authenticationManagement.errors.loadFailed', + 'Failed to load LDAP settings' + ) + ) + return + } + + const payload: LDAPSettingUpdateRequest = { + enabled: formData.enabled, + serverUrl: formData.serverUrl.trim(), + useStartTLS: formData.useStartTLS, + bindDn: formData.bindDn.trim(), + userBaseDn: formData.userBaseDn.trim(), + userFilter: formData.userFilter.trim(), + usernameAttribute: formData.usernameAttribute.trim(), + displayNameAttribute: formData.displayNameAttribute.trim(), + groupBaseDn: formData.groupBaseDn.trim(), + groupFilter: formData.groupFilter.trim(), + groupNameAttribute: formData.groupNameAttribute.trim(), + } + if (formData.bindPassword !== '') { + payload.bindPassword = formData.bindPassword + } + + mutation.mutate(payload) + } + + if (isLoading && !data) { + return ( +
+
+ {t('common.loading', 'Loading...')} +
+
+ ) + } + + if (isError && !data) { + return ( + + + + + {t('authenticationManagement.title', 'Authentication')} + + + +
+ {translateError(error, t)} +
+ +
+
+ ) + } + + return ( +
+ + + + + {t('authenticationManagement.title', 'Authentication')} + + + + +
+
+
+ +

+ {t( + 'authenticationManagement.ldap.description', + 'Enable LDAP username/password login and sync groups into RBAC.' + )} +

+
+ + setFormData((prev) => ({ ...prev, enabled: checked })) + } + /> +
+ + {formData.enabled && ( +
+
+
+ + + setFormData((prev) => ({ + ...prev, + serverUrl: e.target.value, + })) + } + placeholder="ldaps://ldap.example.com:636" + /> +
+ +
+
+
+ +

+ {t( + 'authenticationManagement.ldap.form.useStartTLSHint', + 'Enable StartTLS when using ldap:// endpoints.' + )} +

+
+ + setFormData((prev) => ({ + ...prev, + useStartTLS: checked, + })) + } + /> +
+
+ +
+ + + setFormData((prev) => ({ + ...prev, + bindDn: e.target.value, + })) + } + placeholder="cn=svc-kite,ou=services,dc=example,dc=com" + /> +
+ +
+ + + setFormData((prev) => ({ + ...prev, + bindPassword: e.target.value, + })) + } + placeholder={ + formData.bindPasswordConfigured + ? t( + 'authenticationManagement.ldap.form.bindPasswordPlaceholder', + 'Leave empty to keep current bind password' + ) + : '' + } + /> +
+ +
+ + + setFormData((prev) => ({ + ...prev, + userBaseDn: e.target.value, + })) + } + placeholder="ou=users,dc=example,dc=com" + /> +
+ +
+ + + setFormData((prev) => ({ + ...prev, + userFilter: e.target.value, + })) + } + /> +
+ +
+ + + setFormData((prev) => ({ + ...prev, + usernameAttribute: e.target.value, + })) + } + /> +
+ +
+ + + setFormData((prev) => ({ + ...prev, + displayNameAttribute: e.target.value, + })) + } + /> +
+ +
+ + + setFormData((prev) => ({ + ...prev, + groupBaseDn: e.target.value, + })) + } + placeholder="ou=groups,dc=example,dc=com" + /> +
+ +
+ + + setFormData((prev) => ({ + ...prev, + groupFilter: e.target.value, + })) + } + /> +
+ +
+ + + setFormData((prev) => ({ + ...prev, + groupNameAttribute: e.target.value, + })) + } + /> +
+
+
+ )} +
+ +
+ +
+
+
+ + +
+ ) +} diff --git a/ui/src/components/settings/general-management.tsx b/ui/src/components/settings/general-management.tsx index 066de7c5..24c57b45 100644 --- a/ui/src/components/settings/general-management.tsx +++ b/ui/src/components/settings/general-management.tsx @@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { - GeneralSetting, GeneralSettingUpdateRequest, updateGeneralSetting, useGeneralSetting, @@ -29,11 +28,26 @@ const DEFAULT_ANTHROPIC_MODEL = 'claude-sonnet-4-5' const DEFAULT_KUBECTL_IMAGE = 'zzde/kubectl:latest' const DEFAULT_NODE_TERMINAL_IMAGE = 'busybox:latest' +interface GeneralSettingsFormData { + aiAgentEnabled: boolean + aiProvider: 'openai' | 'anthropic' + aiModel: string + aiApiKey: string + aiApiKeyConfigured: boolean + aiBaseUrl: string + aiMaxTokens: number + kubectlEnabled: boolean + kubectlImage: string + nodeTerminalImage: string + enableAnalytics: boolean + enableVersionCheck: boolean +} + export function GeneralManagement() { const { t } = useTranslation() const queryClient = useQueryClient() const { data, isLoading } = useGeneralSetting() - const [formData, setFormData] = useState({ + const [formData, setFormData] = useState({ aiAgentEnabled: false, aiProvider: 'openai', aiModel: DEFAULT_MODEL, diff --git a/ui/src/contexts/auth-context.tsx b/ui/src/contexts/auth-context.tsx index 5ff30059..78e525cf 100644 --- a/ui/src/contexts/auth-context.tsx +++ b/ui/src/contexts/auth-context.tsx @@ -7,6 +7,7 @@ import { useState, } from 'react' +import type { AuthProviderCatalog, CredentialProvider } from '@/lib/api' import { withSubPath } from '@/lib/subpath' interface User { @@ -26,9 +27,14 @@ interface User { interface AuthContextType { user: User | null isLoading: boolean - providers: string[] + credentialProviders: CredentialProvider[] + oauthProviders: string[] login: (provider?: string) => Promise - loginWithPassword: (username: string, password: string) => Promise + loginWithCredentials: ( + provider: CredentialProvider, + username: string, + password: string + ) => Promise logout: () => Promise checkAuth: () => Promise refreshToken: () => Promise @@ -51,17 +57,35 @@ interface AuthProviderProps { export function AuthProvider({ children }: AuthProviderProps) { const [user, setUser] = useState(null) const [isLoading, setIsLoading] = useState(true) - const [providers, setProviders] = useState([]) + const [credentialProviders, setCredentialProviders] = useState< + CredentialProvider[] + >([]) + const [oauthProviders, setOAuthProviders] = useState([]) const loadProviders = async () => { try { const response = await fetch(withSubPath('/api/auth/providers')) if (response.ok) { - const data = await response.json() - setProviders(data.providers || []) + const data = (await response.json()) as Partial + if (data.credentialProviders || data.oauthProviders) { + setCredentialProviders(data.credentialProviders || []) + setOAuthProviders(data.oauthProviders || []) + return + } + + const providers = data.providers || [] + const fallbackCredentialProviders = providers.filter( + (provider): provider is CredentialProvider => + provider === 'password' || provider === 'ldap' + ) + const fallbackOAuthProviders = providers.filter( + (provider) => provider !== 'password' && provider !== 'ldap' + ) + setCredentialProviders(fallbackCredentialProviders) + setOAuthProviders(fallbackOAuthProviders) } } catch (error) { - console.error('Failed to load OAuth providers:', error) + console.error('Failed to load authentication providers:', error) } } @@ -117,9 +141,13 @@ export function AuthProvider({ children }: AuthProviderProps) { } } - const loginWithPassword = async (username: string, password: string) => { + const loginWithCredentials = async ( + provider: CredentialProvider, + username: string, + password: string + ) => { try { - const response = await fetch(withSubPath('/api/auth/login/password'), { + const response = await fetch(withSubPath(`/api/auth/login/${provider}`), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -132,10 +160,10 @@ export function AuthProvider({ children }: AuthProviderProps) { await checkAuth() } else { const errorData = await response.json() - throw new Error(errorData.error || 'Password login failed') + throw new Error(errorData.error || `${provider} login failed`) } } catch (error) { - console.error('Password login failed:', error) + console.error(`${provider} login failed:`, error) throw error } } @@ -211,9 +239,10 @@ export function AuthProvider({ children }: AuthProviderProps) { const value = { user, isLoading, - providers, + credentialProviders, + oauthProviders, login, - loginWithPassword, + loginWithCredentials, logout, checkAuth, refreshToken, diff --git a/ui/src/i18n/locales/en.json b/ui/src/i18n/locales/en.json index b0bfca8a..79de5240 100644 --- a/ui/src/i18n/locales/en.json +++ b/ui/src/i18n/locales/en.json @@ -195,6 +195,11 @@ "enterPassword": "Enter your password", "signingIn": "Signing in...", "signInWithPassword": "Sign In with Password", + "signInWithLdap": "Sign In with LDAP", + "tabs": { + "password": "Password", + "ldap": "LDAP" + }, "orContinueWith": "Or continue with", "signInWith": "Sign in with {{provider}}", "tryAgainDifferentAccount": "Try Again with Different Account", @@ -271,7 +276,7 @@ "tabs": { "clusters": "Cluster", "general": "General", - "oauth": "OAuth", + "oauth": "Authentication", "rbac": "RBAC", "users": "User" } @@ -322,6 +327,40 @@ "updated": "General settings updated" } }, + "authenticationManagement": { + "title": "Authentication", + "ldap": { + "title": "LDAP", + "description": "Enable LDAP username/password login and sync groups into RBAC.", + "form": { + "serverUrl": "Server URL", + "useStartTLS": "Use StartTLS", + "useStartTLSHint": "Enable StartTLS when using ldap:// endpoints.", + "bindDn": "Bind DN", + "bindPassword": "Bind Password", + "bindPasswordPlaceholder": "Leave empty to keep current bind password", + "userBaseDn": "User Base DN", + "userFilter": "User Filter", + "usernameAttribute": "Username Attribute", + "displayNameAttribute": "Display Name Attribute", + "groupBaseDn": "Group Base DN", + "groupFilter": "Group Filter", + "groupNameAttribute": "Group Name Attribute" + } + }, + "errors": { + "serverUrlRequired": "LDAP server URL is required", + "bindDnRequired": "LDAP bind DN is required", + "bindPasswordRequired": "LDAP bind password is required", + "userBaseDnRequired": "LDAP user base DN is required", + "groupBaseDnRequired": "LDAP group base DN is required", + "userFilterPlaceholder": "LDAP user filter must contain exactly one %s", + "groupFilterPlaceholder": "LDAP group filter must contain exactly one %s" + }, + "messages": { + "updated": "Authentication settings updated" + } + }, "settingsHint": { "title": "Complete Your Setup", "description": "Configure essential settings to get the most out of Kite", @@ -329,7 +368,7 @@ "description": "Configure Prometheus" }, "oauth": { - "description": "Set up OAuth providers for authentication" + "description": "Set up LDAP or OAuth authentication" }, "rbac": { "description": "Configure roles and permissions" diff --git a/ui/src/i18n/locales/zh.json b/ui/src/i18n/locales/zh.json index 5538ca15..6834f480 100644 --- a/ui/src/i18n/locales/zh.json +++ b/ui/src/i18n/locales/zh.json @@ -213,6 +213,11 @@ "enterPassword": "请输入密码", "signingIn": "登录中...", "signInWithPassword": "使用密码登录", + "signInWithLdap": "使用 LDAP 登录", + "tabs": { + "password": "密码", + "ldap": "LDAP" + }, "orContinueWith": "或继续使用", "signInWith": "使用 {{provider}} 登录", "tryAgainDifferentAccount": "尝试使用其他账户", @@ -294,7 +299,7 @@ "tabs": { "clusters": "集群", "general": "通用", - "oauth": "OAuth", + "oauth": "认证", "rbac": "权限控制", "users": "用户", "apikeys": "API 密钥" @@ -346,6 +351,40 @@ "updated": "通用设置已更新" } }, + "authenticationManagement": { + "title": "认证", + "ldap": { + "title": "LDAP", + "description": "启用 LDAP 用户名密码登录,并将 LDAP 组同步到 RBAC。", + "form": { + "serverUrl": "服务地址", + "useStartTLS": "启用 StartTLS", + "useStartTLSHint": "当使用 ldap:// 地址时启用 StartTLS。", + "bindDn": "绑定 DN", + "bindPassword": "绑定密码", + "bindPasswordPlaceholder": "留空将保持当前绑定密码", + "userBaseDn": "用户 Base DN", + "userFilter": "用户过滤器", + "usernameAttribute": "用户名属性", + "displayNameAttribute": "显示名属性", + "groupBaseDn": "用户组 Base DN", + "groupFilter": "用户组过滤器", + "groupNameAttribute": "用户组名称属性" + } + }, + "errors": { + "serverUrlRequired": "LDAP 服务地址不能为空", + "bindDnRequired": "LDAP 绑定 DN 不能为空", + "bindPasswordRequired": "LDAP 绑定密码不能为空", + "userBaseDnRequired": "LDAP 用户 Base DN 不能为空", + "groupBaseDnRequired": "LDAP 用户组 Base DN 不能为空", + "userFilterPlaceholder": "LDAP 用户过滤器必须且只能包含一个 %s", + "groupFilterPlaceholder": "LDAP 用户组过滤器必须且只能包含一个 %s" + }, + "messages": { + "updated": "认证设置已更新" + } + }, "settingsHint": { "title": "完成设置", "description": "配置基本设置以获得最佳体验", @@ -353,7 +392,7 @@ "description": "配置 Prometheus" }, "oauth": { - "description": "设置 OAuth 提供商进行身份验证" + "description": "配置 LDAP 或 OAuth 认证" }, "rbac": { "description": "配置角色和权限" diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index c83e596b..7080ca10 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -1758,17 +1758,56 @@ export interface GeneralSetting { } export interface GeneralSettingUpdateRequest { - aiAgentEnabled: boolean - aiProvider: 'openai' | 'anthropic' - aiModel: string + aiAgentEnabled?: boolean + aiProvider?: 'openai' | 'anthropic' + aiModel?: string aiApiKey?: string - aiBaseUrl: string - aiMaxTokens: number - kubectlEnabled: boolean - kubectlImage: string - nodeTerminalImage: string - enableAnalytics: boolean - enableVersionCheck: boolean + aiBaseUrl?: string + aiMaxTokens?: number + kubectlEnabled?: boolean + kubectlImage?: string + nodeTerminalImage?: string + enableAnalytics?: boolean + enableVersionCheck?: boolean +} + +export type CredentialProvider = 'password' | 'ldap' + +export interface AuthProviderCatalog { + providers: string[] + credentialProviders: CredentialProvider[] + oauthProviders: string[] +} + +export interface LDAPSetting { + enabled: boolean + serverUrl: string + useStartTLS: boolean + bindDn: string + bindPassword: string + bindPasswordConfigured: boolean + userBaseDn: string + userFilter: string + usernameAttribute: string + displayNameAttribute: string + groupBaseDn: string + groupFilter: string + groupNameAttribute: string +} + +export interface LDAPSettingUpdateRequest { + enabled: boolean + serverUrl: string + useStartTLS: boolean + bindDn: string + bindPassword?: string + userBaseDn: string + userFilter: string + usernameAttribute: string + displayNameAttribute: string + groupBaseDn: string + groupFilter: string + groupNameAttribute: string } export const fetchGeneralSetting = async (): Promise => { @@ -1793,6 +1832,28 @@ export const updateGeneralSetting = async ( return await apiClient.put('/admin/general-setting/', data) } +export const fetchLDAPSetting = async (): Promise => { + return fetchAPI('/admin/ldap-setting/') +} + +export const useLDAPSetting = (options?: { + staleTime?: number + enabled?: boolean +}) => { + return useQuery({ + queryKey: ['ldap-setting'], + queryFn: fetchLDAPSetting, + enabled: options?.enabled ?? true, + staleTime: options?.staleTime || 30000, + }) +} + +export const updateLDAPSetting = async ( + data: LDAPSettingUpdateRequest +): Promise => { + return await apiClient.put('/admin/ldap-setting/', data) +} + export const fetchAPIKeyList = async (): Promise => { return fetchAPI<{ apiKeys: APIKey[] }>('/admin/apikeys/').then( (response) => response.apiKeys diff --git a/ui/src/pages/login.tsx b/ui/src/pages/login.tsx index 2813c546..a598ee05 100644 --- a/ui/src/pages/login.tsx +++ b/ui/src/pages/login.tsx @@ -1,9 +1,10 @@ -import { FormEvent, useState } from 'react' +import { FormEvent, useEffect, useState } from 'react' import Logo from '@/assets/icon.svg' import { useAuth } from '@/contexts/auth-context' import { useTranslation } from 'react-i18next' import { Navigate, useSearchParams } from 'react-router-dom' +import type { CredentialProvider } from '@/lib/api' import { withSubPath } from '@/lib/subpath' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' @@ -16,19 +17,39 @@ import { } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Footer } from '@/components/footer' import { LanguageToggle } from '@/components/language-toggle' export function LoginPage() { const { t } = useTranslation() - const { user, login, loginWithPassword, providers, isLoading } = useAuth() + const { + user, + login, + loginWithCredentials, + credentialProviders, + oauthProviders, + isLoading, + } = useAuth() const [searchParams] = useSearchParams() const [loginLoading, setLoginLoading] = useState(null) const [username, setUsername] = useState('') const [password, setPassword] = useState('') - const [passwordError, setPasswordError] = useState(null) + const [credentialError, setCredentialError] = useState(null) + const [credentialsProvider, setCredentialsProvider] = + useState('password') const error = searchParams.get('error') + const totalProviders = credentialProviders.length + oauthProviders.length + + useEffect(() => { + if ( + credentialProviders.length > 0 && + !credentialProviders.includes(credentialsProvider) + ) { + setCredentialsProvider(credentialProviders[0]) + } + }, [credentialProviders, credentialsProvider]) if (user && !isLoading) { return @@ -44,27 +65,37 @@ export function LoginPage() { } } - const handlePasswordLogin = async (e: FormEvent) => { + const handleCredentialLogin = async (e: FormEvent) => { e.preventDefault() - setLoginLoading('password') - setPasswordError(null) + setLoginLoading(credentialsProvider) + setCredentialError(null) try { - await loginWithPassword(username, password) + await loginWithCredentials(credentialsProvider, username, password) } catch (err) { if (err instanceof Error) { - setPasswordError( + setCredentialError( t(`login.errors.${err.message}`, { defaultValue: err.message, }) || t('login.errors.invalidCredentials') ) } else { - setPasswordError(t('login.errors.unknownError')) + setCredentialError(t('login.errors.unknownError')) } } finally { setLoginLoading(null) } } + const credentialTabLabel = { + password: t('login.tabs.password', 'Password'), + ldap: t('login.tabs.ldap', 'LDAP'), + } satisfies Record + + const credentialSubmitLabel = { + password: t('login.signInWithPassword', 'Sign In with Password'), + ldap: t('login.signInWithLdap', 'Sign In with LDAP'), + } satisfies Record + const getErrorMessage = (errorCode: string | null) => { if (!errorCode) return null @@ -200,7 +231,7 @@ export function LoginPage() { )} - {providers.length === 0 ? ( + {totalProviders === 0 ? (

{t('login.noLoginMethods')}

@@ -209,54 +240,91 @@ export function LoginPage() {

) : (
- {providers.includes('password') && ( -
-
- - setUsername(e.target.value)} - required - /> -
-
- - setPassword(e.target.value)} - required - /> -
- {passwordError && ( - - {passwordError} - + {credentialProviders.length > 0 && ( +
+ {credentialProviders.length > 1 && ( + { + if (value === 'password' || value === 'ldap') { + setCredentialsProvider(value) + setCredentialError(null) + } + }} + > + 1 + ? 'grid-cols-2' + : 'grid-cols-1' + }`} + > + {credentialProviders.map((provider) => ( + + {credentialTabLabel[provider]} + + ))} + + )} - - + + +
)} - {providers.filter((p) => p !== 'password').length > 0 && - providers.includes('password') && ( + {oauthProviders.length > 0 && + credentialProviders.length > 0 && (
@@ -269,34 +337,32 @@ export function LoginPage() {
)} - {providers - .filter((p) => p !== 'password') - .map((provider) => ( - - ))} + {oauthProviders.map((provider) => ( + + ))}
)} diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index e6203ade..92700989 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -4,9 +4,9 @@ import { usePageTitle } from '@/hooks/use-page-title' import { ResponsiveTabs } from '@/components/ui/responsive-tabs' import { APIKeyManagement } from '@/components/settings/apikey-management' import { AuditLog } from '@/components/settings/audit-log' +import { AuthenticationManagement } from '@/components/settings/authentication-management' import { ClusterManagement } from '@/components/settings/cluster-management' import { GeneralManagement } from '@/components/settings/general-management' -import { OAuthProviderManagement } from '@/components/settings/oauth-provider-management' import { RBACManagement } from '@/components/settings/rbac-management' import { TemplateManagement } from '@/components/settings/template-management' import { UserManagement } from '@/components/settings/user-management' @@ -41,8 +41,8 @@ export function SettingsPage() { }, { value: 'oauth', - label: t('settings.tabs.oauth', 'OAuth'), - content: , + label: t('settings.tabs.oauth', 'Authentication'), + content: , }, { value: 'rbac',