Skip to content

feat: attempt limits on the auth routes, keyed by the browser's real IP (LUI-141) - #8

Merged
argentinaluiz merged 1 commit into
feature/projeto-webfrom
feature/lui-141-limite-de-tentativas-e-ip-real
Oct 9, 2026
Merged

argentinaluiz merged 1 commit into
feature/projeto-webfrom
feature/lui-141-limite-de-tentativas-e-ip-real

Conversation

@argentinaluiz

Copy link
Copy Markdown
Contributor

Summary

The fifth vertical slice of feature 01 (LUI-141): sign-in, registration, verification resend and "forgot password" now block excess attempts. Whoever goes over the limit sees, on the same screen, a message in Portuguese saying they have to wait. The per-IP limit uses the IP of the person's browser, not the web server's, so one abuser does not lock everyone else out.

What changed

  • Rate limit module (common/rate-limit): counters per key and window in the new rate_limits table, bumped by a single upsert that runs on the database clock. The window opens on the first attempt and is not stretched by the following ones.
  • Limits on the four routes: sign-in allows 5 per e-mail and 20 per IP every 15 minutes; registration, resend and "forgot password" allow 3 per e-mail and 10 per IP every hour. Over the limit, the answer is 429 rate_limited.
  • Client IP (common/client-ip): ClientIpGuard takes the IP from X-Client-Ip only when X-Internal-Secret matches INTERNAL_API_SECRET; otherwise the connection IP is used. The controller reads it with @ClientIp().
  • Web: the API client sends the browser's IP and the secret on every call.
  • Config: INTERNAL_API_SECRET (required, at least 32 characters) and six variables for the ceilings and windows, with the production values as defaults.
  • Local environment: the secret is in apps/api/.env.example and in the web service of compose.dev.yaml.
  • Docs: docs/lld.md (sections 1, 2, 4.7 and 6) and both project AGENTS.md files.

Decisions worth a look

  • Every sign-in attempt counts, including the one with the right password, and a success does not reset the counter. Six sign-ins in 15 minutes block the e-mail. The same goes for the per-IP counter: 21 people behind one NAT inside the window are blocked together.
  • Each route has its own counters. Registration, resend and "forgot password" share the ceilings, not the count.
  • The attempt is counted before any User lookup, and input refused by validation is not counted. The blocked answer is the same whether or not the e-mail has a User.
  • The e-mail key is the SHA-256 of the typed e-mail, lowercased, so one address does not get a counter per spelling.
  • The web forwards the last address of X-Forwarded-For, which is the one Cloud Run appends. If a load balancer goes in front of the web, the right address is no longer the last one. Without a proxy in front (local), the browser can choose that value.
  • One spelling per IP. IPv6 is written in full and IPv4 wrapped in IPv6 comes out as IPv4, so the same address has one counter.
  • A secret that arrives and does not match is logged by the API, at most once a minute and without the received value. A mismatch between web and API would otherwise count everyone as the web's IP with nothing in the logs.
  • The per-IP ceilings are raised to 100000 in the local .env.example. The person's browser and the Playwright suite leave from the same IP, and the production ceiling would stop the suite halfway. The per-e-mail ceilings stay at the production values.
  • The API test base raises the four ceilings in test/support/setup.ts, and each rate-limit test lowers the one it exercises with vi.stubEnv.
  • The web has no example env file, so its copy of the secret lives in compose.dev.yaml, next to API_URL.

Verification

In the containers, on this branch:

  • API: pnpm test 28 passed (21 new), pnpm test:int 57 passed (10 new), pnpm test:e2e 123 passed (22 new).
  • API: pnpm exec tsc --noEmit, pnpm lint and Prettier clean.
  • Web: npx playwright test 54 passed (1 new), after the build.
  • Web: pnpm lint, pnpm exec tsc --noEmit and pnpm build clean. They ran before the last review round, which changed API files only.
  • The banner text was compared with the Figma frame "Entrar — erro limite de tentativas" and matches. No screenshots were taken: no component changed.

Acceptance criteria, all covered:

  • The sixth sign-in for the same e-mail returns rate_limited, with or without a User (HTTP).
  • Over the limit, the right password also returns rate_limited (HTTP).
  • Sign-in works again when the window closes (HTTP, window lowered to 1 second).
  • The per-IP limit blocks different e-mails from the same IP (HTTP).
  • The fourth registration, resend and "forgot password" for the same e-mail return rate_limited (HTTP).
  • With the right secret, two forwarded IPs do not block each other (HTTP).
  • Without the secret, or with a wrong or empty one, the forwarded IP is ignored (HTTP).
  • In the browser, going over the sign-in limit shows the message above the form (Playwright).
  • The blocked answer is the same for an e-mail with and without a User (HTTP, both bodies compared).
  • All suites, type checks, lint and the web build pass in the containers.

Not exercised:

  • The web forwarding the IP has no automated test. It was checked by hand: after the browser test, the API counter was under the IP of the playwright container, not the web one.
  • The "last X-Forwarded-For address" rule has no test at any level; the web has no unit test runner.
  • Two API instances sharing the counters.

Review findings

A /code-review pass raised ten findings. Fixed here: the silent fallback when the secret does not match, the window using each instance's clock, and equivalent IPv6 spellings getting separate counters.

Left as they are:

  • An IPv6 client can rotate addresses inside its own /64 and never reach the per-IP limit. Counting IPv6 by /64 changes the rule in the LLD.
  • The last X-Forwarded-For address is trusted as is. The alternative is a variable with the number of trusted proxies.
  • Successful sign-ins use up the per-IP counter. It is a product rule; the ticket sets "20 per IP" without telling success from failure.
  • rate_limits only grows. Cleaning expired counters is a worker task in the LLD, and the worker does not exist yet.
  • The browser test depends on the local .env keeping the per-e-mail ceiling at 5.
  • The e-mail hash repeats hashOpaqueToken, and lowercases in JavaScript rather than in the database.

From the /simplify pass, also left: giving each test its own IP instead of raising the per-IP ceilings (it would put .env.example back at the production values and cover the forwarding), and forwarding the IP only on the four auth routes.

Known gaps

  • The verification resend still reveals by response time whether the e-mail has a User. PR feat: password recovery, from the reset routes to the screens (LUI-140) #7 pointed it at this ticket, but the ticket only covers the limits. Three requests an hour make the measurement harder, not impossible.
  • The refresh route has no limit and still signs an access token before validating the refresh token. The Proxy's renewal call does not forward the IP, since nothing reads it there.
  • The general per-User request limit is out of this ticket.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RgE8sJLFxENgwV47XrwDGP

…IP (LUI-141)

Login, registration, verification resend and "forgot password" now count
attempts per e-mail and per IP in PostgreSQL and answer rate_limited until
the window closes, so guessing a password or flooding a mailbox stops
being free. The web forwards the browser's IP with a shared secret, so
one abuser does not lock everyone out behind the web server's address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RgE8sJLFxENgwV47XrwDGP
Copilot AI lite review requested due to automatic review settings October 9, 2026 10:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

ClientIpGuard is not registered or imported in AuthModule, preventing the affected routes from reliably applying rate limits.

1 open finding
What changed in this PR

Adds PostgreSQL-backed per-email and per-IP rate limits to authentication routes, with trusted client-IP forwarding and configuration updates.

Changes:

  • Adds configurable counters, 429 responses, migration, and API tests.
  • Forwards and canonicalizes browser IPs between web and API.
  • Updates authentication services, configuration, documentation, and local environments.
File Summary
docs/​lld.md Documents rate limits, IP forwarding, and configuration.
compose.dev.yaml Adds the local shared secret.
apps/​web/​lib/​api/​client.ts Sends client-IP headers with API requests.
apps/​web/​lib/​api/​client-ip.ts Extracts the forwarded browser IP; web-level automated coverage is missing.
apps/​web/​e2e/​session.spec.ts Tests the rate-limit banner.
apps/​web/​AGENTS.md Documents API client IP behavior.
apps/​api/​test/​support/​setup.ts Adjusts default test ceilings.
apps/​api/​test/​auth-rate-limit.e2e-spec.ts Tests rate-limit behavior.
apps/​api/​src/​modules/​auth/​sessions.service.ts Applies limits to sign-in attempts.
apps/​api/​src/​modules/​auth/​registration.service.ts Applies limits to registration and resend attempts.
apps/​api/​src/​modules/​auth/​password-recovery.service.ts Applies limits to password recovery.
apps/​api/​src/​modules/​auth/​auth.module.ts Uses ClientIpGuard, but does not register or import its provider, blocking correct rate-limit execution.
apps/​api/​src/​modules/​auth/​auth.controller.ts Reads client IPs on authentication routes.
apps/​api/​src/​modules/​auth/​auth-attempts.service.ts Builds authentication rate-limit keys.
apps/​api/​src/​config/​rate-limit.config.ts Loads rate-limit configuration.
apps/​api/​src/​config/​env.schema.ts Validates new environment variables.
apps/​api/​src/​config/​config.module.ts Registers configuration providers.
apps/​api/​src/​config/​config.module.int-spec.ts Tests configuration validation.
apps/​api/​src/​config/​app.config.ts Exposes the internal secret configuration.
apps/​api/​src/​common/​rate-limit/​rate-limited.error.ts Defines the rate-limit error.
apps/​api/​src/​common/​rate-limit/​rate-limit.service.ts Coordinates counter updates.
apps/​api/​src/​common/​rate-limit/​rate-limit.repository.ts Atomically updates PostgreSQL counters.
apps/​api/​src/​common/​rate-limit/​rate-limit.module.ts Provides rate-limit dependencies.
apps/​api/​src/​common/​client-ip/​client-ip.guard.ts Validates and selects client IPs.
apps/​api/​src/​common/​client-ip/​client-ip.guard.spec.ts Tests client-IP selection and logging.
apps/​api/​src/​common/​client-ip/​client-ip.decorator.ts Exposes the selected IP to controllers.
apps/​api/​src/​common/​client-ip/​canonical-ip.ts Normalizes IP representations.
apps/​api/​src/​common/​client-ip/​canonical-ip.spec.ts Tests IP canonicalization.
apps/​api/​prisma/​schema.prisma Adds the rate-limit model.
apps/​api/​prisma/​migrations/​20261009100010_rate_limits/​migration.sql Creates the counters table.
apps/​api/​AGENTS.md Documents testing and architectural conventions.
apps/​api/​.env.example Adds secrets and local rate-limit settings.

🧠 Review effort: Lite


💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.


@Module({
imports: [UsersModule, MailModule, AccessTokensModule],
imports: [UsersModule, MailModule, AccessTokensModule, RateLimitModule],

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not apply, so the module stays as it is.

Nest instantiates a guard referenced by @UseGuards() in the context of the controller's module, without it being listed in providers. The only dependency of ClientIpGuard is the app config, which is global, so it resolves.

The suites show the guard running:

  • test/auth-rate-limit.e2e-spec.ts boots the whole AppModule and passes. The cases with the forwarded IP, with and without the secret, only pass if the guard sets the IP.
  • @ClientIp() throws when the guard has not run, so a guard that Nest could not resolve would turn every call to these four routes into a 500. All 123 e2e tests pass.

AuthGuard is listed in AccessTokensModule because other modules import it from there. The explicit entry for ClientIpGuard existed in an earlier version and was removed as redundant.

@argentinaluiz
argentinaluiz merged commit e06e5ff into feature/projeto-web Oct 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants