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:
@@ -7,6 +7,12 @@ name: Deploy · Dev
|
|||||||
# `integration` as part of the PR that produced this push, so this
|
# `integration` as part of the PR that produced this push, so this
|
||||||
# workflow does not re-run those tests — it focuses on packaging and
|
# workflow does not re-run those tests — it focuses on packaging and
|
||||||
# rollout.
|
# 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:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -23,6 +29,7 @@ on:
|
|||||||
- 'tools/dev-deploy/**'
|
- 'tools/dev-deploy/**'
|
||||||
- '.gitea/workflows/dev-deploy.yaml'
|
- '.gitea/workflows/dev-deploy.yaml'
|
||||||
- '!**/*.md'
|
- '!**/*.md'
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
@@ -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) {
|
func TestConfirmEmailCodeAttemptsCeiling(t *testing.T) {
|
||||||
db := startPostgres(t)
|
db := startPostgres(t)
|
||||||
svc, mailer, _, _ := buildService(t, db)
|
svc, mailer, _, _ := buildService(t, db)
|
||||||
|
|||||||
@@ -163,15 +163,28 @@ func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Sessi
|
|||||||
return Session{}, err
|
return Session{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if int(loaded.Attempts) > s.deps.Config.ChallengeMaxAttempts {
|
// The dev-mode fixed-code override is checked first so it bypasses
|
||||||
s.deps.Logger.Info("auth challenge attempts exhausted",
|
// 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.String("challenge_id", in.ChallengeID.String()),
|
||||||
zap.Int32("attempts", loaded.Attempts),
|
zap.Int32("attempts", loaded.Attempts),
|
||||||
)
|
)
|
||||||
return Session{}, ErrTooManyAttempts
|
} else {
|
||||||
}
|
if int(loaded.Attempts) > s.deps.Config.ChallengeMaxAttempts {
|
||||||
|
s.deps.Logger.Info("auth challenge attempts exhausted",
|
||||||
if !s.devFixedCodeMatches(in.Code) {
|
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 err := verifyCode(loaded.CodeHash, in.Code); err != nil {
|
||||||
if errors.Is(err, ErrCodeMismatch) {
|
if errors.Is(err, ErrCodeMismatch) {
|
||||||
s.deps.Logger.Info("auth challenge code mismatch",
|
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
|
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
|
// Re-check permanent_block after verifying the code. SendEmailCode
|
||||||
|
|||||||
Reference in New Issue
Block a user