Fix SEC-02: normalize lockout response
- REST login: change locked account response from HTTP 429 "account_locked" to HTTP 401 "invalid credentials" - gRPC login: change from ResourceExhausted to Unauthenticated with "invalid credentials" message - UI login: change from "account temporarily locked" to "invalid credentials" - REST password-change endpoint: same normalization - Audit logs still record "account_locked" internally - Added tests in all three layers verifying locked-account responses are indistinguishable from wrong-password responses Security: lockout responses now return identical status codes and messages as wrong-password failures across REST, gRPC, and UI, preventing user-enumeration via lockout differentiation. Internal audit logging of lockout events is preserved for operational use. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -594,3 +594,76 @@ func TestRenewToken(t *testing.T) {
|
||||
t.Error("old token should be revoked after renewal")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginLockedAccountReturns401 verifies that a locked-out account gets the
|
||||
// same HTTP 401 / "invalid credentials" response as a wrong-password attempt,
|
||||
// preventing user-enumeration via lockout differentiation (SEC-02).
|
||||
func TestLoginLockedAccountReturns401(t *testing.T) {
|
||||
srv, _, _, database := newTestServer(t)
|
||||
acct := createTestHumanAccount(t, srv, "lockuser")
|
||||
handler := srv.Handler()
|
||||
|
||||
// Lower the lockout threshold so we don't need 10 failures.
|
||||
origThreshold := db.LockoutThreshold
|
||||
db.LockoutThreshold = 3
|
||||
t.Cleanup(func() { db.LockoutThreshold = origThreshold })
|
||||
|
||||
// Record enough failures to trigger lockout.
|
||||
for range db.LockoutThreshold {
|
||||
if err := database.RecordLoginFailure(acct.ID); err != nil {
|
||||
t.Fatalf("RecordLoginFailure: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm the account is locked.
|
||||
locked, err := database.IsLockedOut(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsLockedOut: %v", err)
|
||||
}
|
||||
if !locked {
|
||||
t.Fatal("expected account to be locked out after threshold failures")
|
||||
}
|
||||
|
||||
// Attempt login on the locked account.
|
||||
lockedRR := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{
|
||||
"username": "lockuser",
|
||||
"password": "testpass123",
|
||||
}, "")
|
||||
|
||||
// Also attempt login with a wrong password (not locked) for comparison.
|
||||
wrongRR := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{
|
||||
"username": "lockuser",
|
||||
"password": "wrongpassword",
|
||||
}, "")
|
||||
|
||||
// Both must return 401, not 429.
|
||||
if lockedRR.Code != http.StatusUnauthorized {
|
||||
t.Errorf("locked account: status = %d, want %d", lockedRR.Code, http.StatusUnauthorized)
|
||||
}
|
||||
if wrongRR.Code != http.StatusUnauthorized {
|
||||
t.Errorf("wrong password: status = %d, want %d", wrongRR.Code, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// Parse the JSON bodies and compare — they must be identical.
|
||||
type errResp struct {
|
||||
Error string `json:"error"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
var lockedBody, wrongBody errResp
|
||||
if err := json.Unmarshal(lockedRR.Body.Bytes(), &lockedBody); err != nil {
|
||||
t.Fatalf("unmarshal locked body: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(wrongRR.Body.Bytes(), &wrongBody); err != nil {
|
||||
t.Fatalf("unmarshal wrong body: %v", err)
|
||||
}
|
||||
|
||||
if lockedBody != wrongBody {
|
||||
t.Errorf("locked response %+v differs from wrong-password response %+v", lockedBody, wrongBody)
|
||||
}
|
||||
if lockedBody.Code != "unauthorized" {
|
||||
t.Errorf("locked response code = %q, want %q", lockedBody.Code, "unauthorized")
|
||||
}
|
||||
if lockedBody.Error != "invalid credentials" {
|
||||
t.Errorf("locked response error = %q, want %q", lockedBody.Error, "invalid credentials")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user