auth dev-fixed-code bypasses attempts cap; dev-deploy gains manual dispatch
Tests · Go / test (pull_request) Successful in 2m9s
Tests · Go / test (push) Successful in 2m9s
Tests · Integration / integration (pull_request) Successful in 1m49s
Tests · UI / test (pull_request) Successful in 2m51s

Two problems showed up while trying to log into the long-lived dev
environment with the dev-fixed code `123456`:

1. `ConfirmEmailCode` checked the per-challenge attempts ceiling
   *before* the dev-fixed-code override. A developer who burned past
   `ChallengeMaxAttempts` on an existing un-consumed challenge (easy
   to trigger when the throttle reuses one challenge_id) hit
   `ErrTooManyAttempts` and the UI rendered "code expired or already
   used" even though the fixed code was correct. Reorder so the
   dev-fixed-code branch runs first and bypasses both the bcrypt
   verify and the attempts gate. Production stays unaffected
   because production loaders refuse to set `DevFixedCode`.

2. `dev-deploy.yaml` only fires on push to `development`, so the
   matching docker-compose default change for
   `BACKEND_AUTH_DEV_FIXED_CODE` could not reach the running stack
   before this PR merged. Add `workflow_dispatch: {}` so a developer
   can deploy any branch — typically a feature branch under review —
   from the Gitea Actions UI without waiting for the merge.

Covered by a new `TestConfirmEmailCodeDevFixedCodeBypassesAttemptsCeiling`
integration test that burns through the ceiling with wrong codes
then proves the dev-fixed code still produces a session.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-16 21:28:30 +02:00
parent 166baf4be0
commit 859b157a59
3 changed files with 72 additions and 10 deletions
+46
View File
@@ -513,6 +513,52 @@ func TestConfirmEmailCodeWrongCode(t *testing.T) {
}
}
// TestConfirmEmailCodeDevFixedCodeBypassesAttemptsCeiling proves the
// dev-mode override is a true escape hatch: a developer who already
// burned past ChallengeMaxAttempts on a long-lived dev challenge
// (typically because the throttle merged repeated send-email-code
// calls onto one challenge_id) can still recover by submitting the
// fixed code without first waiting out the challenge TTL.
func TestConfirmEmailCodeDevFixedCodeBypassesAttemptsCeiling(t *testing.T) {
db := startPostgres(t)
cfg := authConfig()
cfg.DevFixedCode = "999999"
svc := buildServiceWithConfig(t, db, cfg)
ctx := context.Background()
id, err := svc.SendEmailCode(ctx, "dev-bypass-ceiling@example.test", "en", "", "")
if err != nil {
t.Fatalf("send: %v", err)
}
// Burn through the attempts ceiling with deliberately wrong codes.
for i := range cfg.ChallengeMaxAttempts + 1 {
_, err := svc.ConfirmEmailCode(ctx, auth.ConfirmInputs{
ChallengeID: id,
Code: "111111",
ClientPublicKey: randomKey(t),
TimeZone: "UTC",
})
if err == nil {
t.Fatalf("attempt %d unexpectedly succeeded", i)
}
}
// The dev-fixed code still goes through.
session, err := svc.ConfirmEmailCode(ctx, auth.ConfirmInputs{
ChallengeID: id,
Code: "999999",
ClientPublicKey: randomKey(t),
TimeZone: "UTC",
})
if err != nil {
t.Fatalf("dev-fixed-code after attempts exhausted: %v", err)
}
if session.DeviceSessionID == uuid.Nil {
t.Fatalf("dev-fixed-code did not produce a session")
}
}
func TestConfirmEmailCodeAttemptsCeiling(t *testing.T) {
db := startPostgres(t)
svc, mailer, _, _ := buildService(t, db)
+19 -10
View File
@@ -163,15 +163,28 @@ func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Sessi
return Session{}, err
}
if int(loaded.Attempts) > s.deps.Config.ChallengeMaxAttempts {
s.deps.Logger.Info("auth challenge attempts exhausted",
// The dev-mode fixed-code override is checked first so it bypasses
// both the bcrypt verify and the per-challenge attempts ceiling.
// Without this, a developer who already burned through
// `ChallengeMaxAttempts` on an existing un-consumed challenge —
// for example after the throttle merged repeated send-email-code
// calls onto one challenge_id — could not recover with the fixed
// code either, defeating the purpose of the override. Production
// deployments leave `DevFixedCode` empty, so this branch is
// inert and the regular attempts gate still applies.
if s.devFixedCodeMatches(in.Code) {
s.deps.Logger.Warn("auth challenge accepted via dev-mode fixed code override",
zap.String("challenge_id", in.ChallengeID.String()),
zap.Int32("attempts", loaded.Attempts),
)
return Session{}, ErrTooManyAttempts
}
if !s.devFixedCodeMatches(in.Code) {
} else {
if int(loaded.Attempts) > s.deps.Config.ChallengeMaxAttempts {
s.deps.Logger.Info("auth challenge attempts exhausted",
zap.String("challenge_id", in.ChallengeID.String()),
zap.Int32("attempts", loaded.Attempts),
)
return Session{}, ErrTooManyAttempts
}
if err := verifyCode(loaded.CodeHash, in.Code); err != nil {
if errors.Is(err, ErrCodeMismatch) {
s.deps.Logger.Info("auth challenge code mismatch",
@@ -182,10 +195,6 @@ func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Sessi
}
return Session{}, err
}
} else {
s.deps.Logger.Warn("auth challenge accepted via dev-mode fixed code override",
zap.String("challenge_id", in.ChallengeID.String()),
)
}
// Re-check permanent_block after verifying the code. SendEmailCode