feat(auth): AD-Authenticator mit Zwei-Schritt-Bind, DC-Failover und verschachtelter Gruppenprüfung

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
Carsten Abele 2026-08-14 09:11:38 +02:00
parent 2eb69bdcfe
commit 99ee8758cc
8 changed files with 908 additions and 1 deletions

View file

@ -0,0 +1,72 @@
package auth
import (
"errors"
"fmt"
"testing"
)
func TestReasonFromLDAPError(t *testing.T) {
// Formulierung wie sie AD in LDAP-Result-Code 49 liefert.
const tmpl = "LDAP Result Code 49 \"Invalid Credentials\": 80090308: LdapErr: " +
"DSID-0C0903A9, comment: AcceptSecurityContext error, data %s, v4563"
cases := map[string]string{
"52e": ReasonInvalidCredentials,
"533": ReasonAccountDisabled,
"775": ReasonAccountLocked,
"532": ReasonPasswordExpired,
"773": ReasonPasswordChangeRequired,
"525": ReasonInvalidCredentials, // user not found -> generisch, keine Enumeration
"701": ReasonAccountDisabled, // account expired
"999": ReasonInvalidCredentials, // unbekannter Code -> generisch
}
for code, want := range cases {
err := errors.New(fmt.Sprintf(tmpl, code))
if got := ReasonFromLDAPError(err); got != want {
t.Errorf("data %s: got %q, want %q", code, got, want)
}
}
}
func TestReasonFromLDAPErrorIsCaseInsensitive(t *testing.T) {
err := errors.New("AcceptSecurityContext error, DATA 52E, v4563")
if got := ReasonFromLDAPError(err); got != ReasonInvalidCredentials {
t.Errorf("got %q, want %q", got, ReasonInvalidCredentials)
}
}
func TestReasonFromLDAPErrorWithoutDataCode(t *testing.T) {
if got := ReasonFromLDAPError(errors.New("connection refused")); got != ReasonBackendUnavailable {
t.Errorf("got %q, want %q", got, ReasonBackendUnavailable)
}
if got := ReasonFromLDAPError(nil); got != "" {
t.Errorf("nil-Fehler muss leeren Reason liefern, got %q", got)
}
}
func TestErrorUserVisibleOnlyForExpiredPassword(t *testing.T) {
if !(&Error{Reason: ReasonPasswordExpired}).UserVisible() {
t.Error("abgelaufenes Passwort ist die einzige spezifische Meldung")
}
for _, r := range []string{
ReasonInvalidCredentials, ReasonAccountDisabled, ReasonAccountLocked,
ReasonNotInVPNGroup, ReasonPasswordChangeRequired, ReasonBackendUnavailable,
} {
if (&Error{Reason: r}).UserVisible() {
t.Errorf("Reason %q darf keine spezifische Meldung erzeugen", r)
}
}
}
func TestErrorUnwraps(t *testing.T) {
inner := errors.New("boom")
err := &Error{Reason: ReasonBackendUnavailable, Err: inner}
if !errors.Is(err, inner) {
t.Error("Error muss den inneren Fehler durchreichen")
}
var authErr *Error
if !errors.As(error(err), &authErr) {
t.Error("errors.As muss *Error finden")
}
}