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 }