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
}