FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
// 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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user