Add a full password-reset flow — the prerequisite for the Android app (docs/android/PLAN.md §8.2), which hands off to the website for reset rather than shipping a native screen. Backend: - password_resets table: stores only the sha256 hash of an opaque 32-byte token (mirrors user_invites / mobile_refresh_tokens), single-use, ~1h TTL. - model/passwordResets + users.getActiveByEmail (email is non-unique, so a request can match several accounts, each emailed its own link). - mailer.sendPasswordReset (fails soft when email is unconfigured). - Endpoints: POST /auth/password/forgot (always a generic 200 — no account enumeration), GET|POST /auth/password/reset/:token. Confirming rotates the hash and revokes every session (web cutoff + mobile refresh tokens); it does not auto-login, so a 2FA account still passes TOTP next sign-in. Also serves SSO-only accounts (null hash) as their set-initial-password path. - Dedicated request/confirm rate limiters. Swagger regenerated. Web: - ForgotPassword + ResetPassword pages, routes /account/forgot and /account/reset/:token, and a "Forgot your password?" link on the login page. Tests: test/passwordResets.test.js (5). All server tests pass; client builds; end-to-end smoketest against MariaDB passes (no-enumeration, single-use, hash rotation, session revoke, login with the new password). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
83 lines
3.6 KiB
JavaScript
83 lines
3.6 KiB
JavaScript
const { test, beforeEach, afterEach } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
// Exercise password-reset create/lookup/single-use consume against an in-memory
|
|
// fake by monkeypatching the shared db module the model require()s. No DB.
|
|
const db = require('../src/model/passwordResets/passwordResets.db')
|
|
const passwordResets = require('../src/model/passwordResets/passwordResets.model')
|
|
|
|
let rows
|
|
let nextId
|
|
const saved = {}
|
|
|
|
beforeEach(() => {
|
|
rows = []
|
|
nextId = 1
|
|
for (const k of ['insert', 'getById', 'findByTokenHash', 'markUsed', 'invalidatePendingForUser']) saved[k] = db[k]
|
|
db.insert = async ({ tokenHash, userId, requestedIp, expiresAt }) => {
|
|
const id = nextId++
|
|
rows.push({ id, token_hash: tokenHash, user_id: userId, status: 'pending', requested_ip: requestedIp ?? null, expires_at: expiresAt, created_at: new Date(), used_at: null })
|
|
return id
|
|
}
|
|
db.getById = async (id) => rows.find((r) => r.id === id) || null
|
|
db.findByTokenHash = async (h) => rows.find((r) => r.token_hash === h) || null
|
|
db.markUsed = async (id) => {
|
|
const row = rows.find((r) => r.id === id && r.status === 'pending')
|
|
if (!row) return 0
|
|
row.status = 'used'
|
|
row.used_at = new Date()
|
|
return 1
|
|
}
|
|
db.invalidatePendingForUser = async (userId) => {
|
|
let n = 0
|
|
for (const r of rows) if (r.user_id === userId && r.status === 'pending') { r.status = 'used'; n++ }
|
|
return n
|
|
}
|
|
})
|
|
|
|
afterEach(() => {
|
|
for (const k of Object.keys(saved)) db[k] = saved[k]
|
|
})
|
|
|
|
test('create stores only the token hash, never the plaintext token', async () => {
|
|
const { token } = await passwordResets.create({ userId: 7, requestedIp: '1.2.3.4' })
|
|
assert.ok(token && token.length >= 20)
|
|
assert.equal(rows[0].token_hash, passwordResets.hashToken(token))
|
|
assert.notEqual(rows[0].token_hash, token) // hash, not the raw token
|
|
assert.equal(rows[0].user_id, 7)
|
|
assert.equal(rows[0].status, 'pending')
|
|
})
|
|
|
|
test('findValidByToken resolves a pending token and rejects a wrong one', async () => {
|
|
const { token } = await passwordResets.create({ userId: 7 })
|
|
const row = await passwordResets.findValidByToken(token)
|
|
assert.ok(row)
|
|
assert.equal(row.user_id, 7)
|
|
assert.equal(await passwordResets.findValidByToken('not-a-real-token'), null)
|
|
assert.equal(await passwordResets.findValidByToken(''), null)
|
|
})
|
|
|
|
test('consume is single-use — the second consume loses the race', async () => {
|
|
const { token } = await passwordResets.create({ userId: 7 })
|
|
const row = await passwordResets.findValidByToken(token)
|
|
assert.equal(await passwordResets.consume(row.id), true)
|
|
assert.equal(await passwordResets.consume(row.id), false) // already used
|
|
assert.equal(await passwordResets.findValidByToken(token), null) // no longer pending
|
|
})
|
|
|
|
test('an expired reset is not valid (exercises the expiry branch, not a bad token)', async () => {
|
|
const { token } = await passwordResets.create({ userId: 7, ttlMinutes: -1 })
|
|
assert.ok(rows[0] && rows[0].status === 'pending') // token correct, row pending
|
|
assert.equal(await passwordResets.findValidByToken(token), null) // only expiry rejects it
|
|
})
|
|
|
|
test('invalidatePendingForUser retires every outstanding link for a user', async () => {
|
|
const a = await passwordResets.create({ userId: 7 })
|
|
const b = await passwordResets.create({ userId: 7 })
|
|
await passwordResets.create({ userId: 99 }) // a different user's link is untouched
|
|
await passwordResets.invalidatePendingForUser(7)
|
|
assert.equal(await passwordResets.findValidByToken(a.token), null)
|
|
assert.equal(await passwordResets.findValidByToken(b.token), null)
|
|
assert.equal(rows.filter((r) => r.status === 'pending' && r.user_id === 99).length, 1)
|
|
})
|