Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
240 lines
8.2 KiB
Go
240 lines
8.2 KiB
Go
// Package ldapauth implements the LDAP bind/search authentication flow against
|
|
// a per-tenant directory (config from internal/ldapstore). It uses
|
|
// github.com/go-ldap/ldap/v3 (pure Go, CGO_ENABLED=0 compatible).
|
|
//
|
|
// Flow (Authenticate):
|
|
// 1. Connect over LDAPS or StartTLS (cleartext LDAP is rejected).
|
|
// 2. Service-bind with bind_dn + decrypted bind password.
|
|
// 3. Search base_dn with user_filter, loginName escaped per RFC 4515 to
|
|
// prevent LDAP filter injection; expect exactly one entry.
|
|
// 4. Re-bind as the found user DN with the user-supplied password
|
|
// (this is the actual credential check — no fallback to a local password).
|
|
// 5. Optionally search the group tree to decide admin group membership,
|
|
// which the caller maps to a role.
|
|
package ldapauth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-ldap/ldap/v3"
|
|
|
|
"archivdms/internal/ldapstore"
|
|
)
|
|
|
|
// Result is the outcome of a successful authentication.
|
|
type Result struct {
|
|
// Username is the attr_username value from the directory (used as the
|
|
// local username / ldap_uid on JIT provisioning).
|
|
Username string
|
|
// Email is the attr_email value.
|
|
Email string
|
|
// DisplayName is the attr_name value.
|
|
DisplayName string
|
|
// UserDN is the distinguished name the user bound with.
|
|
UserDN string
|
|
// IsAdmin is true when admin_group_dn is configured and the user is a
|
|
// member of it — mapped by the caller to domain_admin.
|
|
IsAdmin bool
|
|
}
|
|
|
|
// Authenticator performs LDAP authentication. It is stateless apart from a
|
|
// dial timeout, so a single instance can be shared across requests.
|
|
type Authenticator struct {
|
|
dialTimeout time.Duration
|
|
}
|
|
|
|
// New returns an Authenticator with the given dial/connect timeout (<=0 uses
|
|
// a 10s default).
|
|
func New(dialTimeout time.Duration) *Authenticator {
|
|
if dialTimeout <= 0 {
|
|
dialTimeout = 10 * time.Second
|
|
}
|
|
return &Authenticator{dialTimeout: dialTimeout}
|
|
}
|
|
|
|
// connect opens a TLS-protected LDAP connection according to cfg.UseTLS.
|
|
func (a *Authenticator) connect(cfg *ldapstore.Config) (*ldap.Conn, error) {
|
|
tlsCfg := &tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12}
|
|
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
|
dialer := &net.Dialer{Timeout: a.dialTimeout}
|
|
|
|
switch cfg.UseTLS {
|
|
case ldapstore.TLSModeLDAPS:
|
|
conn, err := ldap.DialURL("ldaps://"+addr, ldap.DialWithTLSConfig(tlsCfg), ldap.DialWithDialer(dialer))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ldapauth: dial ldaps: %w", err)
|
|
}
|
|
return conn, nil
|
|
case ldapstore.TLSModeStartTLS:
|
|
conn, err := ldap.DialURL("ldap://"+addr, ldap.DialWithDialer(dialer))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ldapauth: dial ldap: %w", err)
|
|
}
|
|
if err := conn.StartTLS(tlsCfg); err != nil {
|
|
conn.Close()
|
|
return nil, fmt.Errorf("ldapauth: starttls: %w", err)
|
|
}
|
|
return conn, nil
|
|
default:
|
|
return nil, fmt.Errorf("ldapauth: cleartext LDAP not permitted (use_tls=%q)", cfg.UseTLS)
|
|
}
|
|
}
|
|
|
|
// TestConnection performs only the service-bind and a base-DN search — it does
|
|
// NOT attempt a user login. Returns the round-trip latency.
|
|
func (a *Authenticator) TestConnection(ctx context.Context, cfg *ldapstore.Config, bindPassword string) (time.Duration, error) {
|
|
start := time.Now()
|
|
conn, err := a.connect(cfg)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer conn.Close()
|
|
conn.SetTimeout(a.dialTimeout)
|
|
|
|
if err := conn.Bind(cfg.BindDN, bindPassword); err != nil {
|
|
return 0, fmt.Errorf("ldapauth: service bind failed: %w", err)
|
|
}
|
|
// Minimal base-scope search to confirm base_dn is reachable/valid.
|
|
req := ldap.NewSearchRequest(
|
|
cfg.BaseDN, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, int(a.dialTimeout.Seconds()), false,
|
|
"(objectClass=*)", []string{"dn"}, nil,
|
|
)
|
|
if _, err := conn.Search(req); err != nil {
|
|
return 0, fmt.Errorf("ldapauth: base search failed: %w", err)
|
|
}
|
|
return time.Since(start), nil
|
|
}
|
|
|
|
// Authenticate runs the full bind/search/re-bind flow.
|
|
func (a *Authenticator) Authenticate(ctx context.Context, cfg *ldapstore.Config, bindPassword, loginName, userPassword string) (*Result, error) {
|
|
if userPassword == "" {
|
|
// Prevent LDAP "unauthenticated bind" (empty password = anonymous success).
|
|
return nil, fmt.Errorf("ldapauth: empty password")
|
|
}
|
|
conn, err := a.connect(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
conn.SetTimeout(a.dialTimeout)
|
|
|
|
// 1) Service bind.
|
|
if err := conn.Bind(cfg.BindDN, bindPassword); err != nil {
|
|
return nil, fmt.Errorf("ldapauth: service bind failed: %w", err)
|
|
}
|
|
|
|
// 2) Search for the user. loginName escaped against filter injection.
|
|
filter := strings.ReplaceAll(cfg.UserFilter, "%s", ldap.EscapeFilter(loginName))
|
|
attrs := []string{"dn", cfg.AttrUsername, cfg.AttrEmail, cfg.AttrName}
|
|
searchReq := ldap.NewSearchRequest(
|
|
cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 2, int(a.dialTimeout.Seconds()), false,
|
|
filter, attrs, nil,
|
|
)
|
|
sr, err := conn.Search(searchReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ldapauth: user search failed: %w", err)
|
|
}
|
|
if len(sr.Entries) == 0 {
|
|
return nil, fmt.Errorf("ldapauth: user not found")
|
|
}
|
|
if len(sr.Entries) > 1 {
|
|
return nil, fmt.Errorf("ldapauth: user filter not unique (%d entries)", len(sr.Entries))
|
|
}
|
|
entry := sr.Entries[0]
|
|
|
|
res := &Result{
|
|
UserDN: entry.DN,
|
|
Username: firstNonEmpty(entry.GetAttributeValue(cfg.AttrUsername), loginName),
|
|
Email: entry.GetAttributeValue(cfg.AttrEmail),
|
|
DisplayName: entry.GetAttributeValue(cfg.AttrName),
|
|
}
|
|
|
|
// 3) Re-bind as the user to verify the password.
|
|
if err := conn.Bind(entry.DN, userPassword); err != nil {
|
|
return nil, fmt.Errorf("ldapauth: invalid credentials")
|
|
}
|
|
|
|
// 4) Group membership for admin role mapping. Re-bind as service account
|
|
// first (the user account may lack read rights on the group tree).
|
|
if cfg.AdminGroupDN != "" {
|
|
if err := conn.Bind(cfg.BindDN, bindPassword); err != nil {
|
|
return nil, fmt.Errorf("ldapauth: re-bind for group search failed: %w", err)
|
|
}
|
|
isAdmin, err := a.isAdminMember(conn, cfg, res)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res.IsAdmin = isAdmin
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
// isAdminMember checks whether the authenticated user belongs to admin_group_dn.
|
|
// Two strategies are supported:
|
|
// - group_base_dn + group_filter set: search the group tree with a filter
|
|
// where %s is replaced by the user DN (escaped), then check whether the
|
|
// admin_group_dn is among the returned group DNs.
|
|
// - otherwise: a base-scope search of admin_group_dn testing the standard
|
|
// member/uniqueMember/memberUid attributes against the user.
|
|
func (a *Authenticator) isAdminMember(conn *ldap.Conn, cfg *ldapstore.Config, res *Result) (bool, error) {
|
|
if cfg.GroupBaseDN != "" && cfg.GroupFilter != "" {
|
|
filter := cfg.GroupFilter
|
|
filter = strings.ReplaceAll(filter, "%d", ldap.EscapeFilter(res.UserDN))
|
|
filter = strings.ReplaceAll(filter, "%s", ldap.EscapeFilter(res.Username))
|
|
req := ldap.NewSearchRequest(
|
|
cfg.GroupBaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, int(a.dialTimeout.Seconds()), false,
|
|
filter, []string{"dn"}, nil,
|
|
)
|
|
sr, err := conn.Search(req)
|
|
if err != nil {
|
|
return false, fmt.Errorf("ldapauth: group search failed: %w", err)
|
|
}
|
|
for _, e := range sr.Entries {
|
|
if strings.EqualFold(strings.TrimSpace(e.DN), strings.TrimSpace(cfg.AdminGroupDN)) {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// Fallback: inspect the admin group entry directly.
|
|
req := ldap.NewSearchRequest(
|
|
cfg.AdminGroupDN, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, int(a.dialTimeout.Seconds()), false,
|
|
"(objectClass=*)", []string{"member", "uniqueMember", "memberUid"}, nil,
|
|
)
|
|
sr, err := conn.Search(req)
|
|
if err != nil {
|
|
return false, fmt.Errorf("ldapauth: admin group lookup failed: %w", err)
|
|
}
|
|
if len(sr.Entries) == 0 {
|
|
return false, nil
|
|
}
|
|
e := sr.Entries[0]
|
|
for _, dn := range append(e.GetAttributeValues("member"), e.GetAttributeValues("uniqueMember")...) {
|
|
if strings.EqualFold(strings.TrimSpace(dn), strings.TrimSpace(res.UserDN)) {
|
|
return true, nil
|
|
}
|
|
}
|
|
for _, uid := range e.GetAttributeValues("memberUid") {
|
|
if strings.EqualFold(strings.TrimSpace(uid), strings.TrimSpace(res.Username)) {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func firstNonEmpty(vals ...string) string {
|
|
for _, v := range vals {
|
|
if strings.TrimSpace(v) != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|