Skip to content

Commit 878046e

Browse files
authored
sec(auth): collapse MFA-not-configured into generic auth failure (closes #391) (#830)
When MFA is enabled but the secret is missing (data-integrity anomaly), returning a distinct "MFA is enabled but not configured" error reveals that the supplied password was correct. Collapse the branch to the same generic "Check your email address and password and try again" message used for wrong passwords, and log the anomaly internally (by user ID, no PII) so operators can detect it without relying on the API response. Remove the dead substring match from mapMFAServiceError in handler_auth.go. Add a sub-test that asserts both paths return an identical response body.
1 parent 79f52ec commit 878046e

3 files changed

Lines changed: 55 additions & 18 deletions

File tree

‎internal/api/handler_auth.go‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -519,8 +519,7 @@ func mapMFAServiceError(err error) error {
519519
strings.Contains(msg, "MFA code or recovery code required"),
520520
strings.Contains(msg, "no MFA enrollment in progress"),
521521
strings.Contains(msg, "MFA enrollment expired"),
522-
strings.Contains(msg, "MFA is not enabled"),
523-
strings.Contains(msg, "MFA is enabled but not configured"):
522+
strings.Contains(msg, "MFA is not enabled"):
524523
return NewClientError(400, msg)
525524
case strings.Contains(msg, "authentication failed"):
526525
return NewClientError(401, msg)

‎internal/auth/service.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,11 @@ func (s *Service) verifyPasswordAndMFA(ctx context.Context, user *User, req Logi
203203
return ErrMFARequired
204204
}
205205
if user.MFASecret == "" {
206-
return fmt.Errorf("MFA is enabled but not configured")
206+
// Data integrity anomaly: MFA is flagged enabled but has no secret.
207+
// Log internally for operator visibility without leaking internal state
208+
// to the caller -- a distinct message would confirm the password was correct.
209+
logging.Errorf("MFA enabled but secret missing for user %s -- possible data integrity issue", user.ID)
210+
return fmt.Errorf("Check your email address and password and try again")
207211
}
208212
// verifyTOTP is panic-safe: a malformed secret causes generateTOTP to return ""
209213
// (base32 decode error), resulting in a comparison miss rather than a panic.

‎internal/auth/service_test.go‎

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -442,11 +442,12 @@ func TestLogin_WithMFA_MissingCode(t *testing.T) {
442442
assert.ErrorIs(t, err, ErrMFARequired)
443443
}
444444

445+
// TestLogin_WithMFA_NoSecret verifies that a correct password against an account
446+
// where MFA is enabled but the secret is missing returns the same generic error
447+
// as a wrong password -- the response must not reveal that the password was correct
448+
// (closes #391).
445449
func TestLogin_WithMFA_NoSecret(t *testing.T) {
446450
ctx := context.Background()
447-
mockStore := new(MockStore)
448-
mockEmail := new(MockEmailSender)
449-
service := createTestService(mockStore, mockEmail)
450451

451452
s := newTestService()
452453
hash, _ := s.hashPassword("SecurePass@123")
@@ -455,24 +456,57 @@ func TestLogin_WithMFA_NoSecret(t *testing.T) {
455456
ID: "user-123",
456457
Email: "mfa@example.com",
457458
PasswordHash: hash,
458-
Salt: "", // Not used anymore
459+
Salt: "",
459460
Active: true,
460461
MFAEnabled: true,
461-
MFASecret: "", // No secret configured
462+
MFASecret: "", // Data-integrity anomaly: MFA enabled but no secret stored
462463
}
463464

464-
mockStore.On("GetUserByEmail", ctx, "mfa@example.com").Return(user, nil)
465+
const genericMsg = "Check your email address and password and try again"
465466

466-
req := LoginRequest{
467-
Email: "mfa@example.com",
468-
Password: "SecurePass@123",
469-
MFACode: "123456",
470-
}
467+
// Right password, no MFA secret -- must return the generic error.
468+
t.Run("correct password no MFA secret returns generic error", func(t *testing.T) {
469+
mockStore := new(MockStore)
470+
mockEmail := new(MockEmailSender)
471+
service := createTestService(mockStore, mockEmail)
472+
t.Cleanup(func() { mockStore.AssertExpectations(t) })
471473

472-
resp, err := service.Login(ctx, req)
473-
assert.Error(t, err)
474-
assert.Nil(t, resp)
475-
assert.Contains(t, err.Error(), "MFA is enabled but not configured")
474+
mockStore.On("GetUserByEmail", ctx, "mfa@example.com").Return(user, nil).Once()
475+
476+
req := LoginRequest{
477+
Email: "mfa@example.com",
478+
Password: "SecurePass@123",
479+
MFACode: "123456",
480+
}
481+
482+
resp, err := service.Login(ctx, req)
483+
assert.Error(t, err)
484+
assert.Nil(t, resp)
485+
assert.Contains(t, err.Error(), genericMsg)
486+
})
487+
488+
// Wrong password -- must return the identical generic error so the two cases
489+
// are indistinguishable to the caller.
490+
t.Run("wrong password returns same generic error", func(t *testing.T) {
491+
mockStore := new(MockStore)
492+
mockEmail := new(MockEmailSender)
493+
service := createTestService(mockStore, mockEmail)
494+
t.Cleanup(func() { mockStore.AssertExpectations(t) })
495+
496+
mockStore.On("GetUserByEmail", ctx, "mfa@example.com").Return(user, nil).Once()
497+
mockStore.On("UpdateUser", ctx, mock.AnythingOfType("*auth.User")).Return(nil).Maybe()
498+
499+
req := LoginRequest{
500+
Email: "mfa@example.com",
501+
Password: "wrongpassword",
502+
MFACode: "123456",
503+
}
504+
505+
resp, err := service.Login(ctx, req)
506+
assert.Error(t, err)
507+
assert.Nil(t, resp)
508+
assert.Contains(t, err.Error(), genericMsg)
509+
})
476510
}
477511

478512
// Test UpdateUserProfile

0 commit comments

Comments
 (0)