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:
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
+57
-16
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user