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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
3d20d86a4f
commit
f224b5c9be
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user