diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0f1ce7cc..b3fe368d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/audit/claims.yaml b/audit/claims.yaml
index 79aea672..0516c39c 100644
--- a/audit/claims.yaml
+++ b/audit/claims.yaml
@@ -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.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 58defaab..e915ad8d 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -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:
@@ -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
@@ -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**
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index 7a34f787..f51c2cb6 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -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 = [
diff --git a/frontend/src/routes/forgot-password/+page.svelte b/frontend/src/routes/forgot-password/+page.svelte
new file mode 100644
index 00000000..5330545b
--- /dev/null
+++ b/frontend/src/routes/forgot-password/+page.svelte
@@ -0,0 +1,118 @@
+
+
+Reset your password — Calnode
+
+
+
+
+
+
+
+
Reset your password
+
+
+ {#if !loaded}
+
Loading…
+ {:else if !available}
+
+ 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,
+ calnode reset-admin resets any account from the command line.
+
+ {:else if sentMessage}
+
+
{sentMessage}
+
+ The link works once and expires in 30 minutes. If nothing arrives, check your spam folder,
+ or try again in a minute.
+
{checkError} You can still set your password below, or check the link again.
+
+
+ {/if}
+ {#if error}
+
{error}
+ {/if}
+
+ {/if}
+
+
diff --git a/internal/db/migrations/00058_password_reset_tokens.sql b/internal/db/migrations/00058_password_reset_tokens.sql
new file mode 100644
index 00000000..b2d92a9d
--- /dev/null
+++ b/internal/db/migrations/00058_password_reset_tokens.sql
@@ -0,0 +1,23 @@
+-- +goose Up
+-- Self-service password reset links (issue #34). Same shape as magic_link_tokens
+-- (00040): only the SHA-256 of the token is stored, never the raw value, so a copy of
+-- the database (or of its Litestream replica) cannot be turned into a working link.
+-- Single use is enforced by a conditional UPDATE on used_at, not by this schema.
+--
+-- created_at is written by the application in RFC 3339 rather than defaulted by
+-- SQLite, because it is compared against RFC 3339 cutoffs: the per-account cooldown
+-- between reset emails reads it, and datetime('now') produces a different text format
+-- that would not order correctly against them.
+CREATE TABLE password_reset_tokens (
+ token_hash TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ expires_at TEXT NOT NULL,
+ used_at TEXT,
+ created_at TEXT NOT NULL
+);
+
+CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens(user_id, created_at);
+
+-- +goose Down
+DROP INDEX IF EXISTS idx_password_reset_tokens_user;
+DROP TABLE IF EXISTS password_reset_tokens;
diff --git a/internal/handler/export_test.go b/internal/handler/export_test.go
new file mode 100644
index 00000000..67241ea0
--- /dev/null
+++ b/internal/handler/export_test.go
@@ -0,0 +1,10 @@
+package handler
+
+// WaitPasswordResetSends blocks until every password-reset send started so far has
+// finished. RequestPasswordReset answers before it knows whether the account exists, so
+// a test asserting that nothing was minted for an unknown or ineligible address has to
+// wait for the background work to end rather than poll for a row that should never
+// appear.
+func (h *Handler) WaitPasswordResetSends() {
+ h.resetSends.Wait()
+}
diff --git a/internal/handler/handler.go b/internal/handler/handler.go
index 6adbb088..59f2b882 100644
--- a/internal/handler/handler.go
+++ b/internal/handler/handler.go
@@ -50,6 +50,7 @@ type Handler struct {
demoResetInterval time.Duration
demoMu sync.RWMutex
demoNextResetAt time.Time
+ resetSends sync.WaitGroup // password-reset emails running after their request was answered
}
// SetLiveKit swaps the active LiveKit client (nil disables built-in video rooms).
diff --git a/internal/handler/password_reset.go b/internal/handler/password_reset.go
new file mode 100644
index 00000000..26f90027
--- /dev/null
+++ b/internal/handler/password_reset.go
@@ -0,0 +1,359 @@
+package handler
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html"
+ "net/http"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+
+ "github.com/calnode/calnode/internal/mailer"
+)
+
+// passwordResetTTL is how long an emailed reset link stays usable. It is longer than the
+// magic link's 15 minutes only to survive slow delivery: greylisting mail servers commonly
+// hold a first message from a new sender for 5 to 15 minutes, and a link that is dead on
+// arrival sends the user straight back to the form. It stays at the short end of the usual
+// range otherwise, because a reset link is exactly as powerful as a login link.
+const passwordResetTTL = 30 * time.Minute
+
+// passwordResetCooldown is the minimum gap between two reset emails for one account.
+// The per-IP limit on the route cannot provide this, because a sender with many
+// addresses gets many buckets. It matters for more than inbox flooding: every new
+// request invalidates the account's outstanding link, so without a cooldown anyone who
+// knows the address could keep the owner's links dying faster than they can click them.
+// With it, the newest link always survives at least this long.
+const passwordResetCooldown = time.Minute
+
+// passwordResetAccepted is the one response the request endpoint ever gives to a
+// well-formed body. It must not vary with whether the account exists, is archived, signs
+// in with SSO only, is inside its cooldown, or whether email is configured at all.
+const passwordResetAccepted = "If that email belongs to an account that signs in with a password, a reset link is on its way."
+
+// passwordResetInvalid is the single answer for every token that cannot be used: unknown,
+// expired, already used, superseded by a newer request, or belonging to an account that
+// has since been archived or no longer signs in with a password.
+const passwordResetInvalid = "This reset link is invalid or has expired. Request a new one."
+
+// resetEligibleUser is the SQL predicate, over users aliased u, for an account that may
+// reset its password by email. It is LoginEmail's own condition for a password sign-in to
+// be possible (email_login set and a hash present) plus not archived, so the reset can
+// never hand a password to an account that could not have used one: an SSO-only member
+// gets a password from an admin, not from their inbox. Request, check and confirm all
+// read this one definition so the three cannot drift apart.
+const resetEligibleUser = `u.archived_at IS NULL AND u.email_login = 1 AND COALESCE(u.password_hash, '') != ''`
+
+func hashPasswordResetToken(raw string) string {
+ sum := sha256.Sum256([]byte(raw))
+ return hex.EncodeToString(sum[:])
+}
+
+// RequestPasswordReset handles POST /v1/auth/password-reset/request (public). It answers
+// immediately with the same status and body for every well-formed request, and does no
+// work that depends on the account before answering: the user lookup, the cooldown check,
+// the token write and the email all run in the background. Magic links (magic_link.go)
+// look the user up inline and move only the send off the request; this goes one step
+// further so there is not even a found-versus-missing query to time.
+//
+// It is a goroutine rather than a worker job for two reasons. A job's payload is stored,
+// so the raw token cannot travel in it, and minting inside the job would mean enqueueing
+// for every address typed, known or not, which writes attacker-chosen strings into a
+// jobs table that nothing purges (and that Litestream, where configured, replicates
+// offsite). Enqueueing only for known accounts would put an account-dependent write back
+// on the request path.
+func (h *Handler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
+ var req struct {
+ Email string `json:"email"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ h.writeError(w, http.StatusBadRequest, "invalid JSON")
+ return
+ }
+ email := strings.TrimSpace(strings.ToLower(req.Email))
+ // isEmailEnabled is instance configuration, not a property of the account, so
+ // branching on it here tells a caller nothing they could not read from
+ // GET /v1/auth/status.
+ if email != "" && h.isEmailEnabled() {
+ ctx := context.WithoutCancel(r.Context())
+ h.resetSends.Go(func() { h.sendPasswordReset(ctx, email) })
+ }
+ h.writeJSON(w, http.StatusOK, map[string]any{
+ "ok": true,
+ "message": passwordResetAccepted,
+ })
+}
+
+// sendPasswordReset mints a reset token for email's account and mails the link, when the
+// account is eligible and outside its cooldown. Every other outcome is silent by design:
+// the requester has already been answered, and logging an unknown address would copy
+// attacker-supplied strings into the logs.
+func (h *Handler) sendPasswordReset(ctx context.Context, email string) {
+ var userID string
+ err := h.db.QueryRowContext(ctx,
+ `SELECT u.id FROM users u WHERE u.email = ? AND `+resetEligibleUser, email).Scan(&userID)
+ if errors.Is(err, sql.ErrNoRows) {
+ return
+ }
+ if err != nil {
+ h.logger.ErrorContext(ctx, "password reset: look up user", "error", err)
+ return
+ }
+
+ rawBytes := make([]byte, 32)
+ if _, err := rand.Read(rawBytes); err != nil {
+ h.logger.ErrorContext(ctx, "password reset: rand", "error", err)
+ return
+ }
+ raw := hex.EncodeToString(rawBytes)
+ now := time.Now().UTC()
+
+ // Cooldown check, invalidation and insert in one transaction. With the single SQLite
+ // connection that serialises two concurrent requests for the same account, so both
+ // cannot pass the cooldown and mint.
+ tx, err := h.db.BeginTx(ctx, nil)
+ if err != nil {
+ h.logger.ErrorContext(ctx, "password reset: begin tx", "error", err)
+ return
+ }
+ defer tx.Rollback() //nolint:errcheck
+
+ var recent int
+ if err := tx.QueryRowContext(ctx,
+ `SELECT COUNT(*) FROM password_reset_tokens WHERE user_id = ? AND created_at > ?`,
+ userID, now.Add(-passwordResetCooldown).Format(time.RFC3339)).Scan(&recent); err != nil {
+ h.logger.ErrorContext(ctx, "password reset: cooldown check", "error", err)
+ return
+ }
+ if recent > 0 {
+ h.logger.InfoContext(ctx, "password reset: skipped, a link was sent within the cooldown",
+ "user_id", userID, "cooldown", passwordResetCooldown.String())
+ return
+ }
+ // Only one live link per account: a new request supersedes the old one, the same
+ // rule issueInvite applies to invites.
+ if _, err := tx.ExecContext(ctx,
+ `DELETE FROM password_reset_tokens WHERE user_id = ? AND used_at IS NULL`, userID); err != nil {
+ h.logger.ErrorContext(ctx, "password reset: invalidate older links", "error", err)
+ return
+ }
+ if _, err := tx.ExecContext(ctx,
+ `INSERT INTO password_reset_tokens (token_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)`,
+ hashPasswordResetToken(raw), userID,
+ now.Add(passwordResetTTL).Format(time.RFC3339), now.Format(time.RFC3339)); err != nil {
+ h.logger.ErrorContext(ctx, "password reset: store token", "error", err)
+ return
+ }
+ if err := tx.Commit(); err != nil {
+ h.logger.ErrorContext(ctx, "password reset: commit", "error", err)
+ return
+ }
+
+ // The link is built from the configured BASE_URL and never from anything in the
+ // request. A reset link assembled from the Host header lets whoever sends the request
+ // choose the domain the victim's token is delivered to.
+ //
+ // The token travels in the fragment, unlike invites (path) and magic links (query).
+ // A fragment is never sent to the server, so it stays out of reverse-proxy access
+ // logs, and it is never included in a Referer, including the one on the admin app's
+ // font request. The reset page reads it client-side and strips it from the address bar.
+ link := h.baseURL + "/admin/reset-password#token=" + raw
+ if err := h.mailer.Send(ctx, passwordResetMessage(email, link)); err != nil {
+ h.logger.ErrorContext(ctx, "password reset: send email", "error", err, "user_id", userID)
+ }
+}
+
+// passwordResetTokenRequest is the body of the check and confirm endpoints. The token is
+// posted rather than put in a URL for the same reason the email link carries it in a
+// fragment: nothing that logs request lines ever sees it.
+type passwordResetTokenRequest struct {
+ Token string `json:"token"`
+ Password string `json:"password"`
+}
+
+// CheckPasswordReset handles POST /v1/auth/password-reset/check (public). It reports
+// whether a token can still be used, without consuming it, and returns the account's
+// email so the reset page can say whose password is being changed and give password
+// managers a username to save the new password against. The email reveals nothing the
+// token holder cannot already take: the token alone is enough to set the password.
+func (h *Handler) CheckPasswordReset(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
+ var req passwordResetTokenRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ h.writeError(w, http.StatusBadRequest, "invalid JSON")
+ return
+ }
+ email, _, err := h.lookupPasswordResetToken(r.Context(), strings.TrimSpace(req.Token))
+ if err != nil {
+ if !errors.Is(err, sql.ErrNoRows) {
+ h.logger.ErrorContext(r.Context(), "password reset check: lookup", "error", err)
+ }
+ h.writeError(w, http.StatusNotFound, passwordResetInvalid)
+ return
+ }
+ h.writeJSON(w, http.StatusOK, map[string]any{"email": email})
+}
+
+// lookupPasswordResetToken returns the email and user id behind a usable token, or
+// sql.ErrNoRows when there is none (unknown, expired, used, or the account no longer
+// qualifies).
+func (h *Handler) lookupPasswordResetToken(ctx context.Context, raw string) (email, userID string, err error) {
+ if raw == "" {
+ return "", "", sql.ErrNoRows
+ }
+ err = h.db.QueryRowContext(ctx, `
+ SELECT u.email, u.id FROM password_reset_tokens t
+ JOIN users u ON u.id = t.user_id
+ WHERE t.token_hash = ? AND t.used_at IS NULL AND t.expires_at > ? AND `+resetEligibleUser,
+ hashPasswordResetToken(raw), time.Now().UTC().Format(time.RFC3339)).Scan(&email, &userID)
+ return email, userID, err
+}
+
+// ConfirmPasswordReset handles POST /v1/auth/password-reset/confirm (public). It consumes
+// the token, sets the new password, revokes every session the account holds, and then
+// signs the caller in, as claiming an invite and following a magic link both do.
+//
+// Order matters in three places:
+// - The password policy is checked before anything else, so a rejected password does
+// not spend the link.
+// - bcrypt runs before the transaction. At DefaultCost it takes long enough that
+// holding the single SQLite connection across it would stall every other request.
+// A cheap non-consuming lookup comes first so an unusable token never costs a hash.
+// - The token is consumed by a conditional UPDATE inside the transaction, and that
+// UPDATE is the authority: two concurrent confirms can both pass the lookup, and
+// exactly one of them will see a row affected.
+func (h *Handler) ConfirmPasswordReset(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
+ var req passwordResetTokenRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ h.writeError(w, http.StatusBadRequest, "invalid JSON")
+ return
+ }
+ token := strings.TrimSpace(req.Token)
+ if msg := validatePassword(req.Password); msg != "" {
+ h.writeError(w, http.StatusBadRequest, msg)
+ return
+ }
+ if _, _, err := h.lookupPasswordResetToken(r.Context(), token); err != nil {
+ if !errors.Is(err, sql.ErrNoRows) {
+ h.logger.ErrorContext(r.Context(), "password reset confirm: lookup", "error", err)
+ }
+ h.writeError(w, http.StatusNotFound, passwordResetInvalid)
+ return
+ }
+
+ newHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
+ if err != nil {
+ h.logger.ErrorContext(r.Context(), "password reset confirm: bcrypt", "error", err)
+ h.writeError(w, http.StatusInternalServerError, "internal error")
+ return
+ }
+
+ tokenHash := hashPasswordResetToken(token)
+ now := time.Now().UTC().Format(time.RFC3339)
+
+ tx, err := h.db.BeginTx(r.Context(), nil)
+ if err != nil {
+ h.logger.ErrorContext(r.Context(), "password reset confirm: begin tx", "error", err)
+ h.writeError(w, http.StatusInternalServerError, "internal error")
+ return
+ }
+ defer tx.Rollback() //nolint:errcheck
+
+ res, err := tx.ExecContext(r.Context(),
+ `UPDATE password_reset_tokens SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?`,
+ now, tokenHash, now)
+ if err != nil {
+ h.logger.ErrorContext(r.Context(), "password reset confirm: consume token", "error", err)
+ h.writeError(w, http.StatusInternalServerError, "internal error")
+ return
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ h.writeError(w, http.StatusNotFound, passwordResetInvalid) // lost a race, or expired since the lookup
+ return
+ }
+
+ var userID string
+ if err := tx.QueryRowContext(r.Context(),
+ `SELECT user_id FROM password_reset_tokens WHERE token_hash = ?`, tokenHash).Scan(&userID); err != nil {
+ h.logger.ErrorContext(r.Context(), "password reset confirm: load user", "error", err)
+ h.writeError(w, http.StatusInternalServerError, "internal error")
+ return
+ }
+
+ // The eligibility predicate is re-applied at the write, not only at the lookup above,
+ // so an account archived (or moved off password sign-in) during the bcrypt cannot slip
+ // through. Refusing rolls the consume back: like a session, a link is judged against
+ // the account as it is when used, which is how archiving works everywhere else.
+ res, err = tx.ExecContext(r.Context(),
+ `UPDATE users AS u SET password_hash = ? WHERE u.id = ? AND `+resetEligibleUser,
+ string(newHash), userID)
+ if err != nil {
+ h.logger.ErrorContext(r.Context(), "password reset confirm: update password", "error", err)
+ h.writeError(w, http.StatusInternalServerError, "internal error")
+ return
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ h.writeError(w, http.StatusNotFound, passwordResetInvalid)
+ return
+ }
+
+ // Revoke every session, as AdminSetPassword does. A reset is what someone does when
+ // they believe their password is known to someone else, and a stolen session cookie
+ // must not outlive that. Any other outstanding link for the account dies too.
+ if _, err := tx.ExecContext(r.Context(), `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil {
+ h.logger.ErrorContext(r.Context(), "password reset confirm: revoke sessions", "error", err)
+ h.writeError(w, http.StatusInternalServerError, "internal error")
+ return
+ }
+ if _, err := tx.ExecContext(r.Context(),
+ `DELETE FROM password_reset_tokens WHERE user_id = ? AND token_hash != ?`, userID, tokenHash); err != nil {
+ h.logger.ErrorContext(r.Context(), "password reset confirm: invalidate other links", "error", err)
+ h.writeError(w, http.StatusInternalServerError, "internal error")
+ return
+ }
+ if err := tx.Commit(); err != nil {
+ h.logger.ErrorContext(r.Context(), "password reset confirm: commit", "error", err)
+ h.writeError(w, http.StatusInternalServerError, "internal error")
+ return
+ }
+
+ if err := h.createSession(r.Context(), w, userID); err != nil {
+ // The password is already changed; the user can sign in with it normally.
+ h.logger.ErrorContext(r.Context(), "password reset confirm: create session", "error", err)
+ h.writeJSON(w, http.StatusOK, map[string]any{"ok": true, "signed_in": false})
+ return
+ }
+ h.writeJSON(w, http.StatusOK, map[string]any{"ok": true, "signed_in": true})
+}
+
+// passwordResetMessage builds the reset email (plain text plus minimal HTML), in the same
+// form as magicLinkMessage. English only, like every other message addressed to a member
+// rather than a booker: the admin app is not translated (ARCHITECTURE §23).
+func passwordResetMessage(to, link string) mailer.Message {
+ minutes := int(passwordResetTTL.Minutes())
+ escaped := html.EscapeString(link)
+ return mailer.Message{
+ To: []string{to},
+ Subject: "Reset your Calnode password",
+ Text: fmt.Sprintf("Someone asked to reset the password for the Calnode account %s. "+
+ "Click the link below to choose a new one. It expires in %d minutes and can be used once.\n\n"+
+ "%s\n\nIf you didn't ask for this, you can ignore this email. Your password has not been changed.",
+ to, minutes, link),
+ HTML: fmt.Sprintf(`
`+
+ `
Someone asked to reset the password for the Calnode account %s. Click the button below to choose a new one. It expires in %d minutes and can be used once.