auth dev-fixed-code bypasses attempts cap; dev-deploy gains manual dispatch
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user