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
This commit is contained in:
2026-05-27 00:08:00 +02:00
parent f58ac04069
commit 8a7b0e18ed
6 changed files with 161 additions and 18 deletions
+42 -1
View File
@@ -255,6 +255,26 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="panel-section">
<h3>LDAP / SLDAP Authentication</h3>
<form @submit.prevent="saveLdapSettings" class="admin-form">
<label>Server URL</label>
<input v-model="ldapSettings.ldap_url" placeholder="ldap://host:389 or ldaps://host:636" />
<label>Bind DN</label>
<input v-model="ldapSettings.ldap_bind_dn" placeholder="cn=service,dc=example,dc=com" />
<label>Bind Password</label>
<input v-model="ldapSettings.ldap_bind_pass" type="password" placeholder="Service account password" />
<label>Base DN</label>
<input v-model="ldapSettings.ldap_base_dn" placeholder="dc=example,dc=com" />
<label>User Filter</label>
<input v-model="ldapSettings.ldap_user_filter" placeholder="(&(objectClass=inetOrgPerson)(uid=%s))" />
<label>Required Group (DN or CN)</label>
<input v-model="ldapSettings.ldap_group_filter" placeholder="cn=markdownhub-users,ou=groups,dc=..." />
<label><input type="checkbox" v-model="ldapSkipTLS" /> Skip TLS verification</label>
<button type="submit">Save LDAP Settings</button>
</form>
<p v-if="ldapMsg" class="admin-msg">{{ ldapMsg }}</p>
</div>
</main> </main>
<!-- About --> <!-- About -->
<main class="panel" v-if="view === 'about'" style="text-align:center;padding-top:80px"> <main class="panel" v-if="view === 'about'" style="text-align:center;padding-top:80px">
@@ -380,6 +400,9 @@ const newRemote = ref({ name: '', url: '' })
const users = ref([]) const users = ref([])
const newUser = ref({ username: '', email: '', password: '', isAdmin: false }) const newUser = ref({ username: '', email: '', password: '', isAdmin: false })
const adminMsg = ref('') 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)) const rendered = computed(() => renderMarkdown(content.value))
@@ -404,7 +427,7 @@ async function login() {
loadShared() loadShared()
loadRemotes() loadRemotes()
syncPending() syncPending()
if (isAdmin.value) loadUsers() if (isAdmin.value) { loadUsers(); loadLdapSettings() }
// Sync pending changes when coming back online // Sync pending changes when coming back online
window.addEventListener('online', syncPending) window.addEventListener('online', syncPending)
} catch (e) { } 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) { function formatDate(d) {
if (!d) return '' if (!d) return ''
try { try {
+1 -1
View File
@@ -95,7 +95,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
// Try LDAP if local auth failed // Try LDAP if local auth failed
if !localAuthOK { if !localAuthOK {
ldapCfg := auth.LDAPConfigFromEnv() ldapCfg := auth.LDAPConfigFromDB(s.db)
if ldapCfg != nil { if ldapCfg != nil {
email, displayName, ldapErr := auth.LDAPAuth(ldapCfg, req.Email, req.Password) email, displayName, ldapErr := auth.LDAPAuth(ldapCfg, req.Email, req.Password)
if ldapErr == nil { if ldapErr == nil {
+2
View File
@@ -73,6 +73,8 @@ func NewRouter(db *sql.DB, dataDir, secret string) http.Handler {
mux.HandleFunc("POST /api/build/cancel", s.requireAuth(s.handleBuildCancel)) mux.HandleFunc("POST /api/build/cancel", s.requireAuth(s.handleBuildCancel))
// Daemon endpoints (admin only) // 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/poll", s.requireAdmin(s.handleDaemonPoll))
mux.HandleFunc("POST /api/daemon/heartbeat", s.requireAdmin(s.handleDaemonHeartbeat)) mux.HandleFunc("POST /api/daemon/heartbeat", s.requireAdmin(s.handleDaemonHeartbeat))
mux.HandleFunc("POST /api/daemon/report", s.requireAdmin(s.handleDaemonReport)) mux.HandleFunc("POST /api/daemon/report", s.requireAdmin(s.handleDaemonReport))
+55
View File
@@ -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"})
}
+53 -12
View File
@@ -2,37 +2,55 @@ package auth
import ( import (
"crypto/tls" "crypto/tls"
"database/sql"
"fmt" "fmt"
"os" "os"
"strings"
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
) )
type LDAPConfig struct { type LDAPConfig struct {
URL string // ldap://host:389 or ldaps://host:636 URL string
BindDN string // cn=admin,dc=example,dc=com (for search) BindDN string
BindPass string BindPass string
BaseDN string // dc=example,dc=com BaseDN string
UserFilter string // (&(objectClass=inetOrgPerson)(uid=%s)) UserFilter string
GroupFilter string // e.g. cn=markdownhub-users,ou=groups,dc=example,dc=com
SkipTLS bool SkipTLS bool
} }
func LDAPConfigFromEnv() *LDAPConfig { // LDAPConfigFromDB reads LDAP settings from the settings table, falling back to env vars.
url := os.Getenv("MH_LDAP_URL") 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 == "" { if url == "" {
return nil return nil
} }
filter := os.Getenv("MH_LDAP_USER_FILTER")
filter := get("ldap_user_filter", "MH_LDAP_USER_FILTER")
if filter == "" { if filter == "" {
filter = "(&(objectClass=inetOrgPerson)(uid=%s))" filter = "(&(objectClass=inetOrgPerson)(uid=%s))"
} }
return &LDAPConfig{ return &LDAPConfig{
URL: url, URL: url,
BindDN: os.Getenv("MH_LDAP_BIND_DN"), BindDN: get("ldap_bind_dn", "MH_LDAP_BIND_DN"),
BindPass: os.Getenv("MH_LDAP_BIND_PASS"), BindPass: get("ldap_bind_pass", "MH_LDAP_BIND_PASS"),
BaseDN: os.Getenv("MH_LDAP_BASE_DN"), BaseDN: get("ldap_base_dn", "MH_LDAP_BASE_DN"),
UserFilter: filter, UserFilter: filter,
SkipTLS: os.Getenv("MH_LDAP_SKIP_TLS") == "true", 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)) filter := fmt.Sprintf(cfg.UserFilter, ldap.EscapeFilter(username))
sr, err := conn.Search(ldap.NewSearchRequest( sr, err := conn.Search(ldap.NewSearchRequest(
cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 10, false, 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 { if err != nil || len(sr.Entries) == 0 {
return "", "", fmt.Errorf("ldap user not found") 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] entry := sr.Entries[0]
userDN := entry.DN 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 // Bind as user to verify password
if err := conn.Bind(userDN, password); err != nil { if err := conn.Bind(userDN, password); err != nil {
return "", "", fmt.Errorf("ldap auth failed") return "", "", fmt.Errorf("ldap auth failed")
+4
View File
@@ -43,6 +43,10 @@ func Migrate(database *DB) error {
detail TEXT, detail TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')) 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 return nil
} }