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

67
internal/auth/auth.go Normal file
View file

@ -0,0 +1,67 @@
// Package auth kapselt die Benutzerauthentifizierung hinter einem schmalen
// Interface, damit später weitere Backends (OIDC/Entra ID) ergänzt werden können.
package auth
import (
"context"
"fmt"
"strings"
)
// Identity ist die kanonische Identität eines authentifizierten Benutzers.
type Identity struct {
// Username ist der aus dem Verzeichnis gelesene sAMAccountName in
// Kleinschreibung — niemals die Benutzereingabe.
Username string
// Groups enthält die Gruppen, die für Rollenentscheidungen relevant sind.
// v1 füllt hier nur die VPN-Gruppe; das Feld hält den Weg zu einer
// späteren VPN-Portal-Admins-Gruppe offen.
Groups []string
}
// HasGroup prüft Gruppenzugehörigkeit ohne Rücksicht auf Groß-/Kleinschreibung.
func (i *Identity) HasGroup(name string) bool {
for _, g := range i.Groups {
if strings.EqualFold(g, name) {
return true
}
}
return false
}
// Authenticator prüft Zugangsdaten und liefert eine kanonische Identität.
type Authenticator interface {
Authenticate(ctx context.Context, username, password string) (*Identity, error)
}
// Reason-Codes landen unverändert im Audit-Log.
const (
ReasonInvalidCredentials = "invalid_credentials"
ReasonAccountDisabled = "account_disabled"
ReasonAccountLocked = "account_locked"
ReasonPasswordExpired = "password_expired"
ReasonPasswordChangeRequired = "password_change_required"
ReasonNotInVPNGroup = "not_in_vpn_group"
ReasonUserNotFound = "user_not_found"
ReasonBackendUnavailable = "backend_unavailable"
)
// Error trägt den Audit-Reason und den technischen Ursprungsfehler.
type Error struct {
Reason string
Err error
}
func (e *Error) Error() string {
if e.Err == nil {
return e.Reason
}
return fmt.Sprintf("%s: %v", e.Reason, e.Err)
}
func (e *Error) Unwrap() error { return e.Err }
// UserVisible meldet, ob dem Benutzer eine spezifische statt der generischen
// Fehlermeldung gezeigt werden darf. Nur das abgelaufene Passwort ist eine
// Ausnahme — alles andere wäre ein Enumerationsorakel.
func (e *Error) UserVisible() bool { return e.Reason == ReasonPasswordExpired }