Add LDAP authentication

- LDAP bind + search auth with auto-create local user
- Falls back to local auth if LDAP not configured or fails
- Configurable via MH_LDAP_* environment variables
- Supports ldap:// and ldaps:// with optional TLS skip
- go-ldap/ldap/v3 dependency added
This commit is contained in:
2026-05-27 00:00:12 +02:00
parent bf655c6bc5
commit f58ac04069
5 changed files with 215 additions and 1 deletions
+33 -1
View File
@@ -90,7 +90,39 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
err := s.db.QueryRow(
"SELECT id, password_hash, is_admin, totp_secret FROM users WHERE email = ?", req.Email,
).Scan(&id, &hash, &isAdmin, &totpSecret)
if err != nil || !auth.CheckPassword(hash, req.Password) {
localAuthOK := err == nil && auth.CheckPassword(hash, req.Password)
// Try LDAP if local auth failed
if !localAuthOK {
ldapCfg := auth.LDAPConfigFromEnv()
if ldapCfg != nil {
email, displayName, ldapErr := auth.LDAPAuth(ldapCfg, req.Email, req.Password)
if ldapErr == nil {
// LDAP success — find or create local user
err2 := s.db.QueryRow(
"SELECT id, password_hash, is_admin, totp_secret FROM users WHERE email = ?", email,
).Scan(&id, &hash, &isAdmin, &totpSecret)
if err2 != nil {
// Auto-create user from LDAP
id = uuid.New().String()
username := displayName
if username == "" {
username = req.Email
}
s.db.Exec(
"INSERT INTO users (id, username, email, password_hash, is_admin) VALUES (?, ?, ?, ?, 0)",
id, username, email, "ldap",
)
files.EnsureUserDir(s.dataDir, id)
totpSecret = nil
}
localAuthOK = true
}
}
}
if !localAuthOK {
recordLoginAttempt(ip)
writeJSON(w, 401, map[string]string{"error": "invalid credentials"})
return
+83
View File
@@ -0,0 +1,83 @@
package auth
import (
"crypto/tls"
"fmt"
"os"
"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
}
func LDAPConfigFromEnv() *LDAPConfig {
url := os.Getenv("MH_LDAP_URL")
if url == "" {
return nil
}
filter := os.Getenv("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",
}
}
// 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"}, nil,
))
if err != nil || len(sr.Entries) == 0 {
return "", "", fmt.Errorf("ldap user not found")
}
entry := sr.Entries[0]
userDN := entry.DN
// 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
}