From 8a7b0e18ed1b37ce6492899fe206624ea30171b1 Mon Sep 17 00:00:00 2001 From: Anders Holck Date: Wed, 27 May 2026 00:08:00 +0200 Subject: [PATCH] LDAP admin GUI + group filter - LDAP settings configurable from Admin panel (no restart needed) - Required group filter: only users in specified group can login - Supports both memberOf attribute and groupOfNames search - Settings stored in DB (settings table), env vars as fallback - SLDAP supported via ldaps:// URL - Bind password masked in UI --- frontend/src/App.vue | 43 ++++++++++++++++++++++- internal/api/handlers.go | 2 +- internal/api/router.go | 2 ++ internal/api/settings.go | 55 ++++++++++++++++++++++++++++++ internal/auth/ldap.go | 73 +++++++++++++++++++++++++++++++--------- internal/db/db.go | 4 +++ 6 files changed, 161 insertions(+), 18 deletions(-) create mode 100644 internal/api/settings.go diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1a0075a..355a116 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -255,6 +255,26 @@ +
+

LDAP / SLDAP Authentication

+
+ + + + + + + + + + + + + + +
+

{{ ldapMsg }}

+
@@ -380,6 +400,9 @@ const newRemote = ref({ name: '', url: '' }) const users = ref([]) const newUser = ref({ username: '', email: '', password: '', isAdmin: false }) const adminMsg = ref('') +const ldapSettings = ref({ ldap_url: '', ldap_bind_dn: '', ldap_bind_pass: '', ldap_base_dn: '', ldap_user_filter: '', ldap_group_filter: '' }) +const ldapSkipTLS = ref(false) +const ldapMsg = ref('') const rendered = computed(() => renderMarkdown(content.value)) @@ -404,7 +427,7 @@ async function login() { loadShared() loadRemotes() syncPending() - if (isAdmin.value) loadUsers() + if (isAdmin.value) { loadUsers(); loadLdapSettings() } // Sync pending changes when coming back online window.addEventListener('online', syncPending) } catch (e) { @@ -777,6 +800,24 @@ async function adminCreateUser() { } } +async function loadLdapSettings() { + try { + ldapSettings.value = await api('/api/admin/settings/get', {}) + ldapSkipTLS.value = ldapSettings.value.ldap_skip_tls === 'true' + } catch {} +} + +async function saveLdapSettings() { + const data = { ...ldapSettings.value, ldap_skip_tls: ldapSkipTLS.value ? 'true' : 'false' } + try { + await api('/api/admin/settings/save', data) + ldapMsg.value = 'LDAP settings saved' + toast('LDAP settings saved', 'success') + } catch { + ldapMsg.value = 'Failed to save' + } +} + function formatDate(d) { if (!d) return '' try { diff --git a/internal/api/handlers.go b/internal/api/handlers.go index e840e40..2ac5a3c 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -95,7 +95,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { // Try LDAP if local auth failed if !localAuthOK { - ldapCfg := auth.LDAPConfigFromEnv() + ldapCfg := auth.LDAPConfigFromDB(s.db) if ldapCfg != nil { email, displayName, ldapErr := auth.LDAPAuth(ldapCfg, req.Email, req.Password) if ldapErr == nil { diff --git a/internal/api/router.go b/internal/api/router.go index e1d7cf0..ce921e4 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -73,6 +73,8 @@ func NewRouter(db *sql.DB, dataDir, secret string) http.Handler { mux.HandleFunc("POST /api/build/cancel", s.requireAuth(s.handleBuildCancel)) // Daemon endpoints (admin only) + mux.HandleFunc("POST /api/admin/settings/get", s.requireAdmin(s.handleGetSettings)) + mux.HandleFunc("POST /api/admin/settings/save", s.requireAdmin(s.handleSaveSettings)) mux.HandleFunc("POST /api/daemon/poll", s.requireAdmin(s.handleDaemonPoll)) mux.HandleFunc("POST /api/daemon/heartbeat", s.requireAdmin(s.handleDaemonHeartbeat)) mux.HandleFunc("POST /api/daemon/report", s.requireAdmin(s.handleDaemonReport)) diff --git a/internal/api/settings.go b/internal/api/settings.go new file mode 100644 index 0000000..7c7d3f8 --- /dev/null +++ b/internal/api/settings.go @@ -0,0 +1,55 @@ +package api + +import ( + "net/http" +) + +func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) { + keys := []string{ + "ldap_url", "ldap_bind_dn", "ldap_bind_pass", "ldap_base_dn", + "ldap_user_filter", "ldap_group_filter", "ldap_skip_tls", + } + result := make(map[string]string) + for _, k := range keys { + var val string + s.db.QueryRow("SELECT value FROM settings WHERE key = ?", k).Scan(&val) + // Don't expose bind password + if k == "ldap_bind_pass" && val != "" { + result[k] = "••••••••" + } else { + result[k] = val + } + } + writeJSON(w, 200, result) +} + +func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) { + var req map[string]string + if err := decodeBody(r, &req); err != nil { + writeJSON(w, 400, map[string]string{"error": "invalid request"}) + return + } + + allowed := map[string]bool{ + "ldap_url": true, "ldap_bind_dn": true, "ldap_bind_pass": true, + "ldap_base_dn": true, "ldap_user_filter": true, "ldap_group_filter": true, + "ldap_skip_tls": true, + } + + for k, v := range req { + if !allowed[k] { + continue + } + // Don't overwrite password with masked value + if k == "ldap_bind_pass" && v == "••••••••" { + continue + } + s.db.Exec( + `INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?`, + k, v, v, + ) + } + + s.audit(getUserID(r), "save_settings", "ldap") + writeJSON(w, 200, map[string]string{"status": "saved"}) +} diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index 56048eb..4538cb9 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -2,37 +2,55 @@ package auth import ( "crypto/tls" + "database/sql" "fmt" "os" + "strings" "github.com/go-ldap/ldap/v3" ) type LDAPConfig struct { - URL string // ldap://host:389 or ldaps://host:636 - BindDN string // cn=admin,dc=example,dc=com (for search) - BindPass string - BaseDN string // dc=example,dc=com - UserFilter string // (&(objectClass=inetOrgPerson)(uid=%s)) - SkipTLS bool + URL string + BindDN string + BindPass string + BaseDN string + UserFilter string + GroupFilter string // e.g. cn=markdownhub-users,ou=groups,dc=example,dc=com + SkipTLS bool } -func LDAPConfigFromEnv() *LDAPConfig { - url := os.Getenv("MH_LDAP_URL") +// LDAPConfigFromDB reads LDAP settings from the settings table, falling back to env vars. +func LDAPConfigFromDB(db *sql.DB) *LDAPConfig { + get := func(key, envKey string) string { + var val string + if db != nil { + db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&val) + } + if val == "" { + val = os.Getenv(envKey) + } + return val + } + + url := get("ldap_url", "MH_LDAP_URL") if url == "" { return nil } - filter := os.Getenv("MH_LDAP_USER_FILTER") + + filter := get("ldap_user_filter", "MH_LDAP_USER_FILTER") if filter == "" { filter = "(&(objectClass=inetOrgPerson)(uid=%s))" } + return &LDAPConfig{ - URL: url, - BindDN: os.Getenv("MH_LDAP_BIND_DN"), - BindPass: os.Getenv("MH_LDAP_BIND_PASS"), - BaseDN: os.Getenv("MH_LDAP_BASE_DN"), - UserFilter: filter, - SkipTLS: os.Getenv("MH_LDAP_SKIP_TLS") == "true", + URL: url, + BindDN: get("ldap_bind_dn", "MH_LDAP_BIND_DN"), + BindPass: get("ldap_bind_pass", "MH_LDAP_BIND_PASS"), + BaseDN: get("ldap_base_dn", "MH_LDAP_BASE_DN"), + UserFilter: filter, + GroupFilter: get("ldap_group_filter", "MH_LDAP_GROUP_FILTER"), + SkipTLS: get("ldap_skip_tls", "MH_LDAP_SKIP_TLS") == "true", } } @@ -56,7 +74,7 @@ func LDAPAuth(cfg *LDAPConfig, username, password string) (string, string, error filter := fmt.Sprintf(cfg.UserFilter, ldap.EscapeFilter(username)) sr, err := conn.Search(ldap.NewSearchRequest( cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 10, false, - filter, []string{"dn", "mail", "cn", "uid"}, nil, + filter, []string{"dn", "mail", "cn", "uid", "memberOf"}, nil, )) if err != nil || len(sr.Entries) == 0 { return "", "", fmt.Errorf("ldap user not found") @@ -65,6 +83,29 @@ func LDAPAuth(cfg *LDAPConfig, username, password string) (string, string, error entry := sr.Entries[0] userDN := entry.DN + // Check group membership if configured + if cfg.GroupFilter != "" { + memberOf := entry.GetAttributeValues("memberOf") + found := false + for _, g := range memberOf { + if strings.EqualFold(g, cfg.GroupFilter) { + found = true + break + } + } + if !found { + // Also try group search (for posixGroup style) + groupFilter := fmt.Sprintf("(&(objectClass=groupOfNames)(member=%s)(cn=%s))", ldap.EscapeFilter(userDN), ldap.EscapeFilter(cfg.GroupFilter)) + gsr, _ := conn.Search(ldap.NewSearchRequest( + cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 5, false, + groupFilter, []string{"dn"}, nil, + )) + if gsr == nil || len(gsr.Entries) == 0 { + return "", "", fmt.Errorf("user not in required group") + } + } + } + // Bind as user to verify password if err := conn.Bind(userDN, password); err != nil { return "", "", fmt.Errorf("ldap auth failed") diff --git a/internal/db/db.go b/internal/db/db.go index b42d719..7804044 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -43,6 +43,10 @@ func Migrate(database *DB) error { detail TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) )`) + database.Exec(`CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )`) return nil }