Compare commits

..
Author SHA1 Message Date
sysopsandClaude Sonnet 5 f224b5c9be IAM-04: zwei-faktor-authentifizierung-totp
internal/totp/totp.go: RFC-6238-TOTP (HOTP RFC 4226) selbst implementiert
mit stdlib crypto/hmac+sha1 — kein externes Modul. DefaultSkewSteps=1
dokumentiert die Zeitversatz-Toleranz (+/-30s um die Serverzeit,
Akzeptanzkriterium/Pruefung 3). ProvisioningURI liefert die otpauth://-URI
fuer den QR-Code (Akzeptanzkriterium 1) — das Rendering selbst ist
Frontend-Sache (IAM-08).

internal/totp/store.go: BeginSetup speichert ein neues Secret als NICHT
bestaetigt; ConfirmSetup aktiviert 2FA erst nach einmaliger erfolgreicher
Code-Eingabe (Akzeptanzkriterium 1) und erzeugt 10 Wiederherstellungscodes
(nur Hash gespeichert, Klartext einmalig zurueckgegeben). VerifyLoginCode
akzeptiert TOTP-Code ODER Wiederherstellungscode; consumeRecoveryCode
markiert einen Code atomar als verwendet (WHERE used_at IS NULL) — kein
doppeltes Einloesen moeglich (Akzeptanzkriterium 3).

internal/totp/login.go: LoginWithTOTP komponiert IAM-02s LoginService, ohne
ihn zu veraendern — ist 2FA fuer den Benutzer aktiv, wird ein fehlender/
falscher Code zuverlaessig abgewiesen, selbst bei korrektem Passwort
(Akzeptanzkriterium 2).

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Login-Versuch ohne zweiten Faktor bei aktivem 2FA zuverlaessig abgewiesen —
   TestLoginWithTOTP_RequiresSecondFactorWhenEnabled: korrektes Passwort ohne
   Code -> ErrSecondFactorRequired, mit gueltigem Code -> Token. PASS.
2. Wiederherstellungscode nach Nutzung als verbraucht getestet —
   TestVerifyLoginCode_RecoveryCodeIsSingleUse: erste Nutzung erfolgreich,
   zweite abgelehnt. PASS.
3. Zeitversatz-Toleranz dokumentiert und getestet —
   TestValidate_ClockSkewTolerance: Code aus 25s Vergangenheit gueltig
   (innerhalb dokumentierter Toleranz), Code aus 5min Vergangenheit
   ungueltig. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 22:34:34 +02:00
12 changed files with 599 additions and 461 deletions
+42
View File
@@ -0,0 +1,42 @@
package totp
import (
"context"
"errors"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
var ErrSecondFactorRequired = errors.New("totp: zweiter faktor erforderlich")
// LoginWithTOTP komponiert IAM-02s LoginService mit der 2FA-Pruefung, ohne
// LoginService selbst zu veraendern. Ist 2FA fuer den Benutzer aktiv, MUSS
// totpCode gueltig sein (Akzeptanzkriterium 2) — ein leerer/falscher Code
// wird zuverlaessig abgewiesen, auch bei korrektem Passwort.
func LoginWithTOTP(ctx context.Context, users *user.TenantUserStore, totpStore *Store, login *auth.LoginService, email, password, totpCode string) (token string, err error) {
creds, err := users.GetByEmailForAuth(ctx, email)
if err != nil {
return "", auth.ErrInvalidCredentials
}
enabled, err := totpStore.IsEnabled(ctx, creds.User.ID)
if err != nil {
return "", err
}
if enabled {
if totpCode == "" {
return "", ErrSecondFactorRequired
}
ok, err := totpStore.VerifyLoginCode(ctx, creds.User.ID, totpCode)
if err != nil {
return "", err
}
if !ok {
return "", ErrSecondFactorRequired
}
}
return login.Login(ctx, email, password)
}
+163
View File
@@ -0,0 +1,163 @@
package totp
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrInvalidCode = errors.New("totp: code ungueltig")
ErrNotConfirmed = errors.New("totp: einrichtung noch nicht bestaetigt")
ErrRecoveryCodeUsed = errors.New("totp: wiederherstellungscode bereits verwendet oder ungueltig")
)
const RecoveryCodeCount = 10
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// BeginSetup erzeugt ein neues Secret fuer einen Benutzer und speichert es
// als NOCH NICHT bestaetigt — der Code muss vor Aktivierung einmal
// verifiziert werden (Akzeptanzkriterium 1).
func (s *Store) BeginSetup(ctx context.Context, userID, issuer, accountName string) (secret, provisioningURI string, err error) {
secret, err = GenerateSecret()
if err != nil {
return "", "", err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO totp_credentials (user_id, secret, confirmed)
VALUES ($1, $2, false)
ON CONFLICT (user_id) DO UPDATE SET secret = $2, confirmed = false
`, userID, secret)
if err != nil {
return "", "", fmt.Errorf("totp-einrichtung speichern: %w", err)
}
return secret, ProvisioningURI(issuer, accountName, secret), nil
}
// ConfirmSetup verifiziert den ersten eingegebenen Code und aktiviert 2FA
// erst dann (Akzeptanzkriterium 1). Bei Erfolg werden einmalig
// Wiederherstellungscodes erzeugt und im Klartext zurueckgegeben — gespeichert
// wird nur deren Hash.
func (s *Store) ConfirmSetup(ctx context.Context, userID, code string) (recoveryCodes []string, err error) {
var secret string
if err := s.pool.QueryRow(ctx, `
SELECT secret FROM totp_credentials WHERE user_id = $1
`, userID).Scan(&secret); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotConfirmed
}
return nil, fmt.Errorf("secret lesen: %w", err)
}
ok, err := Validate(secret, code, time.Now())
if err != nil {
return nil, err
}
if !ok {
return nil, ErrInvalidCode
}
if _, err := s.pool.Exec(ctx, `UPDATE totp_credentials SET confirmed = true WHERE user_id = $1`, userID); err != nil {
return nil, fmt.Errorf("bestaetigung speichern: %w", err)
}
recoveryCodes, err = s.generateRecoveryCodes(ctx, userID)
if err != nil {
return nil, err
}
return recoveryCodes, nil
}
func (s *Store) generateRecoveryCodes(ctx context.Context, userID string) ([]string, error) {
codes := make([]string, RecoveryCodeCount)
for i := range codes {
buf := make([]byte, 8)
if _, err := rand.Read(buf); err != nil {
return nil, fmt.Errorf("wiederherstellungscode erzeugen: %w", err)
}
code := hex.EncodeToString(buf)
codes[i] = code
hash := hashRecoveryCode(code)
if _, err := s.pool.Exec(ctx, `
INSERT INTO totp_recovery_codes (user_id, code_hash) VALUES ($1, $2)
`, userID, hash); err != nil {
return nil, fmt.Errorf("wiederherstellungscode speichern: %w", err)
}
}
return codes, nil
}
// IsEnabled prueft, ob 2FA fuer einen Benutzer aktiv (bestaetigt) ist.
func (s *Store) IsEnabled(ctx context.Context, userID string) (bool, error) {
var confirmed bool
err := s.pool.QueryRow(ctx, `SELECT confirmed FROM totp_credentials WHERE user_id = $1`, userID).Scan(&confirmed)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("status lesen: %w", err)
}
return confirmed, nil
}
// VerifyLoginCode akzeptiert entweder einen gueltigen TOTP-Code oder einen
// noch nicht verwendeten Wiederherstellungscode (Akzeptanzkriterium 2 + 3).
func (s *Store) VerifyLoginCode(ctx context.Context, userID, code string) (bool, error) {
var secret string
var confirmed bool
err := s.pool.QueryRow(ctx, `SELECT secret, confirmed FROM totp_credentials WHERE user_id = $1`, userID).Scan(&secret, &confirmed)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) || !confirmed {
return false, nil
}
return false, fmt.Errorf("secret lesen: %w", err)
}
if !confirmed {
return false, nil
}
if ok, err := Validate(secret, code, time.Now()); err != nil {
return false, err
} else if ok {
return true, nil
}
return s.consumeRecoveryCode(ctx, userID, code)
}
// consumeRecoveryCode markiert einen Wiederherstellungscode atomar als
// verwendet (WHERE used_at IS NULL) — ein bereits verbrauchter Code kann
// nicht doppelt eingeloest werden (Akzeptanzkriterium 3 / Pruefung 2).
func (s *Store) consumeRecoveryCode(ctx context.Context, userID, code string) (bool, error) {
hash := hashRecoveryCode(code)
tag, err := s.pool.Exec(ctx, `
UPDATE totp_recovery_codes SET used_at = now()
WHERE user_id = $1 AND code_hash = $2 AND used_at IS NULL
`, userID, hash)
if err != nil {
return false, fmt.Errorf("wiederherstellungscode einloesen: %w", err)
}
return tag.RowsAffected() == 1, nil
}
func hashRecoveryCode(code string) []byte {
sum := sha256.Sum256([]byte(code))
return sum[:]
}
+185
View File
@@ -0,0 +1,185 @@
package totp
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func setupTest(t *testing.T) (*Store, *user.TenantUserStore, string, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS totp_credentials (
user_id UUID PRIMARY KEY REFERENCES users(id), secret TEXT NOT NULL,
confirmed BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS totp_recovery_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id),
code_hash BYTEA NOT NULL UNIQUE, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
userStore := user.NewTenantUserStore(pool)
email := fmt.Sprintf("totp-test-%d@example.com", time.Now().UnixNano())
u, err := userStore.Create(ctx, email, "TOTP Test")
if err != nil {
t.Fatalf("create user: %v", err)
}
hash, err := auth.HashPassword("korrektes-passwort")
if err != nil {
t.Fatalf("hash: %v", err)
}
if err := userStore.SetPasswordHash(ctx, u.ID, hash); err != nil {
t.Fatalf("set password: %v", err)
}
cleanup := func() { pool.Close() }
return NewStore(pool), userStore, u.ID, cleanup
}
// Akzeptanzkriterium 1: Einrichtung per QR-Code (URI), Bestaetigung vor Aktivierung.
func TestBeginAndConfirmSetup(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
secret, uri, err := store.BeginSetup(ctx, userID, "NEXARCH", "user@example.com")
if err != nil {
t.Fatalf("begin setup: %v", err)
}
if uri == "" {
t.Fatal("erwartet nicht-leere provisioning-uri")
}
enabled, err := store.IsEnabled(ctx, userID)
if err != nil {
t.Fatalf("is enabled (vor bestaetigung): %v", err)
}
if enabled {
t.Fatal("erwartet NICHT aktiviert vor bestaetigung")
}
// Falscher Code darf nicht aktivieren.
if _, err := store.ConfirmSetup(ctx, userID, "000000"); !errors.Is(err, ErrInvalidCode) && err == nil {
t.Fatalf("erwartet ablehnung bei falschem code, habe err=%v", err)
}
code, err := GenerateCode(secret, time.Now())
if err != nil {
t.Fatalf("generate code: %v", err)
}
recoveryCodes, err := store.ConfirmSetup(ctx, userID, code)
if err != nil {
t.Fatalf("confirm setup: %v", err)
}
if len(recoveryCodes) != RecoveryCodeCount {
t.Fatalf("erwartet %d wiederherstellungscodes, habe %d", RecoveryCodeCount, len(recoveryCodes))
}
enabled, err = store.IsEnabled(ctx, userID)
if err != nil {
t.Fatalf("is enabled (nach bestaetigung): %v", err)
}
if !enabled {
t.Fatal("erwartet aktiviert nach erfolgreicher bestaetigung")
}
}
// Akzeptanzkriterium 2 + Pruefung 1: Login ohne zweiten Faktor bei aktivem
// 2FA wird zuverlaessig abgewiesen.
func TestLoginWithTOTP_RequiresSecondFactorWhenEnabled(t *testing.T) {
store, userStore, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
secret, _, err := store.BeginSetup(ctx, userID, "NEXARCH", "user@example.com")
if err != nil {
t.Fatalf("begin setup: %v", err)
}
code, _ := GenerateCode(secret, time.Now())
if _, err := store.ConfirmSetup(ctx, userID, code); err != nil {
t.Fatalf("confirm setup: %v", err)
}
u, err := userStore.Get(ctx, userID)
if err != nil {
t.Fatalf("get user: %v", err)
}
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
loginService := auth.NewLoginService(userStore, issuer, "acme")
// Korrektes Passwort, aber KEIN 2FA-Code -> abgewiesen.
if _, err := LoginWithTOTP(ctx, userStore, store, loginService, u.Email, "korrektes-passwort", ""); !errors.Is(err, ErrSecondFactorRequired) {
t.Fatalf("erwartet ErrSecondFactorRequired ohne code, habe %v", err)
}
// Korrektes Passwort + korrekter aktueller Code -> erfolgreich.
loginCode, _ := GenerateCode(secret, time.Now())
token, err := LoginWithTOTP(ctx, userStore, store, loginService, u.Email, "korrektes-passwort", loginCode)
if err != nil {
t.Fatalf("login mit gueltigem 2fa-code: %v", err)
}
if token == "" {
t.Fatal("erwartet gueltiges token")
}
}
// Akzeptanzkriterium 3 + Pruefung 2: Wiederherstellungscode nach Nutzung als
// verbraucht getestet.
func TestVerifyLoginCode_RecoveryCodeIsSingleUse(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
secret, _, err := store.BeginSetup(ctx, userID, "NEXARCH", "user@example.com")
if err != nil {
t.Fatalf("begin setup: %v", err)
}
code, _ := GenerateCode(secret, time.Now())
recoveryCodes, err := store.ConfirmSetup(ctx, userID, code)
if err != nil {
t.Fatalf("confirm setup: %v", err)
}
recovery := recoveryCodes[0]
ok, err := store.VerifyLoginCode(ctx, userID, recovery)
if err != nil {
t.Fatalf("verify (erste nutzung): %v", err)
}
if !ok {
t.Fatal("erwartet erfolgreiche erste nutzung des wiederherstellungscodes")
}
ok, err = store.VerifyLoginCode(ctx, userID, recovery)
if err != nil {
t.Fatalf("verify (zweite nutzung): %v", err)
}
if ok {
t.Fatal("erwartet abgelehnt bei erneuter nutzung desselben wiederherstellungscodes")
}
}
+104
View File
@@ -0,0 +1,104 @@
// Package totp implementiert Core IAM-04: TOTP-basierte Zwei-Faktor-
// Authentifizierung (RFC 6238, aufbauend auf HOTP RFC 4226) mit stdlib
// crypto/hmac+sha1 — kein externes Modul noetig.
package totp
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/subtle"
"encoding/base32"
"encoding/binary"
"fmt"
"net/url"
"time"
)
// StepSeconds ist das TOTP-Zeitfenster (RFC-6238-Standard: 30 Sekunden).
const StepSeconds = 30
// DefaultSkewSteps ist die dokumentierte Zeitversatz-Toleranz
// (Akzeptanzkriterium/Pruefung 3): +/- 1 Schritt = +/- 30 Sekunden um die
// aktuelle Serverzeit, insgesamt ein 90-Sekunden-Fenster. Grosszuegig genug
// fuer leicht abweichende Client-Uhren, eng genug um ein TOTP-Fenster nicht
// unbrauchbar lange gueltig zu lassen.
const DefaultSkewSteps = 1
// GenerateSecret erzeugt ein neues, zufaelliges TOTP-Secret (20 Byte, wie von
// Authenticator-Apps erwartet), Base32-kodiert ohne Padding.
func GenerateSecret() (string, error) {
buf := make([]byte, 20)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("secret erzeugen: %w", err)
}
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf), nil
}
// ProvisioningURI liefert die otpauth://-URI fuer den QR-Code
// (Akzeptanzkriterium 1) — das eigentliche QR-Rendering ist Frontend-Sache
// (IAM-08), hier wird nur die standardkonforme URI erzeugt.
func ProvisioningURI(issuer, accountName, secret string) string {
label := url.PathEscape(issuer) + ":" + url.PathEscape(accountName)
q := url.Values{}
q.Set("secret", secret)
q.Set("issuer", issuer)
q.Set("algorithm", "SHA1")
q.Set("digits", "6")
q.Set("period", fmt.Sprintf("%d", StepSeconds))
return fmt.Sprintf("otpauth://totp/%s?%s", label, q.Encode())
}
// GenerateCode berechnet den 6-stelligen TOTP-Code fuer secret zum Zeitpunkt t.
func GenerateCode(secret string, t time.Time) (string, error) {
key, err := decodeSecret(secret)
if err != nil {
return "", err
}
counter := uint64(t.Unix() / StepSeconds)
return hotp(key, counter), nil
}
// Validate prueft code gegen secret mit der dokumentierten Zeitversatz-
// Toleranz (DefaultSkewSteps) um t. Vergleich ist timing-safe.
func Validate(secret, code string, t time.Time) (bool, error) {
key, err := decodeSecret(secret)
if err != nil {
return false, err
}
counter := int64(t.Unix() / StepSeconds)
for delta := -DefaultSkewSteps; delta <= DefaultSkewSteps; delta++ {
candidate := hotp(key, uint64(counter+int64(delta)))
if subtle.ConstantTimeCompare([]byte(candidate), []byte(code)) == 1 {
return true, nil
}
}
return false, nil
}
func decodeSecret(secret string) ([]byte, error) {
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
if err != nil {
return nil, fmt.Errorf("secret dekodieren: %w", err)
}
return key, nil
}
// hotp implementiert RFC 4226 mit HMAC-SHA1, 6 Ziffern.
func hotp(key []byte, counter uint64) string {
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, counter)
mac := hmac.New(sha1.New, key)
mac.Write(buf)
sum := mac.Sum(nil)
offset := sum[len(sum)-1] & 0x0f
code := (uint32(sum[offset]&0x7f) << 24) |
(uint32(sum[offset+1]) << 16) |
(uint32(sum[offset+2]) << 8) |
uint32(sum[offset+3])
return fmt.Sprintf("%06d", code%1000000)
}
+79
View File
@@ -0,0 +1,79 @@
package totp
import (
"testing"
"time"
)
func TestGenerateAndValidateCode_RoundTrip(t *testing.T) {
secret, err := GenerateSecret()
if err != nil {
t.Fatalf("generate secret: %v", err)
}
now := time.Now()
code, err := GenerateCode(secret, now)
if err != nil {
t.Fatalf("generate code: %v", err)
}
if len(code) != 6 {
t.Fatalf("erwartet 6-stelligen code, habe %q", code)
}
ok, err := Validate(secret, code, now)
if err != nil {
t.Fatalf("validate: %v", err)
}
if !ok {
t.Fatal("erwartet gueltigen code")
}
wrongCode := "000000"
if code == wrongCode {
wrongCode = "111111"
}
ok, err = Validate(secret, wrongCode, now)
if err != nil {
t.Fatalf("validate (falsch): %v", err)
}
if ok {
t.Fatal("erwartet ungueltig fuer falschen code")
}
}
// Akzeptanzkriterium/Pruefung 3: Zeitversatz-Toleranz dokumentiert (siehe
// DefaultSkewSteps = 1 Schritt = 30s) und getestet.
func TestValidate_ClockSkewTolerance(t *testing.T) {
secret, err := GenerateSecret()
if err != nil {
t.Fatalf("generate secret: %v", err)
}
now := time.Now()
// Code aus dem VORHERIGEN Zeitfenster (bis zu 30s in der Vergangenheit)
// muss innerhalb der dokumentierten Toleranz noch akzeptiert werden.
pastCode, err := GenerateCode(secret, now.Add(-25*time.Second))
if err != nil {
t.Fatalf("generate code (past): %v", err)
}
ok, err := Validate(secret, pastCode, now)
if err != nil {
t.Fatalf("validate (past): %v", err)
}
if !ok {
t.Fatal("erwartet gueltig innerhalb der dokumentierten skew-toleranz (25s)")
}
// Code weit ausserhalb der Toleranz (mehrere Schritte in der
// Vergangenheit) muss abgelehnt werden.
farPastCode, err := GenerateCode(secret, now.Add(-5*time.Minute))
if err != nil {
t.Fatalf("generate code (far past): %v", err)
}
ok, err = Validate(secret, farPastCode, now)
if err != nil {
t.Fatalf("validate (far past): %v", err)
}
if ok {
t.Fatal("erwartet ungueltig weit ausserhalb der skew-toleranz (5min)")
}
}
-19
View File
@@ -1,19 +0,0 @@
package webauthn
import (
"context"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
// CompleteLogin verifiziert die WebAuthn-Signatur und stellt bei Erfolg ein
// normales IAM-02-Sitzungs-Token aus — komponiert auth.TokenIssuer, ohne
// LoginService/IAM-02 selbst zu veraendern. Passwort+TOTP (IAM-02/IAM-04)
// bleiben davon vollstaendig unberuehrt und weiterhin nutzbar
// (Akzeptanzkriterium 3).
func CompleteLogin(ctx context.Context, store *Store, issuer *auth.TokenIssuer, tenantSlug, userID, challenge, credentialID string, signature []byte) (token string, err error) {
if err := store.FinishLogin(ctx, userID, challenge, credentialID, signature); err != nil {
return "", err
}
return issuer.Issue(userID, tenantSlug)
}
-184
View File
@@ -1,184 +0,0 @@
// Package webauthn implementiert Core IAM-10: passwortlose Anmeldung ueber
// einen registrierten Authenticator (Plattform-Authenticator wie Windows
// Hello/Touch ID oder externer Sicherheitsschluessel).
//
// WICHTIGER HINWEIS: Dies ist eine vereinfachte, aber kryptographisch echte
// Challenge-Response-Zeremonie (Ed25519-Signatur ueber eine einmalige,
// zufaellige Challenge) — NICHT das vollstaendige W3C-WebAuthn-Drahtformat
// (CBOR-attestationObject/clientDataJSON, COSE-Schluessel), das echte
// Browser/Authenticatoren erzeugen. Ohne Browser-Umgebung laesst sich dieses
// Drahtformat hier nicht erzeugen oder pruefen (aehnliche Einschraenkung wie
// bei IAM-05/LDAP und dem Live-Redirect in IAM-06/OIDC). Diese
// Implementierung deckt dieselben Sicherheitseigenschaften ab (Public-Key-
// Challenge-Response, Replay-Schutz durch Einmal-Challenge, mehrere
// Authenticatoren pro Benutzer) und ist die Grundlage, auf der eine spaetere
// Kachel echtes CBOR/COSE-Parsing nachruesten kann, ohne das Datenmodell zu
// aendern.
package webauthn
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrChallengeInvalid = errors.New("webauthn: challenge ungueltig, verbraucht oder abgelaufen")
ErrSignatureInvalid = errors.New("webauthn: signatur ungueltig")
ErrNoCredentials = errors.New("webauthn: keine registrierten authenticatoren")
)
// ChallengeTTL begrenzt, wie lange eine ausgestellte Challenge fuer die
// Registrierungs-/Anmelde-Ceremonie gueltig bleibt.
const ChallengeTTL = 5 * time.Minute
type Credential struct {
ID string
UserID string
DeviceName string
PublicKey ed25519.PublicKey
SignCount int
}
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// BeginRegistration stellt eine Einmal-Challenge fuer die Registrierung
// eines neuen Authenticators aus (Akzeptanzkriterium 1).
func (s *Store) BeginRegistration(ctx context.Context, userID string) (challenge string, err error) {
return s.issueChallenge(ctx, userID, "register")
}
// FinishRegistration verifiziert, dass der Aufrufer im Besitz des privaten
// Schluessels zu publicKey ist (Signatur ueber die Challenge), und speichert
// den Authenticator. Mehrere Authenticatoren pro Benutzer sind moeglich,
// da jede Registrierung eine neue Zeile anlegt (Akzeptanzkriterium 2).
func (s *Store) FinishRegistration(ctx context.Context, userID, challenge, deviceName string, publicKey ed25519.PublicKey, signature []byte) (credentialID string, err error) {
if err := s.consumeChallenge(ctx, userID, challenge, "register"); err != nil {
return "", err
}
if !ed25519.Verify(publicKey, []byte(challenge), signature) {
return "", ErrSignatureInvalid
}
err = s.pool.QueryRow(ctx, `
INSERT INTO webauthn_credentials (user_id, device_name, public_key)
VALUES ($1, $2, $3)
RETURNING id
`, userID, deviceName, []byte(publicKey)).Scan(&credentialID)
if err != nil {
return "", fmt.Errorf("credential speichern: %w", err)
}
return credentialID, nil
}
// BeginLogin stellt eine Einmal-Challenge fuer eine bestehende Anmeldung
// aus. Der Benutzer muss mindestens einen registrierten Authenticator haben.
func (s *Store) BeginLogin(ctx context.Context, userID string) (challenge string, err error) {
var count int
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM webauthn_credentials WHERE user_id = $1`, userID).Scan(&count); err != nil {
return "", fmt.Errorf("credentials zaehlen: %w", err)
}
if count == 0 {
return "", ErrNoCredentials
}
return s.issueChallenge(ctx, userID, "login")
}
// FinishLogin verifiziert die Signatur ueber die Login-Challenge gegen den
// gespeicherten oeffentlichen Schluessel des angegebenen Credentials
// (Akzeptanzkriterium 1: Anmeldung ohne Passwort).
func (s *Store) FinishLogin(ctx context.Context, userID, challenge, credentialID string, signature []byte) error {
if err := s.consumeChallenge(ctx, userID, challenge, "login"); err != nil {
return err
}
var publicKey []byte
err := s.pool.QueryRow(ctx, `
SELECT public_key FROM webauthn_credentials WHERE id = $1 AND user_id = $2
`, credentialID, userID).Scan(&publicKey)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrSignatureInvalid
}
return fmt.Errorf("credential lesen: %w", err)
}
if !ed25519.Verify(ed25519.PublicKey(publicKey), []byte(challenge), signature) {
return ErrSignatureInvalid
}
_, _ = s.pool.Exec(ctx, `UPDATE webauthn_credentials SET sign_count = sign_count + 1 WHERE id = $1`, credentialID)
return nil
}
// ListCredentials liefert alle registrierten Authenticatoren eines
// Benutzers (Akzeptanzkriterium 2).
func (s *Store) ListCredentials(ctx context.Context, userID string) ([]Credential, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, device_name, public_key, sign_count FROM webauthn_credentials WHERE user_id = $1
`, userID)
if err != nil {
return nil, fmt.Errorf("credentials auflisten: %w", err)
}
defer rows.Close()
var out []Credential
for rows.Next() {
c := Credential{UserID: userID}
var pub []byte
if err := rows.Scan(&c.ID, &c.DeviceName, &pub, &c.SignCount); err != nil {
return nil, fmt.Errorf("credential lesen: %w", err)
}
c.PublicKey = pub
out = append(out, c)
}
return out, rows.Err()
}
func (s *Store) issueChallenge(ctx context.Context, userID, purpose string) (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("challenge erzeugen: %w", err)
}
challenge := hex.EncodeToString(buf)
_, err := s.pool.Exec(ctx, `
INSERT INTO webauthn_challenges (user_id, challenge, purpose, expires_at)
VALUES ($1, $2, $3, $4)
`, userID, challenge, purpose, time.Now().Add(ChallengeTTL))
if err != nil {
return "", fmt.Errorf("challenge speichern: %w", err)
}
return challenge, nil
}
// consumeChallenge loest eine Challenge ATOMAR und EINMALIG ein (WHERE
// used_at IS NULL, dasselbe Muster wie IAM-03/IAM-06/IAM-09) — verhindert
// Replay einer abgefangenen Signatur-Antwort.
func (s *Store) consumeChallenge(ctx context.Context, userID, challenge, purpose string) error {
tag, err := s.pool.Exec(ctx, `
UPDATE webauthn_challenges SET used_at = now()
WHERE user_id = $1 AND challenge = $2 AND purpose = $3
AND used_at IS NULL AND expires_at > now()
`, userID, challenge, purpose)
if err != nil {
return fmt.Errorf("challenge einloesen: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrChallengeInvalid
}
return nil
}
-233
View File
@@ -1,233 +0,0 @@
package webauthn
import (
"context"
"crypto/ed25519"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func setupTest(t *testing.T) (*Store, *user.TenantUserStore, string, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id),
device_name TEXT NOT NULL, public_key BYTEA NOT NULL, sign_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS webauthn_challenges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id),
challenge TEXT NOT NULL UNIQUE, purpose TEXT NOT NULL CHECK (purpose IN ('register','login')),
expires_at TIMESTAMPTZ NOT NULL, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
userStore := user.NewTenantUserStore(pool)
email := fmt.Sprintf("webauthn-test-%d@example.com", time.Now().UnixNano())
u, err := userStore.Create(ctx, email, "WebAuthn Test")
if err != nil {
t.Fatalf("create user: %v", err)
}
cleanup := func() { pool.Close() }
return NewStore(pool), userStore, u.ID, cleanup
}
// simulateAuthenticator erzeugt ein neues Schluesselpaar und "unterschreibt"
// eine Challenge — steht fuer einen echten Hardware-/Plattform-Authenticator
// im Test (siehe Paket-Dokumentation zur vereinfachten Zeremonie).
func simulateAuthenticator(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("schluesselpaar erzeugen: %v", err)
}
return pub, priv
}
// Akzeptanzkriterium 1 + Pruefung 1: Registrierung und Anmeldung mit
// simuliertem Authenticator erfolgreich, ganz ohne Passworteingabe.
func TestRegisterAndLogin_WithoutPassword(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
pub, priv := simulateAuthenticator(t)
regChallenge, err := store.BeginRegistration(ctx, userID)
if err != nil {
t.Fatalf("begin registration: %v", err)
}
sig := ed25519.Sign(priv, []byte(regChallenge))
credentialID, err := store.FinishRegistration(ctx, userID, regChallenge, "Yubikey 1", pub, sig)
if err != nil {
t.Fatalf("finish registration: %v", err)
}
loginChallenge, err := store.BeginLogin(ctx, userID)
if err != nil {
t.Fatalf("begin login: %v", err)
}
loginSig := ed25519.Sign(priv, []byte(loginChallenge))
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
token, err := CompleteLogin(ctx, store, issuer, "acme", userID, loginChallenge, credentialID, loginSig)
if err != nil {
t.Fatalf("complete login: %v", err)
}
if token == "" {
t.Fatal("erwartet nicht-leeres sitzungs-token")
}
}
func TestFinishRegistration_RejectsWrongSignature(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
pub, _ := simulateAuthenticator(t)
_, otherPriv := simulateAuthenticator(t) // falscher privater schluessel
challenge, err := store.BeginRegistration(ctx, userID)
if err != nil {
t.Fatalf("begin registration: %v", err)
}
wrongSig := ed25519.Sign(otherPriv, []byte(challenge))
if _, err := store.FinishRegistration(ctx, userID, challenge, "Faelscher", pub, wrongSig); !errors.Is(err, ErrSignatureInvalid) {
t.Fatalf("erwartet ErrSignatureInvalid, habe %v", err)
}
}
func TestChallenge_CannotBeReplayed(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
pub, priv := simulateAuthenticator(t)
challenge, err := store.BeginRegistration(ctx, userID)
if err != nil {
t.Fatalf("begin registration: %v", err)
}
sig := ed25519.Sign(priv, []byte(challenge))
if _, err := store.FinishRegistration(ctx, userID, challenge, "Geraet 1", pub, sig); err != nil {
t.Fatalf("erste registrierung: %v", err)
}
if _, err := store.FinishRegistration(ctx, userID, challenge, "Geraet 2 (replay)", pub, sig); !errors.Is(err, ErrChallengeInvalid) {
t.Fatalf("erwartet ErrChallengeInvalid bei wiederverwendeter challenge, habe %v", err)
}
}
// Akzeptanzkriterium 2 + Pruefung 2: mehrere Authenticatoren pro Benutzer,
// beide funktionsfaehig (Geraetewechsel/-verlust).
func TestMultipleCredentials_BothWork(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
pub1, priv1 := simulateAuthenticator(t)
pub2, priv2 := simulateAuthenticator(t)
reg1, _ := store.BeginRegistration(ctx, userID)
cred1, err := store.FinishRegistration(ctx, userID, reg1, "Laptop", pub1, ed25519.Sign(priv1, []byte(reg1)))
if err != nil {
t.Fatalf("register cred1: %v", err)
}
reg2, _ := store.BeginRegistration(ctx, userID)
cred2, err := store.FinishRegistration(ctx, userID, reg2, "Handy", pub2, ed25519.Sign(priv2, []byte(reg2)))
if err != nil {
t.Fatalf("register cred2: %v", err)
}
creds, err := store.ListCredentials(ctx, userID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(creds) != 2 {
t.Fatalf("erwartet 2 credentials, habe %d", len(creds))
}
// Beide Authenticatoren koennen sich unabhaengig voneinander anmelden.
login1, _ := store.BeginLogin(ctx, userID)
if err := store.FinishLogin(ctx, userID, login1, cred1, ed25519.Sign(priv1, []byte(login1))); err != nil {
t.Fatalf("login mit cred1: %v", err)
}
login2, _ := store.BeginLogin(ctx, userID)
if err := store.FinishLogin(ctx, userID, login2, cred2, ed25519.Sign(priv2, []byte(login2))); err != nil {
t.Fatalf("login mit cred2: %v", err)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Login mit Passwort+TOTP funktioniert
// weiterhin unveraendert, kein Zwang auf WebAuthn.
func TestPasswordLoginStillWorks_AfterWebAuthnRegistration(t *testing.T) {
store, userStore, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
u, err := userStore.Get(ctx, userID)
if err != nil {
t.Fatalf("get user: %v", err)
}
hash, err := auth.HashPassword("korrektes-passwort")
if err != nil {
t.Fatalf("hash: %v", err)
}
if err := userStore.SetPasswordHash(ctx, userID, hash); err != nil {
t.Fatalf("set password: %v", err)
}
// WebAuthn zusaetzlich registrieren.
pub, priv := simulateAuthenticator(t)
reg, _ := store.BeginRegistration(ctx, userID)
if _, err := store.FinishRegistration(ctx, userID, reg, "Geraet", pub, ed25519.Sign(priv, []byte(reg))); err != nil {
t.Fatalf("register: %v", err)
}
// Passwort-Login (IAM-02) bleibt unveraendert funktionsfaehig.
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
loginService := auth.NewLoginService(userStore, issuer, "acme")
token, err := loginService.Login(ctx, u.Email, "korrektes-passwort")
if err != nil {
t.Fatalf("passwort-login sollte trotz registriertem webauthn weiterhin funktionieren: %v", err)
}
if token == "" {
t.Fatal("erwartet gueltiges token")
}
}
func TestBeginLogin_RejectsUserWithoutCredentials(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.BeginLogin(ctx, userID); !errors.Is(err, ErrNoCredentials) {
t.Fatalf("erwartet ErrNoCredentials, habe %v", err)
}
}
+3
View File
@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS totp_policy;
DROP TABLE IF EXISTS totp_recovery_codes;
DROP TABLE IF EXISTS totp_credentials;
+23
View File
@@ -0,0 +1,23 @@
-- Zwei-Faktor-Authentifizierung TOTP (IAM-04, siehe core-kanban/tickets/IAM-04.md).
CREATE TABLE totp_credentials (
user_id UUID PRIMARY KEY REFERENCES users(id),
secret TEXT NOT NULL,
confirmed BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- code_hash enthaelt NIEMALS den Klartext-Wiederherstellungscode, nur dessen
-- SHA-256-Hash. used_at markiert einmalige Verwendbarkeit (Akzeptanzkriterium 3).
CREATE TABLE totp_recovery_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
code_hash BYTEA NOT NULL UNIQUE,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Erzwingbare Aktivierungspflicht pro Tenant (Singleton-Zeile, Modell C).
CREATE TABLE totp_policy (
id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id),
required BOOLEAN NOT NULL DEFAULT false
);
-2
View File
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS webauthn_challenges;
DROP TABLE IF EXISTS webauthn_credentials;
-23
View File
@@ -1,23 +0,0 @@
-- Passwortlose Anmeldung via WebAuthn/Passkey (IAM-10, siehe
-- core-kanban/tickets/IAM-10.md).
CREATE TABLE webauthn_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
device_name TEXT NOT NULL,
public_key BYTEA NOT NULL,
sign_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX webauthn_credentials_user_idx ON webauthn_credentials (user_id);
-- Einmal-Challenges fuer Registrierung/Anmeldung (Replay-Schutz).
CREATE TABLE webauthn_challenges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
challenge TEXT NOT NULL UNIQUE,
purpose TEXT NOT NULL CHECK (purpose IN ('register', 'login')),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);