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 backend/InTheKnow.Tests/ApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ public async Task EveryApiRouteNeedsASession()
internal static readonly HashSet<string> PublicRoutes =
[
"/api/auth/bot-check", "/api/auth/sign-up", "/api/auth/confirmation", "/api/auth/confirm", "/api/auth/sign-in",
"/api/auth/password-reset", "/api/auth/password-reset/complete", "/api/auth/email-change/confirm", "/api/auth/invite/accept",
"/api/auth/password-reset", "/api/auth/password-reset/check", "/api/auth/password-reset/complete", "/api/auth/email-change/confirm", "/api/auth/invite/accept",
];

// A route pattern with its parameters filled with 1.
Expand Down
54 changes: 54 additions & 0 deletions backend/InTheKnow.Tests/SignInTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -317,4 +317,58 @@ public async Task ResetConfirmsAndAddsAPassword()
Assert.Equal(1, await d.Db.ScalarAsync("SELECT count(*) FROM account_message WHERE kind = 'methodAdded' AND account_id = $1", keyOnly.AccountId));
await d.H.SignInViaAsync("password", keyOnly.Email, "chosen-password-34");
}

// --- 009: the reset link is checked before a password is typed -------------------------------------------------

static Task<HttpResponseMessage> CheckAsync(DeliveryHost d, string? token) =>
SignUpTests.PostAsync(d, "/api/auth/password-reset/check", new { token });

// 009 US1, FR-002, FR-007
[Fact]
public async Task ResetCheckRefusesDeadLinks()
{
await using var d = await SignUpTests.HostAsync();
var used = await ResetTokenAsync(d, d.S1.Email);
await Expect(await CompleteAsync(d, used, "new-password-4567"), HttpStatusCode.NoContent);
await Expect(await CheckAsync(d, used), HttpStatusCode.BadRequest, "token_invalid");

var replaced = await ResetTokenAsync(d, d.S1.Email);
var current = await ResetTokenAsync(d, d.S1.Email);
await Expect(await CheckAsync(d, current), HttpStatusCode.NoContent);

await SignUpTests.SignUpAsync(d, "pending@example.nl");
await d.NotifyAsync(2);
var confirmation = SignUpTests.TokenOf(SignUpTests.Mails(d, "confirm").Last(m => m.Target == "pending@example.nl").Message);

var disabled = await ResetTokenAsync(d, d.S2.Email);
await d.Db.ExecAsync("UPDATE account SET disabled_at = $2 WHERE id = $1", d.S2.AccountId, d.Now);

foreach (var dead in new[] { "nonsense", "", confirmation, disabled })
await Expect(await CheckAsync(d, dead), HttpStatusCode.BadRequest, "token_invalid");
await Expect(await SignUpTests.PostAsync(d, "/api/auth/password-reset/check", new { }), HttpStatusCode.BadRequest, "token_invalid");

d.H.Time.Advance(TimeSpan.FromHours(1) + TimeSpan.FromSeconds(1));
await Expect(await CheckAsync(d, current), HttpStatusCode.BadRequest, "token_invalid");
Assert.NotEqual(replaced, current);
}

// 009 US2, FR-005, SC-002, SC-004
[Fact]
public async Task ResetCheckChangesNothing()
{
await using var d = await SignUpTests.HostAsync();
var a = d.S1;
var token = await ResetTokenAsync(d, a.Email);
async Task<string> SnapshotAsync() => string.Join("|",
(await d.Db.RowsAsync("SELECT id, used_at, token_expires_at FROM account_message WHERE account_id = $1 ORDER BY id", a.AccountId)).Select(r => string.Join(",", r)),
await d.Db.ScalarAsync("SELECT count(*) FROM session WHERE account_id = $1", a.AccountId),
await d.Db.ScalarAsync("SELECT count(*) FROM security_event WHERE account_id = $1", a.AccountId),
SignUpTests.Mails(d, "reset").Count);
var before = await SnapshotAsync();
for (var i = 0; i < 3; i++) await Expect(await CheckAsync(d, token), HttpStatusCode.NoContent);
Assert.Equal(before, await SnapshotAsync());

await Expect(await CompleteAsync(d, token, "new-password-4567"), HttpStatusCode.NoContent);
await d.H.SignInViaAsync("password", a.Email, "new-password-4567");
}
}
5 changes: 5 additions & 0 deletions backend/InTheKnow/AccountApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ await BotGateAsync(bot, body?.BotCheck, true, http.RequestAborted) ?? await sign
await accounts.CompleteResetAsync(body?.Token, body?.Password, http) is { } error ? Refuse(error) : Results.NoContent())
.AllowAnonymous().RequireRateLimiting("public");

// 009: side-effect free, so the reset page can show a dead link before a password is typed.
auth.MapPost("/password-reset/check", async (TokenPasswordRequest? body, HttpContext http, Accounts accounts) =>
await accounts.ResetUsableAsync(body?.Token, http.RequestAborted) ? Results.NoContent() : Refuse("token_invalid"))
.AllowAnonymous().RequireRateLimiting("public");

// The code step of a sign-in with 2FA on: only for an awaiting session (research R6).
auth.MapPost("/sign-in/code", (ClaimsPrincipal user, CodeRequest? body, HttpContext http, SignIn signIn) =>
user.HasClaim(c => c.Type == Claims.Awaiting) ? signIn.CodeAsync(user.SessionId(), user.AccountId(), body?.Code, http)
Expand Down
34 changes: 21 additions & 13 deletions backend/InTheKnow/Accounts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,26 +204,34 @@ public async Task RequestResetAsync(string? email, CancellationToken ct)
await tx.CommitAsync(ct);
}

// The one rule for "this reset link can still be used", shared by the check and the submit (009 FR-002).
static async Task<(long MessageId, long AccountId, string Email, string Language, bool Confirmed)?> FindResetAsync(
NpgsqlConnection conn, string? token, DateTimeOffset now, CancellationToken ct)
{
await using var cmd = new NpgsqlCommand("""
SELECT m.id, a.id, a.email, a.language, a.confirmed_at IS NOT NULL
FROM account_message m JOIN account a ON a.id = m.account_id
WHERE m.token_hash = $1 AND m.kind = 'reset' AND m.used_at IS NULL AND m.token_expires_at > $2 AND a.disabled_at IS NULL
""", conn) { Parameters = { new() { Value = SignIn.Hash(token ?? "") }, new() { Value = now } } };
await using var r = await cmd.ExecuteReaderAsync(ct);
return await r.ReadAsync(ct) ? (r.GetInt64(0), r.GetInt64(1), r.GetString(2), r.GetString(3), r.GetBoolean(4)) : null;
}

// 009: a pure read, so a link scanner or a page open never uses the link up.
public async Task<bool> ResetUsableAsync(string? token, CancellationToken ct)
{
await using var conn = await db.OpenConnectionAsync(ct);
return await FindResetAsync(conn, token, time.GetUtcNow(), ct) is not null;
}

// FR-009, FR-010: a new password (added when there was none), every session ends, the throttle is cleared, and an
// unconfirmed account is confirmed. 2FA is untouched.
public async Task<string?> CompleteResetAsync(string? token, string? password, HttpContext http)
{
var ct = http.RequestAborted;
var now = time.GetUtcNow();
await using var conn = await db.OpenConnectionAsync(ct);
long messageId, accountId;
string email, language;
bool confirmed;
await using (var cmd = new NpgsqlCommand("""
SELECT m.id, a.id, a.email, a.language, a.confirmed_at IS NOT NULL
FROM account_message m JOIN account a ON a.id = m.account_id
WHERE m.token_hash = $1 AND m.kind = 'reset' AND m.used_at IS NULL AND m.token_expires_at > $2 AND a.disabled_at IS NULL
""", conn) { Parameters = { new() { Value = SignIn.Hash(token ?? "") }, new() { Value = now } } })
await using (var r = await cmd.ExecuteReaderAsync(ct))
{
if (!await r.ReadAsync(ct)) return "token_invalid";
(messageId, accountId, email, language, confirmed) = (r.GetInt64(0), r.GetInt64(1), r.GetString(2), r.GetString(3), r.GetBoolean(4));
}
if (await FindResetAsync(conn, token, now, ct) is not var (messageId, accountId, email, language, confirmed)) return "token_invalid";
if (Passwords.Check(password, email) is { } rule) return rule;

await using var tx = await conn.BeginTransactionAsync(ct);
Expand Down
14 changes: 12 additions & 2 deletions frontend/src/routes/account/reset/+page.server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { api, clearSession } from '$lib/server/api';
import { api, clearSession, must } from '$lib/server/api';
import { raw, refuse } from '$lib/server/actions';

export const load = ({ url }) => ({ hasToken: Boolean(url.searchParams.get('token')) });
// 009: a dead link says so before a password is typed. After a submit the action's result decides the page, and a re-check
// would find the token just used (or fail on a rate limit) and hide "done"; reset links are always a full page load.
export const load = async (event) => {
const token = event.url.searchParams.get('token');
if (!token) return { usable: false };
if (event.request.method !== 'GET' || event.isDataRequest) return { usable: true };
const r = await api(event, 'POST', '/api/auth/password-reset/check', { token });
if (r.ok) return { usable: true };
if (r.status === 400 && r.reason === 'token_invalid') return { usable: false };
return must(event, r) as never;
};

export const actions = {
default: async (event) => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/routes/account/reset/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<p class="notice success" role="status">{t.reset.done}</p>
<div><Button href="/sign-in">{t.nav.signIn}</Button></div>
</AuthPanel>
{:else if !data.hasToken || r?.reason === 'token_invalid'}
{:else if !data.usable || r?.reason === 'token_invalid'}
<AuthPanel title={t.reset.title}>
<p class="notice error" role="alert">{t.reasons.token_invalid}</p>
<p><a href="/account/forgot-password">{t.reset.newLink}</a></p>
Expand Down
44 changes: 44 additions & 0 deletions frontend/src/routes/account/reset/reset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fakeEvent } from '$lib/server/fake';
import * as apiModule from '$lib/server/api';
import { load } from './+page.server';

vi.mock('$lib/server/api', async (orig) => ({ ...(await orig<typeof apiModule>()), api: vi.fn() }));
const api = vi.mocked(apiModule.api);

beforeEach(() => vi.clearAllMocks());

const run = (o: Parameters<typeof fakeEvent>[0]) => load(fakeEvent(o).event as never);
const attempt = <T>(fn: () => T) =>
Promise.resolve()
.then(fn)
.catch((e: unknown) => e);

describe('reset page load', () => {
it('shows the dead-link panel without calling the API when there is no token', async () => {
expect(await run({ url: '/account/reset' })).toEqual({ usable: false });
expect(api).not.toHaveBeenCalled();
});

it('does not re-check after a submit (POST or data request)', async () => {
expect(await run({ url: '/account/reset?token=T', method: 'POST' })).toEqual({ usable: true });
expect(await run({ url: '/account/reset?token=T', isDataRequest: true })).toEqual({ usable: true });
expect(api).not.toHaveBeenCalled();
});

it('shows the form when the check says the link is usable', async () => {
api.mockResolvedValue({ ok: true, status: 204, data: undefined });
expect(await run({ url: '/account/reset?token=T' })).toEqual({ usable: true });
expect(api.mock.calls[0].slice(1, 4)).toEqual(['POST', '/api/auth/password-reset/check', { token: 'T' }]);
});

it('shows the dead-link panel on token_invalid', async () => {
api.mockResolvedValue({ ok: false, status: 400, reason: 'token_invalid', extra: {} });
expect(await run({ url: '/account/reset?token=T' })).toEqual({ usable: false });
});

it.each([503, 429])('shows the error page when the check answers %i', async (status) => {
api.mockResolvedValue({ ok: false, status, reason: status === 503 ? 'unavailable' : 'rate_limited', extra: {} });
expect(await attempt(() => run({ url: '/account/reset?token=T' }))).toMatchObject({ status });
});
});
5 changes: 5 additions & 0 deletions specs/004-accounts-login/contracts/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ fully signed in. A used recovery code queues `recoveryCodeUsed` with the number
`{ "email": "…", "botCheck": "…?" }` → `202` always. A link is sent only if the address has a not-disabled account
under its cap of 3 per 24 h.

### `POST /api/auth/password-reset/check` 🚦 (009)

`{ "token": "…" }` → `204` when the link can still be used, `400 token_invalid` otherwise. Side-effect free; see
[009 contract](../../009-reset-link-precheck/contracts/reset-check.md).

### `POST /api/auth/password-reset/complete` 🚦 (FR-009, FR-010, US3-2 to US3-5)

`{ "token": "…", "password": "…" }` → `204`. The password is replaced or added, an unconfirmed account is confirmed,
Expand Down
1 change: 1 addition & 0 deletions specs/005-sveltekit-frontend/contracts/pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ second factor gets the "locked" page with a way forward (US8-1). Both come from
| `POST /api/auth/sign-in` | `/sign-in` | ✓ |
| `POST /api/auth/sign-in/code` | `/sign-in/code` | ✓ (code and recovery code) |
| `POST /api/auth/password-reset` | `/account/forgot-password` | ✓ |
| `POST /api/auth/password-reset/check` | `/account/reset` (load) | ✓ |
| `POST /api/auth/password-reset/complete` | `/account/reset` | ✓ |
| `POST /api/auth/email-change/confirm` | `/account/email` | ✓ |
| `POST /api/auth/invite/accept` | `/account/invite` | ✓ |
Expand Down
35 changes: 35 additions & 0 deletions specs/009-reset-link-precheck/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Specification Quality Checklist: Check the password-reset link before the new password is typed

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-10-21
**Feature**: [spec.md](../spec.md)

## Content Quality

- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed

## Requirement Completeness

- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified

## Feature Readiness

- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification

## Notes

- All items pass on the first validation pass. No clarifications were needed: the issue is narrow and every open
choice has a safe default that matches current behaviour (see Assumptions).
30 changes: 30 additions & 0 deletions specs/009-reset-link-precheck/contracts/reset-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Contract: reset-link check

## `POST /api/auth/password-reset/check` 🚦 (FR-001 to FR-007)

Public (no session), `"public"` per-source rate limit, no bot check.

`{ "token": "…" }` → `204` when the token can still complete a reset under the same rules as
`POST /api/auth/password-reset/complete`.

| Status | `reason` | When |
|---|---|---|
| `400` | `token_invalid` | missing, empty or unknown token; not a reset token; used; expired; account disabled |
| `429` | (rate limit) | too many requests from the source |

- Side-effect free: the token is not used, changed or extended; no session ends, no email is queued, no security-log
entry is written. Repeated calls give the same answer until the token is used or expires.
- The answer never says which rule failed and carries no account detail.
- `/complete` keeps checking the token itself; a `204` here is not a promise.

## Page `/account/reset?token=…`

| Situation | Shown |
|---|---|
| no `token` | "link no longer works" panel + `/account/forgot-password` link (no API call) |
| check `204` | the new-password form, as before |
| check `400 token_invalid` | "link no longer works" panel + `/account/forgot-password` link, no password field |
| check fails otherwise (API down, 429, 5xx) | the standard error page (`must()`) |
| form sent (any result) | `load` skips the check after a submit (POST, or the `use:enhance` data request), so the action's result decides the page |
| form sent, reset done | the "done" panel |
| form sent, `token_invalid` | "link no longer works" panel, as before |
13 changes: 13 additions & 0 deletions specs/009-reset-link-precheck/data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Data model: reset-link pre-check

No schema change and no new stored data.

The check only reads what `CompleteResetAsync` already reads:

| Table | Columns read | Rule (FR-002) |
|---|---|---|
| `account_message` | `token_hash`, `kind`, `used_at`, `token_expires_at`, `account_id` | `token_hash = hash(token)`, `kind = 'reset'`, `used_at IS NULL`, `token_expires_at > now` |
| `account` | `disabled_at` | `disabled_at IS NULL` |

Nothing is written: `used_at` and `token_expires_at` stay as they are, and no `session`, `security_event` or queued
`account_message` row is added or removed (FR-005, SC-004). Token lifetime (1 h) and single use are unchanged.
Loading
Loading