From 859b157a590c5ee6b38d3fd949526bd3b4790e5a Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 16 May 2026 21:28:30 +0200 Subject: [PATCH] auth dev-fixed-code bypasses attempts cap; dev-deploy gains manual dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitea/workflows/dev-deploy.yaml | 7 ++++ backend/internal/auth/auth_e2e_test.go | 46 ++++++++++++++++++++++++++ backend/internal/auth/challenge.go | 29 ++++++++++------ 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/.gitea/workflows/dev-deploy.yaml b/.gitea/workflows/dev-deploy.yaml index 0ebf15f..28db15b 100644 --- a/.gitea/workflows/dev-deploy.yaml +++ b/.gitea/workflows/dev-deploy.yaml @@ -7,6 +7,12 @@ name: Deploy · Dev # `integration` as part of the PR that produced this push, so this # workflow does not re-run those tests — it focuses on packaging and # rollout. +# +# `workflow_dispatch` is also accepted so a developer can deploy any +# branch (typically a feature branch under active review) into the +# shared dev environment from the Gitea Actions UI without waiting for +# the PR to merge first. The deploy job picks up whatever the chosen +# ref is — same packaging + healthcheck steps as the merge path. on: push: @@ -23,6 +29,7 @@ on: - 'tools/dev-deploy/**' - '.gitea/workflows/dev-deploy.yaml' - '!**/*.md' + workflow_dispatch: {} jobs: deploy: diff --git a/backend/internal/auth/auth_e2e_test.go b/backend/internal/auth/auth_e2e_test.go index f88460e..d4f4937 100644 --- a/backend/internal/auth/auth_e2e_test.go +++ b/backend/internal/auth/auth_e2e_test.go @@ -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) diff --git a/backend/internal/auth/challenge.go b/backend/internal/auth/challenge.go index 73cb364..08ef81e 100644 --- a/backend/internal/auth/challenge.go +++ b/backend/internal/auth/challenge.go @@ -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