Skip to content
Closed
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,25 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad

## [Unreleased]

### Added
- **A forgotten password can be reset by email.** The login page's "Forgot password?" link
pointed at a page that was never built, so it quietly bounced back to sign-in. It now
leads to a form that emails a reset link, and the link opens a page to choose a new
password. Closes [#34](https://github.com/Calnode/calnode/issues/34).

The request answers the same way whether or not the address has an account, and does no
account-dependent work before answering: the lookup, the token and the email happen after
the response has gone. Only an active account that signs in with a password is mailed;
an SSO-only member still gets a password from an admin. The link is built from
`BASE_URL`, never from the request's `Host`, and carries its token in the URL fragment,
so it never reaches a server or proxy log. Only the token's SHA-256 is stored. A link
works once, for 30 minutes, and only the newest one requested; one account gets at most
one email a minute however many addresses ask. Setting the new password signs out every
existing session, as an admin reset does, and signs the user in.

The link is shown only when email is configured and password sign-in is in use. New
migration `00058_password_reset_tokens`.

### Fixed
- **The Zoom setup text no longer promises that an unpublished app works for "your own
team".** Zoom only lets users inside the Zoom account that owns an unpublished app
Expand Down
33 changes: 33 additions & 0 deletions audit/claims.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -340,3 +340,36 @@ claims:
than add admin approval — the friction of gating a personal calendar connection
behind admin sign-off was judged worse than the bounded, already-authenticated-
user SSRF exposure it would close.

- id: password-reset-no-enumeration-no-host-poisoning
claim: >
Self-service password reset (POST /v1/auth/password-reset/request) cannot be used
to learn whether an address has an account, and the emailed link always points at
the configured BASE_URL, never at a host taken from the request. Only the SHA-256
of a reset token is stored; a token works once, for 30 minutes, and only while the
account is active and signs in with a password. Completing a reset revokes every
session the account held.
verify:
- "internal/handler/password_reset.go's RequestPasswordReset: writes one fixed
response and hands the email to a goroutine before any query runs; the user
lookup, token write and send are all in sendPasswordReset."
- "internal/handler/password_reset.go's sendPasswordReset: the link is
h.baseURL + \"/admin/reset-password#token=\"; grep the file for r.Host or
X-Forwarded and expect nothing."
- "internal/handler/password_reset_test.go: TestPasswordReset_requestIsIndistinguishable
(same status, Content-Type and body for existing, unknown, archived, SSO-only and
email-off), TestPasswordReset_linkUsesBaseURLNotHostHeader (hostile Host,
X-Forwarded-Host, Forwarded, Origin and Referer), TestPasswordReset_storesOnlyTheHash
(scans every column of every table for the raw token),
TestPasswordReset_concurrentConfirmsSpendTheTokenOnce and
TestPasswordReset_revokesSessions."
status: verified
caveat: >
Whoever controls an account's mailbox can take the account over. That was already
true before this endpoint existed, because magic-link sign-in grants a session to
the same proof. Response timing is flat on the request path, but a reset email's
arrival is of course visible to the mailbox owner, and an operator who reads logs
sees an info line when a request is suppressed by the per-account cooldown. API
keys and connected MCP apps survive a reset, as they survive the two other
password-change paths (POST /v1/users/me/password and POST /v1/users/{id}/password);
revoking them on any password change would be a separate decision for all three.
42 changes: 40 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ partial unique index covers) — no TOCTOU between concurrent bookings.
`archived_by`, plus prefs `time_format`/`week_start`/`date_format`, seven
`notify_*` toggles, and auth columns `email_login`/`password_hash`/`provider`/
`provider_id`), `sessions` (cookie auth), `api_keys` (SHA-256 hashed),
`invite_tokens` (hashed, single-use). **There is no `auth_providers` table** —
`invite_tokens` (hashed, single-use), `magic_link_tokens` and
`password_reset_tokens` (SHA-256 hashed, single-use, short-lived). **There is no `auth_providers` table** —
OAuth identity is columns on `users` (migration 00012).
- **Teams:** `teams`, `team_members` (with `routing_priority`).
- **Event types:** `event_types` (+ `routing_mode` — CHECK has **four** values:
Expand Down Expand Up @@ -177,6 +178,42 @@ the platform/recovery secret doesn't expose secrets.
`POST /v1/auth/magic-link/request` + `GET …/verify`, `magic_link.go`, migration
00040 — alongside password auth.) The actual user-creation path for non-first
users is **invites** (`invite_tokens`), not OAuth self-registration.
- **Password reset by email** (`password_reset.go`, migration 00058, issue #34). Three
public routes on their own per-IP bucket (10/min, deliberately not `loginRL`, so someone
locked out of login by wrong passwords can still recover):
`POST /v1/auth/password-reset/request` `{email}` → email → SPA
`/admin/reset-password#token=…` → `POST …/check` `{token}` (non-consuming, returns the
account email for display and password managers) → `POST …/confirm` `{token, password}`.
The rules that matter:
- **Eligible = LoginEmail's own condition** (`email_login = 1` and a stored hash) **and not
archived**, one SQL predicate (`resetEligibleUser`) read by all three routes and
re-applied at the password write. An SSO-only account is never given a password by
email; an admin reset or `calnode reset-admin` does that. Like a session, a link is
judged against the account as it is when used.
- **The request is not an oracle.** It answers with one fixed status and body for every
well-formed request (unknown, archived, SSO-only, inside cooldown, email not
configured) and does no account-dependent work first: lookup, mint and send run in a
goroutine after the response, one step further than magic links, which look the user up
inline. Not a worker job: a job payload is stored, so the raw token could not ride in
it, and enqueueing for every typed address would persist attacker-chosen strings in a
`jobs` table that is never purged.
- **Token:** 32 random bytes, hex; only SHA-256 stored; **30 min** TTL (magic links are 15;
the extra time is for greylisting mail servers); single use via
`UPDATE … WHERE used_at IS NULL AND expires_at > ?` + RowsAffected, which is the
authority even though a cheap lookup runs first (so a bad token never costs a bcrypt).
A new request deletes the account's unused tokens. **Per-account cooldown of 1 min**
between emails, which the per-IP limit cannot give and which also stops a third party
from superseding the owner's newest link faster than it can be clicked.
- **The link is built from `BASE_URL`, never from the request** (host-header poisoning),
and the token rides in the **fragment**, unlike invites (path) and magic links (query), so
it never reaches a proxy access log or a Referer. The page strips it from the address bar.
- **Confirm** hashes before opening the transaction (never hold the single connection
across bcrypt), then in one transaction consumes the token, sets the hash, deletes
**every** session (as `AdminSetPassword` does) and the account's other reset tokens,
then signs the caller in (as invite claim and magic links do). API keys and MCP OAuth
grants survive, as they do for the other two password-change paths.
- Emails are English-only, like invites and magic links. The login page shows the link
only when email is configured **and** some account uses password login.
- **CSRF:** cookie sessions are `SameSite=Lax` (blocks the classic cross-site write),
plus a `SameOriginCheck` middleware (`internal/server`) that rejects a state-changing
request whose Origin/Referer host ≠ the request Host — but only when the
Expand Down Expand Up @@ -676,7 +713,8 @@ as the desired state:

- `internal/worker`: polls the `jobs` table **every 5s** (batch ≤10). Job types:
`webhook.deliver` and `reminder.send`. Also purges expired manage tokens +
sessions + magic-link tokens + idempotency keys (>24h old) + OAuth auth codes +
sessions + magic-link and password-reset tokens (expired or used) + idempotency keys
(>24h old) + OAuth auth codes +
**finished webhook deliveries (>30d, `webhookDeliveryRetention`)** each cycle, and
reaps jobs whose 30s lock expired (crash recovery —
reset to pending +1 min). Retry **backoff is a fixed two-step: 60s then 5 min**
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
const isPublicRoute = $derived(
$page.route.id === '/login' ||
$page.route.id === '/claim' ||
$page.route.id === '/invite/[token]'
$page.route.id === '/invite/[token]' ||
$page.route.id === '/forgot-password' ||
$page.route.id === '/reset-password'
);

const navItems = [
Expand Down
118 changes: 118 additions & 0 deletions frontend/src/routes/forgot-password/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';

type AuthStatus = {
email_login: boolean;
smtp_configured: boolean;
};

// null while loading. If the status call itself fails the form is shown anyway: the
// request endpoint answers the same way whether or not it can send.
let status = $state<AuthStatus | null>(null);
let loaded = $state(false);
let email = $state('');
let submitting = $state(false);
let sentMessage = $state('');
let error = $state('');

onMount(async () => {
try {
const res = await fetch('/v1/auth/status');
if (res.ok) status = await res.json();
} catch {
// Fall through to the form.
} finally {
loaded = true;
}
});

const available = $derived(status === null || (status.smtp_configured && status.email_login));
const isValidEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);

async function requestReset(e: SubmitEvent) {
e.preventDefault();
const addr = email.trim().toLowerCase();
if (!isValidEmail(addr)) {
error = 'Enter a valid email address.';
return;
}
error = '';
submitting = true;
try {
const res = await fetch('/v1/auth/password-reset/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: addr })
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
sentMessage = data.message;
} else if (res.status === 429) {
error = 'Too many requests. Wait a minute and try again.';
} else {
error = data.error || 'Something went wrong. Please try again.';
}
} catch {
error = 'Could not reach the server. Please try again.';
} finally {
submitting = false;
}
}
</script>

<svelte:head><title>Reset your password — Calnode</title></svelte:head>

<div class="flex min-h-screen items-center justify-center bg-muted/30 p-6">
<div class="w-full max-w-sm">
<div class="mb-8 text-center">
<div class="mb-3 flex justify-center">
<svg viewBox="0 0 27 31" width="36" xmlns="http://www.w3.org/2000/svg">
<path fill="#60a5fa" d="m 3.043653,30.614292 c -1.04707,-0.32444 -1.94939,-1.09611 -2.51053002,-2.14706 l -0.28493,-0.53364 -0.0338,-10.26902 c -0.0333,-10.1001 -0.0296,-10.28026 0.22157,-10.95165 C 0.93698298,5.3738426 2.254043,4.3160626 3.592343,4.1779326 l 0.66734,-0.069 0.0442,1.54722 c 0.0411,1.4322294 0.069,1.5846294 0.37758,2.0507194 0.43775,0.66128 1.06374,1.03377 1.8697695,1.11256 0.83411,0.0815 1.60889,-0.30288 2.1454309,-1.06449 0.35775,-0.50781 0.37448,-0.59108 0.41656,-2.0715194 l 0.0439,-1.54245 h 4.3226446 4.32263 l 0.0491,1.45985 c 0.0578,1.7152194 0.26715,2.2526994 1.10313,2.8320394 0.44147,0.30594 0.6462,0.36331 1.30282,0.36509 0.6673,0.002 0.85479,-0.0513 1.30717,-0.3706 0.83953,-0.59252 1.03808,-1.10942 1.09117,-2.8410394 l 0.0452,-1.47436 0.61114,0.0588 c 0.88513,0.085 1.92322,0.66148 2.52855,1.40407 0.98071,1.2030494 0.9433,0.7026404 0.90509,12.1120594 l -0.0339,10.12241 -0.34547,0.70339 c -0.37943,0.77247 -1.03179,1.43013 -1.85424,1.86928 l -0.53363,0.28492 -10.31215,0.0218 c -5.6716955,0.0121 -10.451945,-0.0215 -10.622765,-0.0745 z M 5.994413,7.609932 C 5.466163,7.302262 5.404273,6.907952 5.404273,3.8497926 v -2.91273 l 0.36045,-0.38587 c 0.39686,-0.42484 0.9678395,-0.51125 1.5687795,-0.23743 0.6263,0.28536 0.67987,0.58051 0.67987,3.7459 V 6.972402 l -0.36047,0.38586 c -0.29807,0.31911 -0.45393,0.38521 -0.90075,0.3821 -0.29716,-0.002 -0.6381595,-0.0608 -0.7577395,-0.13043 z m 13.312785,-0.25167 -0.36045,-0.38586 V 4.0596626 c 0,-3.08584 0.0628,-3.47128 0.61433,-3.76641 0.31968,-0.17109 0.91477,-0.16181 1.3149,0.0204 0.62629,0.28536 0.67987,0.58051 0.67987,3.74591 V 6.972292 l -0.36046,0.38587 c -0.30429,0.32575 -0.45138,0.38587 -0.9441,0.38587 -0.4927,0 -0.6398,-0.0601 -0.94409,-0.38587 z"/>
<path fill="#ffffff" d="M 13.529898,23.489202 c 0.41676,-0.42858 2.30921,-2.34577 4.20548,-4.26044 3.89443,-3.93222 3.79896,-3.77881 2.93494,-4.71617 -0.86333,-0.9366 -0.70987,-1.03489 -4.7574,3.04728 -2.01816,2.03542 -3.63135,3.56753 -3.70704,3.52074 -0.0737,-0.0455 -0.86549,-0.83379 -1.759495,-1.7516 -1.7365396,-1.78282 -2.1646795,-2.10404 -2.5380305,-1.90423 -0.40259,0.21546 -1.13741,1.12099 -1.13741,1.40162 0,0.18848 0.79327,1.06899 2.5741409,2.85723 3.0861346,3.09893 2.9741146,3.05059 4.1848146,1.80557 z"/>
</svg>
</div>
<h1 class="text-xl font-semibold tracking-tight">Reset your password</h1>
</div>

{#if !loaded}
<p class="text-center text-sm text-muted-foreground">Loading…</p>
{:else if !available}
<div class="rounded-md bg-muted px-4 py-3 text-sm text-muted-foreground">
Password reset by email isn't available on this server. Ask a workspace admin to set a new
password for you from the Members page. If you run this server,
<code class="text-foreground">calnode reset-admin</code> resets any account from the command line.
</div>
{:else if sentMessage}
<div class="space-y-3">
<div class="rounded-md bg-green-50 px-3 py-2.5 text-sm text-green-700">{sentMessage}</div>
<p class="text-sm text-muted-foreground">
The link works once and expires in 30 minutes. If nothing arrives, check your spam folder,
or try again in a minute.
</p>
</div>
{:else}
{#if error}
<div class="mb-4 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</div>
{/if}
<form onsubmit={requestReset} class="space-y-4">
<p class="text-sm text-muted-foreground">
Enter the email you sign in with and we'll send you a link to choose a new password.
</p>
<div class="space-y-1.5">
<Label for="email">Email</Label>
<Input id="email" type="email" autocomplete="email" bind:value={email} required />
</div>
<Button type="submit" class="h-11 w-full" disabled={submitting}>
{submitting ? 'Sending…' : 'Send reset link'}
</Button>
</form>
{/if}

<p class="mt-6 text-center text-sm">
<a href="/admin/login" class="text-muted-foreground hover:underline">Back to sign in</a>
</p>
</div>
</div>
3 changes: 2 additions & 1 deletion frontend/src/routes/login/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@
const showGoogle = $derived(status?.providers?.includes('google') ?? false);
const showMicrosoft = $derived(status?.providers?.includes('microsoft') ?? false);
const showEmail = $derived(status?.email_login ?? false);
const showForgot = $derived(status?.smtp_configured ?? false);
// Reset needs a way to send the link and an account that signs in with a password.
const showForgot = $derived((status?.smtp_configured && status?.email_login) ?? false);
const showMagic = $derived(status?.smtp_configured ?? false);
const showDivider = $derived((showGoogle || showMicrosoft) && showEmail);
</script>
Expand Down
Loading
Loading