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:
parent
2eb69bdcfe
commit
99ee8758cc
8 changed files with 908 additions and 1 deletions
67
internal/auth/auth.go
Normal file
67
internal/auth/auth.go
Normal 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 }
|
||||
Loading…
Add table
Add a link
Reference in a new issue