8a7b0e18ed
- 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
125 lines
3.3 KiB
Go
125 lines
3.3 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/go-ldap/ldap/v3"
|
|
)
|
|
|
|
type LDAPConfig struct {
|
|
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
|
|
}
|
|
|
|
// 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 := get("ldap_user_filter", "MH_LDAP_USER_FILTER")
|
|
if filter == "" {
|
|
filter = "(&(objectClass=inetOrgPerson)(uid=%s))"
|
|
}
|
|
|
|
return &LDAPConfig{
|
|
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",
|
|
}
|
|
}
|
|
|
|
// LDAPAuth attempts to authenticate a user via LDAP.
|
|
// Returns (email, displayName, error).
|
|
func LDAPAuth(cfg *LDAPConfig, username, password string) (string, string, error) {
|
|
conn, err := ldap.DialURL(cfg.URL, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: cfg.SkipTLS}))
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("ldap connect: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Bind with service account to search
|
|
if cfg.BindDN != "" {
|
|
if err := conn.Bind(cfg.BindDN, cfg.BindPass); err != nil {
|
|
return "", "", fmt.Errorf("ldap bind: %w", err)
|
|
}
|
|
}
|
|
|
|
// Search for user
|
|
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", "memberOf"}, nil,
|
|
))
|
|
if err != nil || len(sr.Entries) == 0 {
|
|
return "", "", fmt.Errorf("ldap user not found")
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
email := entry.GetAttributeValue("mail")
|
|
if email == "" {
|
|
email = username + "@ldap"
|
|
}
|
|
displayName := entry.GetAttributeValue("cn")
|
|
if displayName == "" {
|
|
displayName = entry.GetAttributeValue("uid")
|
|
}
|
|
|
|
return email, displayName, nil
|
|
}
|