Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/data/search-index.json

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions docs-src/adr/029-authentication-brute-force-protection.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ default; a third `ICacheService` implementation, the opt-in `HybridCacheService`
`IncrementAsync`; and `ResetPasswordHandlerBase` is a second framework call site, clearing the
failed-attempt counter after a password reset).
Revised 2026-10-01 (change-password is a third framework call site, with a principal-keyed counter; every ADC and Store service host calls `AddCommonHybridCacheWhenRedisConfigured`, so with Redis configured the counters run through `HybridCacheService.IncrementAsync`; see Revision below).
Revised 2026-10-03 (the counters fail open with a Warning log when the cache is unavailable, instead of
answering 500; see Revision below).
## Context
ADR-019's global rate limiter is **principal-keyed**: it caps requests per authenticated principal,
and anonymous traffic is exempt with one metered exception, the configured real-time hub path
Expand Down Expand Up @@ -200,6 +202,26 @@ Redis configured the counters run through `HybridCacheService.IncrementAsync`
non-atomic read-modify-write with L1 bypassed. The line anchors inside the 2026-09-07 Revision record
the code as it stood then and are left as written.

## Revision (2026-10-03)
Decision added: **the counters fail open when the cache is unavailable.** Before this revision no
behavior was decided for a cache outage, and the observed result was neither open nor closed: with
Redis stopped, the lockout check read nothing and let the attempt through, while the failed-attempt
increment and the post-login reset rethrew the cache failure, so `POST /Auth/login` answered 500 for
a right and a wrong password alike (ADC Local Test Run 3). `LoginProtectionService` now catches a
cache failure in every check, increment and reset (login lockout and registration throttle), logs it
at Warning, and continues as if no counter state exists: a check answers success and an increment or
reset is a no-op. Caller cancellation still propagates.

Fail open was chosen over fail closed for three reasons. `CacheSettings` already promises that a
cache outage never becomes an error, and login is the one path where breaking that promise locks
every user out of every service at once. The counters are ephemeral by design (expiry is the reset),
so an outage only shortens a lockout that would have lapsed anyway. And guessing is still bounded
while the cache is down, because ADR-019's `auth-ip` per-IP window on login and register keeps its
own in-process state and is unaffected by the outage (it is deliberately never Redis-backed). It is
also the posture the framework already takes for the distributed request limiter,
`RedisFixedWindowRateLimiter`, which permits the request and logs a warning on a Redis fault. The cost is that a lockout already in force is
not enforced during the outage; the Warning log makes that window visible.

## Alternatives rejected
- **Making the failed-attempt and registration counters atomic.** The increment in
`DistributedCacheService.IncrementAsync` is a read-modify-write through `IDistributedCache` and is
Expand Down
10 changes: 6 additions & 4 deletions docs-src/guides/adc-specifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -928,7 +928,8 @@ the count, never who voted (BR-238).
4. Organizer refreshes the dashboard to see results

**Alternate Flows:**
- A run is already queued or in progress for the same event: HTTP 409 rather than a duplicate run
- A run is already queued or in progress for the same event: the trigger still answers 202 Accepted, and no duplicate run happens. The handler takes a per-event claim before it starts, so a second pass for an event already being scored logs and completes without paying for the same calls twice
- The event does not exist or is soft-deleted: HTTP 404 and nothing is queued

**Postconditions:** Sessions carry AI scores that inform the accept/decline decision.

Expand Down Expand Up @@ -1809,7 +1810,8 @@ All error responses use the **RFC 9457 ProblemDetails** format (the successor to
| 404 Not Found | Entity does not exist or is soft-deleted | `"Not found"` |
| 409 Conflict | Duplicate operation (bookmark) | `"Conflict"` |
| 422 Unprocessable Entity | FluentValidation failure | `"Validation failed"` |
| 429 Too Many Requests | Sessionize refresh throttle (BR-63) exceeded, or login brute-force protection (BR-212), or registration abuse prevention (BR-213) | `"Too many requests"` |
| 401 Unauthorized | Registration abuse prevention (BR-213): code `Auth.RegistrationRateLimitExceeded` | `"Operation failed"` |
| 429 Too Many Requests | Sessionize refresh throttle (BR-63) exceeded, or login brute-force protection (BR-212) | `"Too many requests"` |
| 503 Service Unavailable | API rate limit exceeded (BR-20/BR-68): ASP.NET Core fixed-window rate limiter rejects excess requests with 503 | *(framework default)* |
| 502 Bad Gateway | External service (Sessionize API) unreachable: timeout, HTTP 5xx, or DNS failure (UC-6) | `"Sessionize API is unavailable. Try again later."` |
| *(Client disconnection)* | Client disconnected before response completed: no HTTP response is sent. The server logs the cancellation at `Information` level for diagnostics. This is not an HTTP status code returned to the client. | *(N/A: logged server-side only)* |
Expand Down Expand Up @@ -2028,7 +2030,7 @@ Organizer-only endpoints that support the accept/decline decision on submitted s
| `GET /api/sessionselection/categories/{eventId}` | Category distribution across the event's sessions |
| `GET /api/sessionselection/speaker-overlap/{eventId}` | Speakers with more than one submitted session |
| `GET /api/sessionselection/content-similarity/{eventId}` | Pairs of sessions with similar content |
| `POST /api/sessionselection/score/{eventId}` | Queues AI scoring; returns **202 Accepted**, or **409** when a run is already queued or in progress for that event (UC-27) |
| `POST /api/sessionselection/score/{eventId}` | Queues AI scoring; returns **202 Accepted** (also when a run is already queued or in progress for that event, which the handler de-duplicates), or **404** when the event does not exist (UC-27) |

### 11.13 Session Materials (BR-116b)

Expand Down Expand Up @@ -2509,7 +2511,7 @@ IAuthService (UI abstraction)
| BR-210 | The `speaker_id` JWT claim grants **no additional permissions** beyond what the user's `role` provides. It is an identity claim that enables speaker-specific data views (own session feedback, bookmark counts, profile editing). Authorization is determined solely by `role`. |
| BR-211 | Self-registration is **open**. Any person can create an account without invitation or pre-approval. New accounts default to the `Attendee` role (BR-45). Organizer promotion is done via database seeding or manual update. |
| BR-212 | Login **brute-force protection:** After **5 consecutive failed login attempts** for a given email address, subsequent attempts are delayed with exponential backoff (1s, 2s, 4s, 8s, 16s, capped at **5 minutes**). The counter resets on successful login. |
| BR-213 | Registration **abuse prevention:** Maximum **10 account registrations per IP address per hour**. Excess attempts return HTTP 429. |
| BR-213 | Registration **abuse prevention:** Maximum **10 account registrations per IP address per hour**. Excess attempts return HTTP 401 with code `Auth.RegistrationRateLimitExceeded`, the uniform login-protection failure of ADR-029. The client IP is the browser's on every path: the WebAssembly proxy and the Blazor Server circuit both forward it, so first-visit registrations do not share one bucket keyed on the UI host. |
| BR-214 | Speakers can update their **own** speaker profile (bio, tagline, social links) when linked. The `speaker_id` in the JWT must match the target Speaker entity's ID. Organizers can update any speaker profile regardless of linking. |
| BR-215 | Sessionize refresh (UC-6) **overwrites all speaker profile fields** including any local edits made by speakers via the app. Sessionize remains the source of truth (BR-48). |
| BR-216 | **Logout** invalidates the user's current refresh token (web) or clears stored credentials (MAUI). Access tokens cannot be server-side invalidated before expiry: they remain valid until their 1-hour TTL expires. For immediate revocation needs (e.g., account deletion, role change), a token blacklist or short-lived tokens would be needed (out of scope). |
Expand Down
23 changes: 22 additions & 1 deletion docs/adr/029-authentication-brute-force-protection.html
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,9 @@ <h2 id="status">Status</h2>
default; a third <code>ICacheService</code> implementation, the opt-in <code>HybridCacheService</code>, also overrides
<code>IncrementAsync</code>; and <code>ResetPasswordHandlerBase</code> is a second framework call site, clearing the
failed-attempt counter after a password reset).
Revised 2026-10-01 (change-password is a third framework call site, with a principal-keyed counter; every ADC and Store service host calls <code>AddCommonHybridCacheWhenRedisConfigured</code>, so with Redis configured the counters run through <code>HybridCacheService.IncrementAsync</code>; see Revision below).</p>
Revised 2026-10-01 (change-password is a third framework call site, with a principal-keyed counter; every ADC and Store service host calls <code>AddCommonHybridCacheWhenRedisConfigured</code>, so with Redis configured the counters run through <code>HybridCacheService.IncrementAsync</code>; see Revision below).
Revised 2026-10-03 (the counters fail open with a Warning log when the cache is unavailable, instead of
answering 500; see Revision below).</p>
<h2 id="context">Context</h2>
<p>ADR-019&#39;s global rate limiter is <strong>principal-keyed</strong>: it caps requests per authenticated principal,
and anonymous traffic is exempt with one metered exception, the configured real-time hub path
Expand Down Expand Up @@ -437,6 +439,24 @@ <h2 id="revision-2026-10-01">Revision (2026-10-01)</h2>
(<code>MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/HybridCacheService.cs:253</code>), the same
non-atomic read-modify-write with L1 bypassed. The line anchors inside the 2026-09-07 Revision record
the code as it stood then and are left as written.</p>
<h2 id="revision-2026-10-03">Revision (2026-10-03)</h2>
<p>Decision added: <strong>the counters fail open when the cache is unavailable.</strong> Before this revision no
behavior was decided for a cache outage, and the observed result was neither open nor closed: with
Redis stopped, the lockout check read nothing and let the attempt through, while the failed-attempt
increment and the post-login reset rethrew the cache failure, so <code>POST /Auth/login</code> answered 500 for
a right and a wrong password alike (ADC Local Test Run 3). <code>LoginProtectionService</code> now catches a
cache failure in every check, increment and reset (login lockout and registration throttle), logs it
at Warning, and continues as if no counter state exists: a check answers success and an increment or
reset is a no-op. Caller cancellation still propagates.</p>
<p>Fail open was chosen over fail closed for three reasons. <code>CacheSettings</code> already promises that a
cache outage never becomes an error, and login is the one path where breaking that promise locks
every user out of every service at once. The counters are ephemeral by design (expiry is the reset),
so an outage only shortens a lockout that would have lapsed anyway. And guessing is still bounded
while the cache is down, because ADR-019&#39;s <code>auth-ip</code> per-IP window on login and register keeps its
own in-process state and is unaffected by the outage (it is deliberately never Redis-backed). It is
also the posture the framework already takes for the distributed request limiter,
<code>RedisFixedWindowRateLimiter</code>, which permits the request and logs a warning on a Redis fault. The cost is that a lockout already in force is
not enforced during the outage; the Warning log makes that window visible.</p>
<h2 id="alternatives-rejected">Alternatives rejected</h2>
<ul>
<li><strong>Making the failed-attempt and registration counters atomic.</strong> The increment in
Expand Down Expand Up @@ -476,6 +496,7 @@ <h2 id="related">Related</h2>
<li><a href="#trade-offs">Trade-offs</a></li>
<li><a href="#revision-2026-09-07">Revision (2026-09-07)</a></li>
<li><a href="#revision-2026-10-01">Revision (2026-10-01)</a></li>
<li><a href="#revision-2026-10-03">Revision (2026-10-03)</a></li>
<li><a href="#alternatives-rejected">Alternatives rejected</a></li>
<li><a href="#related">Related</a></li>
</ul>
Expand Down
14 changes: 10 additions & 4 deletions docs/guides/adc-specifications.html
Original file line number Diff line number Diff line change
Expand Up @@ -1734,7 +1734,8 @@ <h3 id="uc-27-score-sessions-for-selection">UC-27: Score Sessions for Selection<
</ol>
<p><strong>Alternate Flows:</strong></p>
<ul>
<li>A run is already queued or in progress for the same event: HTTP 409 rather than a duplicate run</li>
<li>A run is already queued or in progress for the same event: the trigger still answers 202 Accepted, and no duplicate run happens. The handler takes a per-event claim before it starts, so a second pass for an event already being scored logs and completes without paying for the same calls twice</li>
<li>The event does not exist or is soft-deleted: HTTP 404 and nothing is queued</li>
</ul>
<p><strong>Postconditions:</strong> Sessions carry AI scores that inform the accept/decline decision.</p>
<blockquote>
Expand Down Expand Up @@ -3954,8 +3955,13 @@ <h3 id="111-error-response-format">11.1 Error Response Format</h3>
<td><code>&quot;Validation failed&quot;</code></td>
</tr>
<tr>
<td>401 Unauthorized</td>
<td>Registration abuse prevention (BR-213): code <code>Auth.RegistrationRateLimitExceeded</code></td>
<td><code>&quot;Operation failed&quot;</code></td>
</tr>
<tr>
<td>429 Too Many Requests</td>
<td>Sessionize refresh throttle (BR-63) exceeded, or login brute-force protection (BR-212), or registration abuse prevention (BR-213)</td>
<td>Sessionize refresh throttle (BR-63) exceeded, or login brute-force protection (BR-212)</td>
<td><code>&quot;Too many requests&quot;</code></td>
</tr>
<tr>
Expand Down Expand Up @@ -4354,7 +4360,7 @@ <h3 id="1112-session-selection-organizer-decision-support">11.12 Session Selecti
</tr>
<tr>
<td><code>POST /api/sessionselection/score/{eventId}</code></td>
<td>Queues AI scoring; returns <strong>202 Accepted</strong>, or <strong>409</strong> when a run is already queued or in progress for that event (UC-27)</td>
<td>Queues AI scoring; returns <strong>202 Accepted</strong> (also when a run is already queued or in progress for that event, which the handler de-duplicates), or <strong>404</strong> when the event does not exist (UC-27)</td>
</tr>
</tbody></table></div>
<h3 id="1113-session-materials-br-116b">11.13 Session Materials (BR-116b)</h3>
Expand Down Expand Up @@ -5079,7 +5085,7 @@ <h3 id="126-business-rules">12.6 Business Rules</h3>
</tr>
<tr>
<td>BR-213</td>
<td>Registration <strong>abuse prevention:</strong> Maximum <strong>10 account registrations per IP address per hour</strong>. Excess attempts return HTTP 429.</td>
<td>Registration <strong>abuse prevention:</strong> Maximum <strong>10 account registrations per IP address per hour</strong>. Excess attempts return HTTP 401 with code <code>Auth.RegistrationRateLimitExceeded</code>, the uniform login-protection failure of ADR-029. The client IP is the browser&#39;s on every path: the WebAssembly proxy and the Blazor Server circuit both forward it, so first-visit registrations do not share one bucket keyed on the UI host.</td>
</tr>
<tr>
<td>BR-214</td>
Expand Down
4 changes: 2 additions & 2 deletions sitemap.xml
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@
</url>
<url>
<loc>https://ivanball.github.io/docs/adr/029-authentication-brute-force-protection.html</loc>
<lastmod>2026-10-01</lastmod>
<lastmod>2026-10-03</lastmod>
<priority>0.6</priority>
</url>
<url>
Expand Down Expand Up @@ -957,7 +957,7 @@
</url>
<url>
<loc>https://ivanball.github.io/docs/guides/adc-specifications.html</loc>
<lastmod>2026-09-21</lastmod>
<lastmod>2026-10-03</lastmod>
<priority>0.6</priority>
</url>
<url>
Expand Down
Loading