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:
@@ -60,7 +60,9 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
|
||||
if locked {
|
||||
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
|
||||
a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"account_locked"}`) //nolint:errcheck
|
||||
return nil, status.Error(codes.ResourceExhausted, "account temporarily locked")
|
||||
// Security: return the same Unauthenticated / "invalid credentials" as wrong-password
|
||||
// to prevent user-enumeration via lockout differentiation (SEC-02).
|
||||
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
|
||||
}
|
||||
|
||||
ok, err := auth.VerifyPassword(req.Password, acct.PasswordHash)
|
||||
|
||||
@@ -650,3 +650,71 @@ func TestCredentialFieldsAbsentFromAccountResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginLockedAccountReturnsUnauthenticated verifies that a locked-out
|
||||
// account gets the same gRPC Unauthenticated / "invalid credentials" as a
|
||||
// wrong-password attempt, preventing user-enumeration via lockout
|
||||
// differentiation (SEC-02).
|
||||
func TestLoginLockedAccountReturnsUnauthenticated(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
acct := e.createHumanAccount(t, "lockgrpc")
|
||||
|
||||
// Lower the lockout threshold so we don't need 10 failures.
|
||||
origThreshold := db.LockoutThreshold
|
||||
db.LockoutThreshold = 3
|
||||
t.Cleanup(func() { db.LockoutThreshold = origThreshold })
|
||||
|
||||
for range db.LockoutThreshold {
|
||||
if err := e.db.RecordLoginFailure(acct.ID); err != nil {
|
||||
t.Fatalf("RecordLoginFailure: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
locked, err := e.db.IsLockedOut(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsLockedOut: %v", err)
|
||||
}
|
||||
if !locked {
|
||||
t.Fatal("expected account to be locked out after threshold failures")
|
||||
}
|
||||
|
||||
cl := mciasv1.NewAuthServiceClient(e.conn)
|
||||
|
||||
// Attempt login on the locked account.
|
||||
_, lockedErr := cl.Login(context.Background(), &mciasv1.LoginRequest{
|
||||
Username: "lockgrpc",
|
||||
Password: "testpass123",
|
||||
})
|
||||
if lockedErr == nil {
|
||||
t.Fatal("Login on locked account: expected error, got nil")
|
||||
}
|
||||
|
||||
// Attempt login with wrong password for comparison.
|
||||
_, wrongErr := cl.Login(context.Background(), &mciasv1.LoginRequest{
|
||||
Username: "lockgrpc",
|
||||
Password: "wrongpassword",
|
||||
})
|
||||
if wrongErr == nil {
|
||||
t.Fatal("Login with wrong password: expected error, got nil")
|
||||
}
|
||||
|
||||
lockedSt, _ := status.FromError(lockedErr)
|
||||
wrongSt, _ := status.FromError(wrongErr)
|
||||
|
||||
// Both must return Unauthenticated, not ResourceExhausted.
|
||||
if lockedSt.Code() != codes.Unauthenticated {
|
||||
t.Errorf("locked: got code %v, want Unauthenticated", lockedSt.Code())
|
||||
}
|
||||
if wrongSt.Code() != codes.Unauthenticated {
|
||||
t.Errorf("wrong password: got code %v, want Unauthenticated", wrongSt.Code())
|
||||
}
|
||||
|
||||
// Messages must be identical.
|
||||
if lockedSt.Message() != wrongSt.Message() {
|
||||
t.Errorf("locked message %q differs from wrong-password message %q",
|
||||
lockedSt.Message(), wrongSt.Message())
|
||||
}
|
||||
if lockedSt.Message() != "invalid credentials" {
|
||||
t.Errorf("locked message = %q, want %q", lockedSt.Message(), "invalid credentials")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user