Fix SEC-01: require password for TOTP enroll
- REST handleTOTPEnroll now requires password field in request body - gRPC EnrollTOTP updated with password field in proto message - Both handlers check lockout status and record failures on bad password - Updated Go, Python, and Rust client libraries to pass password - Updated OpenAPI specs with new requestBody schema - Added TestTOTPEnrollRequiresPassword with no-password, wrong-password, and correct-password sub-tests Security: TOTP enrollment now requires the current password to prevent session-theft escalation to persistent account takeover. Lockout and failure recording use the same Argon2id constant-time path as login. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -519,8 +519,10 @@ func TestTOTPEnrollDoesNotRequireTOTP(t *testing.T) {
|
||||
t.Fatalf("TrackToken: %v", err)
|
||||
}
|
||||
|
||||
// Start enrollment.
|
||||
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", nil, tokenStr)
|
||||
// Start enrollment (password required since SEC-01 fix).
|
||||
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", totpEnrollRequest{
|
||||
Password: "testpass123",
|
||||
}, tokenStr)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("enroll status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
@@ -558,6 +560,61 @@ func TestTOTPEnrollDoesNotRequireTOTP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTOTPEnrollRequiresPassword verifies that TOTP enrollment (SEC-01)
|
||||
// requires the current password. A stolen session token alone must not be
|
||||
// sufficient to add attacker-controlled MFA to the victim's account.
|
||||
func TestTOTPEnrollRequiresPassword(t *testing.T) {
|
||||
srv, _, priv, _ := newTestServer(t)
|
||||
acct := createTestHumanAccount(t, srv, "totp-pw-check")
|
||||
handler := srv.Handler()
|
||||
|
||||
tokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken: %v", err)
|
||||
}
|
||||
if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
||||
t.Fatalf("TrackToken: %v", err)
|
||||
}
|
||||
|
||||
t.Run("no password", func(t *testing.T) {
|
||||
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", totpEnrollRequest{}, tokenStr)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("enroll without password: status = %d, want %d; body: %s",
|
||||
rr.Code, http.StatusBadRequest, rr.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong password", func(t *testing.T) {
|
||||
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", totpEnrollRequest{
|
||||
Password: "wrong-password",
|
||||
}, tokenStr)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("enroll with wrong password: status = %d, want %d; body: %s",
|
||||
rr.Code, http.StatusUnauthorized, rr.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("correct password", func(t *testing.T) {
|
||||
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", totpEnrollRequest{
|
||||
Password: "testpass123",
|
||||
}, tokenStr)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("enroll with correct password: status = %d, want 200; body: %s",
|
||||
rr.Code, rr.Body.String())
|
||||
}
|
||||
var resp totpEnrollResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp.Secret == "" {
|
||||
t.Error("expected non-empty TOTP secret")
|
||||
}
|
||||
if resp.OTPAuthURI == "" {
|
||||
t.Error("expected non-empty otpauth URI")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRenewToken(t *testing.T) {
|
||||
srv, _, priv, _ := newTestServer(t)
|
||||
acct := createTestHumanAccount(t, srv, "renew-user")
|
||||
|
||||
Reference in New Issue
Block a user