Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
22 changes: 22 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand All @@ -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=
Expand Down
7 changes: 7 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
144 changes: 136 additions & 8 deletions pkg/auth/handler.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package auth

import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
Expand All @@ -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,
})
}

Expand Down Expand Up @@ -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})
Comment on lines +110 to +113

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep credential login errors generic for clients

The response now echoes provider, username, and the raw backend error string to the client on both auth failures and server failures. This leaks internal details (for example DB/LDAP error text) and exposes attempted usernames, and it also breaks the frontend's login.errors.invalidCredentials mapping because the error key is no longer stable. Keep the detailed string in logs, but return a fixed client-safe message.

Useful? React with 👍 / 👎.

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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -418,15 +519,29 @@ 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{
"error": "Name, ClientID, and ClientSecret are required",
})
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(),
})
Expand Down Expand Up @@ -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 == "" {
Expand All @@ -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,
Expand All @@ -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(),
})
Expand Down
Loading
Loading