feat(auth): self-service password reset (backend + web)
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
This commit is contained in:
@@ -155,4 +155,36 @@ async function sendInvite({ to, acceptUrl, role, invitedByName }) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { isConfigured, sendContactMessage, sendTest, sendInvite }
|
||||
/**
|
||||
* Send a password-reset link. `to` is the account's email, `resetUrl` the tokened
|
||||
* reset link, `username` names which account it's for (email is non-unique, so one
|
||||
* address may receive a link per account). If email is not configured, returns
|
||||
* { sent: false, reason: 'NOT_CONFIGURED' } — the caller still returns a generic
|
||||
* success to avoid leaking whether the address exists. Throws only on a send failure.
|
||||
*/
|
||||
async function sendPasswordReset({ to, resetUrl, username }) {
|
||||
const built = await buildTransport()
|
||||
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
|
||||
const { transport, config } = built
|
||||
const forWhom = username ? ` for the account “${username}”` : ''
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: fromHeader(config),
|
||||
to,
|
||||
subject: `Reset your ${brand.name} password`,
|
||||
text:
|
||||
`We received a request to reset the password${forWhom} at ${brand.name}.\n\n` +
|
||||
`Choose a new password here:\n${resetUrl}\n\n` +
|
||||
`This link is single-use and expires in about an hour. If you didn't request ` +
|
||||
`this, you can safely ignore this email — your password won't change.`,
|
||||
})
|
||||
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Password reset send OK', lastVerifiedAt: new Date() })
|
||||
return { sent: true }
|
||||
} catch (err) {
|
||||
log.error('password reset send failed', err)
|
||||
await emailConfig.recordStatus({ status: 'error', statusDetail: err.message })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { isConfigured, sendContactMessage, sendTest, sendInvite, sendPasswordReset }
|
||||
|
||||
Reference in New Issue
Block a user