CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
Backend (kleinstmoeglich, wie im Ticket vorgesehen): - internal/auth: TokenIssuer.IssueWithTTL, LoginService.LoginWithTTL (additiv, bestehende Issue/Login unveraendert) - internal/totp: LoginWithTOTPTTL (durchlaeuft denselben 2FA-Ablauf) - cmd/account-api: neue Route POST /auth/mobile-login (JSON-Token statt Cookie, 90 Tage TTL statt 30 Minuten) Android-App (android/dms-capture, Kotlin, Gradle-Projekt): - CameraX-Erfassung mit Live-Vorschau, erneuter Aufnahme, Mehrseiten- Sammlung (AC2) - Ruft ausschliesslich die bestehende DOC-01-Upload-API auf, kein neuer Backend-Uploadpfad (AC3) -- echter Architektur-Fund dabei: dms-app erwartet ein audience-gebundenes Modul-Token (aud=dms), kein rohes Sitzungstoken; App tauscht ueber den bereits bestehenden Endpunkt POST /auth/module-token (Cookie-Header), dokumentiert im Pruefprotokoll - Room-basierte Offline-Warteschlange mit sichtbarem Status, WorkManager fuer automatische Nachreichung ohne Nutzeraktion (AC4) - Verstaendliche Fehlermeldungen bei Groessen-/Typ-Ablehnung statt nur Fehlercode (AC5) - Internes APK, kein Play-Store-Zwang (AC6) Real getestet: Ende-zu-Ende-Auth-Kette (Login -> Modul-Token -> Bearer gegen echten dms-app) auf Host 204, 13/13 Kotlin-Unit-Tests gruen, echter APK-Build auf Host 236 (Android-SDK dort vorhanden, kein Install auf der Entwicklungsmaschine, Gradle projektlokal nachgezogen statt System-Install). Offen (Pruefprotokoll): Kamera-/Offline-/Smoke-Install-Pruefungen brauchen einen Android-Emulator; kein System-Image auf Host 236 installiert, Nachinstallation bewusst nicht ohne Rueckfrage vorgenommen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
257 lines
9.2 KiB
Go
257 lines
9.2 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/moduletrust"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
// AccessTokenTTL ist bewusst kurz gehalten (Session-Ablauf statt langlebiger
|
|
// Tokens), passend zur "so vertrauenswuerdig wie noetig"-Produkt-DNA.
|
|
const AccessTokenTTL = 30 * time.Minute
|
|
|
|
// MinHS256SecretLen ist die QA-15-F-10-Mindestlaenge fuer das konfigurierte
|
|
// (aus der Umgebung gelesene) HS256-Sitzungsgeheimnis: 32 Byte entsprechen
|
|
// 256 Bit Entropie bei zufaelliger Erzeugung, dem ueblichen Minimum fuer
|
|
// HMAC-SHA256. Gilt fuer die tatsaechliche Betriebskonfiguration
|
|
// (Akzeptanzkriterium 2, geprueft von ValidateHS256Secret in den
|
|
// Diensthauptprogrammen) — NICHT fuer jede Instanziierung von TokenIssuer,
|
|
// damit bestehende Tests mit kurzen literalen Testgeheimnissen unveraendert
|
|
// funktionieren.
|
|
const MinHS256SecretLen = 32
|
|
|
|
// ValidateHS256Secret prueft die QA-15-F-10-Mindestlaenge eines
|
|
// konfigurierten HS256-Geheimnisses. Diensthauptprogramme rufen dies beim
|
|
// Start auf den Wert aus der Umgebungsvariable auf und brechen bei Fehler
|
|
// ab (Akzeptanzkriterium 2: "Start bricht sonst ab").
|
|
func ValidateHS256Secret(secret string) error {
|
|
if len(secret) < MinHS256SecretLen {
|
|
return fmt.Errorf("auth: hs256-geheimnis hat %d byte, mindestens %d erforderlich (QA-15 F-10)", len(secret), MinHS256SecretLen)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var ErrInvalidToken = errors.New("auth: ungueltiges oder abgelaufenes token")
|
|
var ErrNoSigningCapability = errors.New("auth: dieser ausstellungsdienst darf keine tokens signieren (nur pruefen)")
|
|
|
|
type Claims struct {
|
|
UserID string `json:"uid"`
|
|
TenantSlug string `json:"tenant"`
|
|
// IssuedAtNano haelt den Ausstellungszeitpunkt in Nanosekunden-Praezision
|
|
// (IAM-17) — RegisteredClaims.IssuedAt rundet beim JSON-Roundtrip auf
|
|
// volle Sekunden (jwt.TimePrecision), was fuer den Massenwiderrufs-
|
|
// Stichtagsvergleich zu falschen Treffern fuehren wuerde, wenn ein neuer
|
|
// Login in derselben Sekunde wie ein RevokeAllForUser-Aufruf stattfindet.
|
|
IssuedAtNano int64 `json:"iat_ns"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
// pubKeySource liefert den fuer eine gegebene KID gueltigen oeffentlichen
|
|
// Ed25519-Schluessel — implementiert entweder von einem lokalen
|
|
// *moduletrust.KeyManager (signierende Instanz, kennt auch die privaten
|
|
// Schluessel) oder von einem *moduletrust.Verifier (nur-pruefende Instanz,
|
|
// holt den Satz per JWKS von der signierenden Instanz).
|
|
type pubKeySource interface {
|
|
PublicKeys(ctx context.Context) (map[string]ed25519.PublicKey, error)
|
|
}
|
|
|
|
type keyManagerSource struct{ km *moduletrust.KeyManager }
|
|
|
|
func (s keyManagerSource) PublicKeys(context.Context) (map[string]ed25519.PublicKey, error) {
|
|
return s.km.PublicKeySetChecked()
|
|
}
|
|
|
|
// TokenIssuer signiert/verifiziert Sitzungs-JWTs. QA-15 F-10: das frueher
|
|
// ausschliesslich verwendete geteilte HS256-Geheimnis (jeder verifizierende
|
|
// Dienst haette damit auch selbst signieren koennen) wird durch Ed25519 mit
|
|
// Schluesselverteilung ersetzt — "wer nur pruefen muss, soll nicht
|
|
// signieren koennen" (Produkt-DNA). HS256 bleibt als Uebergangs- und
|
|
// Rueckfallpfad bestehen (Akzeptanzkriterium 3: "Uebergang mit beiden
|
|
// Verfahren"), ist aber optional: ein leeres secret deaktiviert ihn.
|
|
type TokenIssuer struct {
|
|
secret []byte // legacy HS256, nil = deaktiviert
|
|
signer *moduletrust.KeyManager
|
|
keys pubKeySource
|
|
}
|
|
|
|
// NewTokenIssuer erzeugt eine rein-HS256-Instanz — unveraendertes Verhalten
|
|
// gegenueber vor QA-15 F-10 (keine Laengenpruefung hier, siehe
|
|
// ValidateHS256Secret fuer die Betriebskonfiguration).
|
|
func NewTokenIssuer(secret string) *TokenIssuer {
|
|
return &TokenIssuer{secret: []byte(secret)}
|
|
}
|
|
|
|
// NewAsymmetricTokenIssuer erzeugt die signierende Instanz (account-api):
|
|
// signiert neue Tokens mit Ed25519 ueber km, verifiziert sowohl Ed25519- als
|
|
// auch (falls legacySecret nicht leer ist) HS256-Tokens — der Uebergangs-
|
|
// zustand aus Akzeptanzkriterium 3. Ein leeres legacySecret ist zulaessig
|
|
// (reine Ed25519-Ausstellung); ein gesetztes muss ValidateHS256Secret
|
|
// erfuellen.
|
|
func NewAsymmetricTokenIssuer(km *moduletrust.KeyManager, legacySecret string) (*TokenIssuer, error) {
|
|
if km == nil {
|
|
return nil, errors.New("auth: schluesselverwaltung fehlt")
|
|
}
|
|
i := &TokenIssuer{signer: km, keys: keyManagerSource{km: km}}
|
|
if legacySecret != "" {
|
|
if err := ValidateHS256Secret(legacySecret); err != nil {
|
|
return nil, err
|
|
}
|
|
i.secret = []byte(legacySecret)
|
|
}
|
|
return i, nil
|
|
}
|
|
|
|
// NewVerifyOnlyTokenIssuer erzeugt eine Instanz ohne Signierfaehigkeit
|
|
// (rbac-admin-api, notifyprefs-api u.ae.): Issue schlaegt immer mit
|
|
// ErrNoSigningCapability fehl, Verify prueft Ed25519 per JWKS-Abruf ueber v
|
|
// und optional HS256 gegen legacySecret waehrend des Uebergangs. Sobald der
|
|
// Uebergang abgeschlossen ist, entfaellt legacySecret vollstaendig — dann
|
|
// braucht dieser Dienst gar kein Geheimnis mehr, nur noch den oeffentlichen
|
|
// JWKS-Endpunkt der signierenden Instanz.
|
|
func NewVerifyOnlyTokenIssuer(v *moduletrust.Verifier, legacySecret string) (*TokenIssuer, error) {
|
|
if v == nil {
|
|
return nil, errors.New("auth: verifier fehlt")
|
|
}
|
|
i := &TokenIssuer{keys: v}
|
|
if legacySecret != "" {
|
|
if err := ValidateHS256Secret(legacySecret); err != nil {
|
|
return nil, err
|
|
}
|
|
i.secret = []byte(legacySecret)
|
|
}
|
|
return i, nil
|
|
}
|
|
|
|
func (i *TokenIssuer) Issue(userID, tenantSlug string) (string, error) {
|
|
now := time.Now()
|
|
jti, err := newJTI()
|
|
if err != nil {
|
|
return "", fmt.Errorf("token-id erzeugen: %w", err)
|
|
}
|
|
claims := Claims{
|
|
UserID: userID,
|
|
TenantSlug: tenantSlug,
|
|
IssuedAtNano: now.UnixNano(),
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ID: jti,
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(AccessTokenTTL)),
|
|
},
|
|
}
|
|
|
|
if i.signer != nil {
|
|
key, err := i.signer.SigningKey()
|
|
if err != nil {
|
|
return "", fmt.Errorf("sitzungsschluessel: %w", err)
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
|
|
token.Header["kid"] = key.KID
|
|
return token.SignedString(key.Private)
|
|
}
|
|
if i.secret != nil {
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
return token.SignedString(i.secret)
|
|
}
|
|
return "", ErrNoSigningCapability
|
|
}
|
|
|
|
// newJTI erzeugt eine zufaellige, eindeutige Token-ID (RegisteredClaims.ID).
|
|
// IAM-17 braucht sie, um ein EINZELNES Token gezielt widerrufen zu koennen,
|
|
// ohne andere Sitzungen desselben Benutzers zu beeinflussen (siehe
|
|
// internal/auth/revocation.go).
|
|
func newJTI() (string, error) {
|
|
buf := make([]byte, 16)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(buf), nil
|
|
}
|
|
|
|
// Verify prueft Signatur UND Ablauf (jwt.ParseWithClaims lehnt abgelaufene
|
|
// Tokens automatisch ab) — der HS256-Signaturvergleich in golang-jwt ist
|
|
// timing-safe (hmac.Equal), die Ed25519-Pruefung ist es konstruktionsbedingt.
|
|
// Akzeptiert je nach Konfiguration beide Verfahren (Akzeptanzkriterium 3):
|
|
// welches ein konkretes Token nutzt, steht im JWT-Header ("alg").
|
|
func (i *TokenIssuer) Verify(tokenString string) (*Claims, error) {
|
|
claims := &Claims{}
|
|
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
|
|
switch t.Method.(type) {
|
|
case *jwt.SigningMethodHMAC:
|
|
if i.secret == nil {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
return i.secret, nil
|
|
case *jwt.SigningMethodEd25519:
|
|
if i.keys == nil {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
kid, _ := t.Header["kid"].(string)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
set, err := i.keys.PublicKeys(ctx)
|
|
if err != nil {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
pub, ok := set[kid]
|
|
if !ok {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
return pub, nil
|
|
default:
|
|
return nil, ErrInvalidToken
|
|
}
|
|
})
|
|
if err != nil || !token.Valid {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
return claims, nil
|
|
}
|
|
|
|
// MobileAccessTokenTTL gilt fuer native Clients (INT-21), die keinen
|
|
// Browser-Cookie-Ablauf-Rhythmus haben und nicht alle 30 Minuten neu
|
|
// anmelden sollen. Bewusst als eigene Konstante statt AccessTokenTTL zu
|
|
// aendern, damit bestehende Browser-Sitzungen unveraendert kurz bleiben.
|
|
const MobileAccessTokenTTL = 90 * 24 * time.Hour
|
|
|
|
// IssueWithTTL ist wie Issue, erlaubt aber eine abweichende Gueltigkeit
|
|
// (INT-21: langlebiges Token fuer native Clients). Additiv, Issue bleibt
|
|
// unveraendert fuer bestehende Aufrufer.
|
|
func (i *TokenIssuer) IssueWithTTL(userID, tenantSlug string, ttl time.Duration) (string, error) {
|
|
now := time.Now()
|
|
jti, err := newJTI()
|
|
if err != nil {
|
|
return "", fmt.Errorf("token-id erzeugen: %w", err)
|
|
}
|
|
claims := Claims{
|
|
UserID: userID,
|
|
TenantSlug: tenantSlug,
|
|
IssuedAtNano: now.UnixNano(),
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ID: jti,
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
|
},
|
|
}
|
|
if i.signer != nil {
|
|
key, err := i.signer.SigningKey()
|
|
if err != nil {
|
|
return "", fmt.Errorf("sitzungsschluessel: %w", err)
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
|
|
token.Header["kid"] = key.KID
|
|
return token.SignedString(key.Private)
|
|
}
|
|
if i.secret != nil {
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
return token.SignedString(i.secret)
|
|
}
|
|
return "", ErrNoSigningCapability
|
|
}
|