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>
164 lines
4.9 KiB
Go
164 lines
4.9 KiB
Go
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[:]
|
|
}
|