diff --git a/cmd/admin-handlers-idp-ldap.go b/cmd/admin-handlers-idp-ldap.go
index 6807de18c..99512c119 100644
--- a/cmd/admin-handlers-idp-ldap.go
+++ b/cmd/admin-handlers-idp-ldap.go
@@ -267,7 +267,7 @@ func (a adminAPIHandlers) AddServiceAccountLDAP(w http.ResponseWriter, r *http.R
lookupResult, targetGroups, err = globalIAMSys.LDAPConfig.LookupUserDN(targetUser)
if err != nil {
// if not found, check if DN
- if strings.Contains(err.Error(), "User DN not found for:") {
+ if strings.Contains(strings.ToLower(err.Error()), "user dn not found for:") {
if isDN {
// warn user that DNs are not allowed
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErrWithErr(ErrAdminLDAPExpectedLoginName, err), r.URL)
diff --git a/cmd/sts-handlers.go b/cmd/sts-handlers.go
index c4092bca3..21e219541 100644
--- a/cmd/sts-handlers.go
+++ b/cmd/sts-handlers.go
@@ -29,17 +29,21 @@ import (
"net/url"
"strconv"
"strings"
+ "sync"
"time"
"github.com/minio/madmin-go/v3"
"github.com/minio/minio/internal/auth"
+ idldap "github.com/minio/minio/internal/config/identity/ldap"
"github.com/minio/minio/internal/config/identity/openid"
+ "github.com/minio/minio/internal/handlers"
"github.com/minio/minio/internal/hash/sha256"
xhttp "github.com/minio/minio/internal/http"
"github.com/minio/minio/internal/logger"
"github.com/minio/mux"
"github.com/minio/pkg/v3/policy"
"github.com/minio/pkg/v3/wildcard"
+ "golang.org/x/time/rate"
)
const (
@@ -91,6 +95,15 @@ const (
// maximum supported STS session policy size
maxSTSSessionPolicySize = 2048
+
+ stsLDAPLoginBurst = 10
+ stsLDAPLoginEntryTTL = 15 * time.Minute
+ stsLDAPLoginRetryAfterSec = int((time.Minute / stsLDAPLoginBurst) / time.Second)
+)
+
+var (
+ errLDAPAuthenticationFailed = errors.New("LDAP authentication failed")
+ globalSTSLDAPLoginRateLimiter = newSTSLDAPLoginRateLimiter(time.Minute/time.Duration(stsLDAPLoginBurst), stsLDAPLoginBurst, stsLDAPLoginEntryTTL)
)
type stsClaims map[string]any
@@ -254,6 +267,109 @@ func getTokenSigningKey() (string, error) {
return secret, nil
}
+type stsLDAPLoginRateLimiter struct {
+ source *stsLDAPLoginKeyLimiterSet
+ user *stsLDAPLoginKeyLimiterSet
+}
+
+type stsLDAPLoginKeyLimiterSet struct {
+ mu sync.Mutex
+ refillEvery time.Duration
+ burst int
+ ttl time.Duration
+ lastCleanup time.Time
+ entries map[string]*stsLDAPLoginKeyLimiter
+}
+
+type stsLDAPLoginKeyLimiter struct {
+ limiter *rate.Limiter
+ lastSeen time.Time
+}
+
+func newSTSLDAPLoginRateLimiter(refillEvery time.Duration, burst int, ttl time.Duration) *stsLDAPLoginRateLimiter {
+ return &stsLDAPLoginRateLimiter{
+ source: newSTSLDAPLoginKeyLimiterSet(refillEvery, burst, ttl),
+ user: newSTSLDAPLoginKeyLimiterSet(refillEvery, burst, ttl),
+ }
+}
+
+func newSTSLDAPLoginKeyLimiterSet(refillEvery time.Duration, burst int, ttl time.Duration) *stsLDAPLoginKeyLimiterSet {
+ return &stsLDAPLoginKeyLimiterSet{
+ refillEvery: refillEvery,
+ burst: burst,
+ ttl: ttl,
+ entries: make(map[string]*stsLDAPLoginKeyLimiter),
+ }
+}
+
+func (l *stsLDAPLoginRateLimiter) Allow(sourceIP, username string) bool {
+ now := UTCNow()
+ if sourceIP != "" && !l.source.Allow(now, sourceIP) {
+ return false
+ }
+
+ username = strings.ToLower(strings.TrimSpace(username))
+ if username != "" && !l.user.Allow(now, username) {
+ return false
+ }
+
+ return true
+}
+
+func (l *stsLDAPLoginKeyLimiterSet) Allow(now time.Time, key string) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ if l.lastCleanup.IsZero() || now.Sub(l.lastCleanup) >= l.ttl {
+ l.cleanup(now)
+ l.lastCleanup = now
+ }
+
+ entry, ok := l.entries[key]
+ if !ok {
+ entry = &stsLDAPLoginKeyLimiter{
+ limiter: rate.NewLimiter(rate.Every(l.refillEvery), l.burst),
+ lastSeen: now,
+ }
+ l.entries[key] = entry
+ } else {
+ entry.lastSeen = now
+ }
+
+ return entry.limiter.AllowN(now, 1)
+}
+
+func (l *stsLDAPLoginKeyLimiterSet) cleanup(now time.Time) {
+ for key, entry := range l.entries {
+ if now.Sub(entry.lastSeen) > l.ttl {
+ delete(l.entries, key)
+ }
+ }
+}
+
+func allowSTSLDAPLogin(r *http.Request) bool {
+ return globalSTSLDAPLoginRateLimiter.Allow(handlers.GetSourceIPRaw(r), r.Form.Get(stsLDAPUsername))
+}
+
+func ldapBindErrorToSTS(err error) (STSErrorCode, error) {
+ if idldap.IsAuthError(err) {
+ return ErrSTSInvalidParameterValue, errLDAPAuthenticationFailed
+ }
+ return ErrSTSUpstreamError, nil
+}
+
+func writeSTSThrottledResponse(w http.ResponseWriter) {
+ stsErrorResponse := STSErrorResponse{}
+ stsErrorResponse.Error.Code = "ThrottlingException"
+ stsErrorResponse.Error.Message = "Request throttled, please retry later."
+ stsErrorResponse.RequestID = w.Header().Get(xhttp.AmzRequestID)
+
+ w.Header().Set("Retry-After", strconv.Itoa(stsLDAPLoginRetryAfterSec))
+
+ encodedErrorResponse := encodeResponse(stsErrorResponse)
+ writeResponse(w, http.StatusTooManyRequests, encodedErrorResponse, mimeXML)
+}
+
// AssumeRole - implementation of AWS STS API AssumeRole to get temporary
// credentials for regular users on Minio.
// https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html
@@ -690,10 +806,18 @@ func (sts *stsAPIHandlers) AssumeRoleWithLDAPIdentity(w http.ResponseWriter, r *
return
}
+ if !allowSTSLDAPLogin(r) {
+ writeSTSThrottledResponse(w)
+ return
+ }
+
lookupResult, groupDistNames, err := globalIAMSys.LDAPConfig.Bind(ldapUsername, ldapPassword)
if err != nil {
- err = fmt.Errorf("LDAP server error: %w", err)
- writeSTSErrorResponse(ctx, w, ErrSTSInvalidParameterValue, err)
+ errCode, errResp := ldapBindErrorToSTS(err)
+ if errCode == ErrSTSUpstreamError {
+ stsLogIf(ctx, err, logger.ErrorKind)
+ }
+ writeSTSErrorResponse(ctx, w, errCode, errResp)
return
}
ldapUserDN := lookupResult.NormDN
diff --git a/cmd/sts-handlers_test.go b/cmd/sts-handlers_test.go
index a883999e9..58786c1f1 100644
--- a/cmd/sts-handlers_test.go
+++ b/cmd/sts-handlers_test.go
@@ -20,8 +20,13 @@ package cmd
import (
"bytes"
"context"
+ "encoding/xml"
+ "errors"
"fmt"
"io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
"os"
"reflect"
"slices"
@@ -977,6 +982,204 @@ func TestIAMWithLDAPServerSuite(t *testing.T) {
}
}
+type ldapSTSErrorResult struct {
+ StatusCode int
+ RetryAfter string
+ Code string
+ Message string
+ Body string
+}
+
+func withGlobalSTSLDAPLoginRateLimiterForTest(limiter *stsLDAPLoginRateLimiter, fn func()) {
+ previous := globalSTSLDAPLoginRateLimiter
+ globalSTSLDAPLoginRateLimiter = limiter
+ defer func() {
+ globalSTSLDAPLoginRateLimiter = previous
+ }()
+
+ fn()
+}
+
+func (s *TestSuiteIAM) postLDAPSTSForError(c *check, username, password string) ldapSTSErrorResult {
+ c.Helper()
+
+ ctx, cancel := context.WithTimeout(context.Background(), testDefaultTimeout)
+ defer cancel()
+
+ form := url.Values{}
+ form.Set("Action", ldapIdentity)
+ form.Set("Version", stsAPIVersion)
+ form.Set(stsLDAPUsername, username)
+ form.Set(stsLDAPPassword, password)
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.endPoint, strings.NewReader(form.Encode()))
+ if err != nil {
+ c.Fatalf("unexpected request creation error: %v", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ resp, err := s.TestSuiteCommon.client.Do(req)
+ if err != nil {
+ c.Fatalf("unexpected LDAP STS request error: %v", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ c.Fatalf("unexpected LDAP STS response read error: %v", err)
+ }
+ if resp.StatusCode == http.StatusOK {
+ c.Fatalf("expected LDAP STS request to fail, got success: %s", body)
+ }
+
+ var stsErr STSErrorResponse
+ if err = xml.Unmarshal(body, &stsErr); err != nil {
+ c.Fatalf("unexpected LDAP STS XML decode error: %v, body: %s", err, body)
+ }
+
+ return ldapSTSErrorResult{
+ StatusCode: resp.StatusCode,
+ RetryAfter: resp.Header.Get("Retry-After"),
+ Code: stsErr.Error.Code,
+ Message: stsErr.Error.Message,
+ Body: string(body),
+ }
+}
+
+func (s *TestSuiteIAM) TestLDAPSTSAuthFailureUniformResponse(c *check) {
+ withGlobalSTSLDAPLoginRateLimiterForTest(
+ newSTSLDAPLoginRateLimiter(time.Minute, stsLDAPLoginBurst, stsLDAPLoginEntryTTL),
+ func() {
+ missingUser := s.postLDAPSTSForError(c, "missing-user", "nottherightpassword")
+ wrongPassword := s.postLDAPSTSForError(c, "dillon", "nottherightpassword")
+
+ if missingUser.StatusCode != http.StatusBadRequest {
+ c.Fatalf("expected missing-user request status %d, got %d", http.StatusBadRequest, missingUser.StatusCode)
+ }
+ if wrongPassword.StatusCode != http.StatusBadRequest {
+ c.Fatalf("expected wrong-password request status %d, got %d", http.StatusBadRequest, wrongPassword.StatusCode)
+ }
+ if missingUser.Code != "InvalidParameterValue" || wrongPassword.Code != "InvalidParameterValue" {
+ c.Fatalf("expected InvalidParameterValue for both auth failures, got missing=%q wrong=%q", missingUser.Code, wrongPassword.Code)
+ }
+ if missingUser.Message != errLDAPAuthenticationFailed.Error() || wrongPassword.Message != errLDAPAuthenticationFailed.Error() {
+ c.Fatalf("expected uniform LDAP auth failure message, got missing=%q wrong=%q", missingUser.Message, wrongPassword.Message)
+ }
+ if strings.Contains(strings.ToLower(missingUser.Body), "unable to find user dn") {
+ c.Fatalf("missing-user response leaked lookup details: %s", missingUser.Body)
+ }
+ if strings.Contains(strings.ToLower(wrongPassword.Body), "ldap auth failed for dn") {
+ c.Fatalf("wrong-password response leaked bind details: %s", wrongPassword.Body)
+ }
+ },
+ )
+}
+
+func (s *TestSuiteIAM) TestLDAPSTSRateLimit(c *check) {
+ withGlobalSTSLDAPLoginRateLimiterForTest(
+ newSTSLDAPLoginRateLimiter(time.Hour, 2, stsLDAPLoginEntryTTL),
+ func() {
+ first := s.postLDAPSTSForError(c, "dillon", "nottherightpassword")
+ second := s.postLDAPSTSForError(c, "dillon", "nottherightpassword")
+ throttled := s.postLDAPSTSForError(c, "dillon", "nottherightpassword")
+
+ if first.StatusCode != http.StatusBadRequest || second.StatusCode != http.StatusBadRequest {
+ c.Fatalf("expected first two failed auth attempts to return %d, got %d and %d", http.StatusBadRequest, first.StatusCode, second.StatusCode)
+ }
+ if throttled.StatusCode != http.StatusTooManyRequests {
+ c.Fatalf("expected throttled request status %d, got %d", http.StatusTooManyRequests, throttled.StatusCode)
+ }
+ if throttled.Code != "ThrottlingException" {
+ c.Fatalf("expected throttled code %q, got %q", "ThrottlingException", throttled.Code)
+ }
+ if throttled.Message != "Request throttled, please retry later." {
+ c.Fatalf("expected throttled message %q, got %q", "Request throttled, please retry later.", throttled.Message)
+ }
+ if throttled.RetryAfter != fmt.Sprintf("%d", stsLDAPLoginRetryAfterSec) {
+ c.Fatalf("expected Retry-After %d, got %q", stsLDAPLoginRetryAfterSec, throttled.RetryAfter)
+ }
+ },
+ )
+}
+
+func (s *TestSuiteIAM) TestLDAPSTSUpstreamFailure(c *check) {
+ original := globalIAMSys.LDAPConfig.Clone()
+ globalIAMSys.LDAPConfig.LDAP.ServerAddr = "127.0.0.1:1"
+ defer func() {
+ globalIAMSys.LDAPConfig = original
+ }()
+
+ withGlobalSTSLDAPLoginRateLimiterForTest(
+ newSTSLDAPLoginRateLimiter(time.Minute, stsLDAPLoginBurst, stsLDAPLoginEntryTTL),
+ func() {
+ upstreamFailure := s.postLDAPSTSForError(c, "dillon", "dillon")
+
+ if upstreamFailure.StatusCode != http.StatusInternalServerError {
+ c.Fatalf("expected upstream failure status %d, got %d", http.StatusInternalServerError, upstreamFailure.StatusCode)
+ }
+ if upstreamFailure.Code != "InternalError" {
+ c.Fatalf("expected upstream failure code %q, got %q", "InternalError", upstreamFailure.Code)
+ }
+ if upstreamFailure.Message != stsErrCodes.ToSTSErr(ErrSTSUpstreamError).Description {
+ c.Fatalf("expected upstream failure message %q, got %q", stsErrCodes.ToSTSErr(ErrSTSUpstreamError).Description, upstreamFailure.Message)
+ }
+ if upstreamFailure.Message == errLDAPAuthenticationFailed.Error() {
+ c.Fatalf("expected upstream failure to stay distinct from auth failure, got %q", upstreamFailure.Message)
+ }
+ },
+ )
+}
+
+func TestIAMWithLDAPSecurityServerSuite(t *testing.T) {
+ tests := []struct {
+ name string
+ run func(*TestSuiteIAM, *check, string)
+ }{
+ {
+ name: "AuthFailureUniformResponse",
+ run: func(suite *TestSuiteIAM, c *check, ldapServer string) {
+ suite.TestLDAPSTSAuthFailureUniformResponse(c)
+ },
+ },
+ {
+ name: "RateLimit",
+ run: func(suite *TestSuiteIAM, c *check, ldapServer string) {
+ suite.TestLDAPSTSRateLimit(c)
+ },
+ },
+ {
+ name: "UpstreamFailure",
+ run: func(suite *TestSuiteIAM, c *check, ldapServer string) {
+ suite.TestLDAPSTSUpstreamFailure(c)
+ },
+ },
+ }
+
+ for i, testCase := range iamTestSuites {
+ t.Run(
+ fmt.Sprintf("Test: %d, ServerType: %s", i+1, testCase.ServerTypeDescription),
+ func(t *testing.T) {
+ ldapServer := os.Getenv(EnvTestLDAPServer)
+ if ldapServer == "" {
+ t.Skipf("Skipping LDAP security test as no LDAP server is provided via %s", EnvTestLDAPServer)
+ }
+
+ suite := testCase
+ for _, tc := range tests {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ c := &check{t, testCase.serverType}
+ suite.SetUpSuite(c)
+ suite.SetUpLDAP(c, ldapServer)
+ tc.run(suite, c, ldapServer)
+ suite.TearDownSuite(c)
+ })
+ }
+ },
+ )
+ }
+}
+
// This test is for a fix added to handle non-normalized base DN values in the
// LDAP configuration. It runs the existing LDAP sub-tests with a non-normalized
// LDAP configuration.
@@ -1052,6 +1255,144 @@ func TestIAMExportImportWithLDAP(t *testing.T) {
}
}
+type matchingAuthError struct{}
+
+func (matchingAuthError) Error() string {
+ return "ldap auth failed"
+}
+
+func (matchingAuthError) Is(target error) bool {
+ return targetIsLDAPAuthFailure(target)
+}
+
+func targetIsLDAPAuthFailure(target error) bool {
+ return target != nil && target.Error() == "ldap authentication failed"
+}
+
+func TestSTSLDAPLoginRateLimiter(t *testing.T) {
+ limiter := newSTSLDAPLoginRateLimiter(time.Hour, 2, time.Minute)
+
+ if !limiter.Allow("192.0.2.10", "dillon") {
+ t.Fatal("expected first attempt to be allowed")
+ }
+ if !limiter.Allow("192.0.2.10", "kevin") {
+ t.Fatal("expected second source-IP attempt to be allowed")
+ }
+ if limiter.Allow("192.0.2.10", "stuart") {
+ t.Fatal("expected source IP bucket to be throttled")
+ }
+
+ limiter = newSTSLDAPLoginRateLimiter(time.Hour, 2, time.Minute)
+ if !limiter.Allow("192.0.2.10", "dillon") {
+ t.Fatal("expected first username attempt to be allowed")
+ }
+ if !limiter.Allow("192.0.2.11", "dillon") {
+ t.Fatal("expected second username attempt from a different source to be allowed")
+ }
+ if limiter.Allow("192.0.2.12", "dillon") {
+ t.Fatal("expected username bucket to be throttled")
+ }
+ if !limiter.Allow("192.0.2.12", "other-user") {
+ t.Fatal("expected a fresh username and source tuple to be allowed")
+ }
+}
+
+func TestLDAPBindErrorToSTS(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ code STSErrorCode
+ message string
+ }{
+ {
+ name: "auth failure",
+ err: matchingAuthError{},
+ code: ErrSTSInvalidParameterValue,
+ message: errLDAPAuthenticationFailed.Error(),
+ },
+ {
+ name: "upstream failure",
+ err: errors.New("ldap server unavailable"),
+ code: ErrSTSUpstreamError,
+ message: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ code, err := ldapBindErrorToSTS(tt.err)
+ if code != tt.code {
+ t.Fatalf("expected code %v, got %v", tt.code, code)
+ }
+ if tt.message == "" {
+ if err != nil {
+ t.Fatalf("expected nil response error, got %v", err)
+ }
+ return
+ }
+ if err == nil || err.Error() != tt.message {
+ t.Fatalf("expected %q, got %v", tt.message, err)
+ }
+ })
+ }
+}
+
+func TestSTSLDAPLoginRateLimiterUsernameNormalization(t *testing.T) {
+ limiter := newSTSLDAPLoginRateLimiter(time.Hour, 2, time.Minute)
+
+ if !limiter.Allow("192.0.2.10", "Admin") {
+ t.Fatal("expected first username variant to be allowed")
+ }
+ if !limiter.Allow("192.0.2.11", " admin ") {
+ t.Fatal("expected trimmed lowercase-equivalent username to be allowed")
+ }
+ if limiter.Allow("192.0.2.12", "ADMIN") {
+ t.Fatal("expected username normalization to hit the same bucket")
+ }
+}
+
+func TestSTSLDAPLoginRateLimiterCleanup(t *testing.T) {
+ set := newSTSLDAPLoginKeyLimiterSet(time.Hour, 1, time.Minute)
+ start := time.Unix(0, 0)
+
+ if !set.Allow(start, "old-key") {
+ t.Fatal("expected initial key to be allowed")
+ }
+ if len(set.entries) != 1 {
+ t.Fatalf("expected one entry, got %d", len(set.entries))
+ }
+
+ if !set.Allow(start.Add(2*time.Minute), "new-key") {
+ t.Fatal("expected new key to be allowed after ttl expiry")
+ }
+ if _, ok := set.entries["old-key"]; ok {
+ t.Fatal("expected expired key to be cleaned up")
+ }
+}
+
+func TestWriteSTSThrottledResponse(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "http://minio.test", strings.NewReader(""))
+ rr := httptest.NewRecorder()
+ req = req.WithContext(newContext(req, rr, "test-throttle"))
+
+ writeSTSThrottledResponse(rr)
+
+ if rr.Code != http.StatusTooManyRequests {
+ t.Fatalf("expected status %d, got %d", http.StatusTooManyRequests, rr.Code)
+ }
+ if got := rr.Header().Get("Retry-After"); got != "6" {
+ t.Fatalf("expected Retry-After header %q, got %q", "6", got)
+ }
+
+ body := rr.Body.String()
+ if !strings.Contains(body, "ThrottlingException") {
+ t.Fatalf("expected throttling code in response, got %s", body)
+ }
+ if !strings.Contains(body, "Request throttled, please retry later.") {
+ t.Fatalf("expected throttling message in response, got %s", body)
+ }
+}
+
func TestIAMImportAssetWithLDAP(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testDefaultTimeout)
defer cancel()
diff --git a/internal/config/identity/ldap/ldap.go b/internal/config/identity/ldap/ldap.go
index 1c1c704c3..967b6d78c 100644
--- a/internal/config/identity/ldap/ldap.go
+++ b/internal/config/identity/ldap/ldap.go
@@ -30,6 +30,42 @@ import (
xldap "github.com/minio/pkg/v3/ldap"
)
+var errAuthentication = errors.New("ldap authentication failed")
+
+func isUserDNNotFoundError(err error) bool {
+ return err != nil && strings.Contains(strings.ToLower(err.Error()), "user dn not found for")
+}
+
+// authError marks authentication failures without changing the returned
+// message, so callers can distinguish auth failures from upstream outages.
+type authError struct {
+ err error
+}
+
+func (e authError) Error() string {
+ return e.err.Error()
+}
+
+func (e authError) Unwrap() error {
+ return e.err
+}
+
+func (e authError) Is(target error) bool {
+ return target == errAuthentication
+}
+
+func wrapAuthError(err error) error {
+ if err == nil {
+ return nil
+ }
+ return authError{err: err}
+}
+
+// IsAuthError reports whether err is an LDAP authentication failure.
+func IsAuthError(err error) bool {
+ return errors.Is(err, errAuthentication)
+}
+
// LookupUserDN searches for the full DN and groups of a given short/login
// username.
func (l *Config) LookupUserDN(username string) (*xldap.DNSearchResult, []string, error) {
@@ -86,7 +122,7 @@ func (l *Config) GetValidatedDNForUsername(username string) (*xldap.DNSearchResu
// the directory.
bindDN, err := l.LDAP.LookupUsername(conn, username)
if err != nil {
- if strings.Contains(err.Error(), "User DN not found for") {
+ if isUserDNNotFoundError(err) {
return nil, nil
}
return nil, fmt.Errorf("Unable to find user DN: %w", err)
@@ -204,7 +240,7 @@ func (l *Config) GetValidatedDNWithGroups(username string) (*xldap.DNSearchResul
// the directory.
lookupRes, err = l.LDAP.LookupUsername(conn, username)
if err != nil {
- if strings.Contains(err.Error(), "User DN not found for") {
+ if isUserDNNotFoundError(err) {
return nil, nil, nil
}
return nil, nil, fmt.Errorf("Unable to find user DN: %w", err)
@@ -245,6 +281,9 @@ func (l *Config) Bind(username, password string) (*xldap.DNSearchResult, []strin
lookupResult, err := l.LDAP.LookupUsername(conn, username)
if err != nil {
errRet := fmt.Errorf("Unable to find user DN: %w", err)
+ if isUserDNNotFoundError(err) {
+ return nil, nil, wrapAuthError(errRet)
+ }
return nil, nil, errRet
}
@@ -252,7 +291,7 @@ func (l *Config) Bind(username, password string) (*xldap.DNSearchResult, []strin
err = conn.Bind(lookupResult.ActualDN, password)
if err != nil {
errRet := fmt.Errorf("LDAP auth failed for DN %s: %w", lookupResult.ActualDN, err)
- return nil, nil, errRet
+ return nil, nil, wrapAuthError(errRet)
}
// Bind to the lookup user account again to perform group search.
diff --git a/internal/config/identity/ldap/ldap_test.go b/internal/config/identity/ldap/ldap_test.go
new file mode 100644
index 000000000..ea646afc2
--- /dev/null
+++ b/internal/config/identity/ldap/ldap_test.go
@@ -0,0 +1,45 @@
+package ldap
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestWrapAuthError(t *testing.T) {
+ baseErr := errors.New("LDAP auth failed for DN uid=dillon,dc=min,dc=io")
+ err := wrapAuthError(baseErr)
+
+ if !IsAuthError(err) {
+ t.Fatal("expected wrapped error to be recognized as auth failure")
+ }
+ if err.Error() != baseErr.Error() {
+ t.Fatalf("expected error text %q, got %q", baseErr.Error(), err.Error())
+ }
+}
+
+func TestWrapAuthErrorNil(t *testing.T) {
+ if wrapAuthError(nil) != nil {
+ t.Fatal("expected nil input to stay nil")
+ }
+}
+
+func TestIsAuthErrorNegative(t *testing.T) {
+ if IsAuthError(errors.New("ldap unavailable")) {
+ t.Fatal("expected plain errors to not be classified as auth failures")
+ }
+}
+
+func TestIsUserDNNotFoundError(t *testing.T) {
+ if isUserDNNotFoundError(nil) {
+ t.Fatal("expected nil error to not be detected as user-not-found")
+ }
+ if !isUserDNNotFoundError(errors.New("user DN not found for: dillon")) {
+ t.Fatal("expected lowercase user-not-found error to be detected")
+ }
+ if !isUserDNNotFoundError(errors.New("User DN not found for: dillon")) {
+ t.Fatal("expected legacy uppercase user-not-found error to be detected")
+ }
+ if isUserDNNotFoundError(errors.New("base DN (dc=min,dc=io) for user DN search does not exist")) {
+ t.Fatal("expected infrastructure lookup error to not be detected as user-not-found")
+ }
+}