feat(auth): self-service password reset (backend + web) #75
@@ -56,6 +56,8 @@ import Appeals from './routes/admin/views/Appeals.jsx'
|
|||||||
// Player portal
|
// Player portal
|
||||||
import PlayerLogin from './routes/player/PlayerLogin.jsx'
|
import PlayerLogin from './routes/player/PlayerLogin.jsx'
|
||||||
import PlayerRegister from './routes/player/PlayerRegister.jsx'
|
import PlayerRegister from './routes/player/PlayerRegister.jsx'
|
||||||
|
import ForgotPassword from './routes/player/ForgotPassword.jsx'
|
||||||
|
import ResetPassword from './routes/player/ResetPassword.jsx'
|
||||||
import AcceptInvite from './routes/player/AcceptInvite.jsx'
|
import AcceptInvite from './routes/player/AcceptInvite.jsx'
|
||||||
import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
|
import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
|
||||||
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
|
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
|
||||||
@@ -169,6 +171,8 @@ export default function App() {
|
|||||||
{/* Player portal */}
|
{/* Player portal */}
|
||||||
<Route path="/account/login" element={<PlayerLogin />} />
|
<Route path="/account/login" element={<PlayerLogin />} />
|
||||||
<Route path="/account/register" element={<PlayerRegister />} />
|
<Route path="/account/register" element={<PlayerRegister />} />
|
||||||
|
<Route path="/account/forgot" element={<ForgotPassword />} />
|
||||||
|
<Route path="/account/reset/:token" element={<ResetPassword />} />
|
||||||
<Route path="/invite/:token" element={<AcceptInvite />} />
|
<Route path="/invite/:token" element={<AcceptInvite />} />
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ export const api = {
|
|||||||
req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }),
|
req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }),
|
||||||
loginTotp: (challenge, code) =>
|
loginTotp: (challenge, code) =>
|
||||||
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
|
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
|
||||||
|
// Self-service password reset (public, token-gated). forgot always resolves the
|
||||||
|
// same way whether or not the email exists (no enumeration); getPasswordReset
|
||||||
|
// validates a link (200 → { username }, 404 → invalid/expired); resetPassword
|
||||||
|
// sets the new password and revokes all sessions (the user then signs in fresh).
|
||||||
|
forgotPassword: (email) => req('/auth/password/forgot', { method: 'POST', body: { email } }),
|
||||||
|
getPasswordReset: (token) => req(`/auth/password/reset/${encodeURIComponent(token)}`),
|
||||||
|
resetPassword: (token, password) =>
|
||||||
|
req(`/auth/password/reset/${encodeURIComponent(token)}`, { method: 'POST', body: { password } }),
|
||||||
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
|
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
|
||||||
// the callback, so only the code is sent). Returns { user, returnTo }.
|
// the callback, so only the code is sent). Returns { user, returnTo }.
|
||||||
ssoLoginTotp: (code) => req('/auth/sso/totp', { method: 'POST', body: { code } }),
|
ssoLoginTotp: (code) => req('/auth/sso/totp', { method: 'POST', body: { code } }),
|
||||||
|
|||||||
74
client/src/routes/player/ForgotPassword.jsx
Normal file
74
client/src/routes/player/ForgotPassword.jsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
import PlayerShell from './PlayerShell.jsx'
|
||||||
|
|
||||||
|
// Public "forgot password" request page. Submitting emails a tokened reset link to
|
||||||
|
// every active account on the address (see ResetPassword for the other half). The
|
||||||
|
// server never reveals whether the email exists — it always answers the same way —
|
||||||
|
// so this page shows an identical confirmation regardless, to avoid enumeration.
|
||||||
|
export default function ForgotPassword() {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [sent, setSent] = useState(false)
|
||||||
|
|
||||||
|
async function onSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
if (!/.+@.+\..+/.test(email.trim())) return setError('Enter a valid email address.')
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.forgotPassword(email.trim())
|
||||||
|
setSent(true)
|
||||||
|
} catch (err) {
|
||||||
|
// Only a rate-limit (429) or a real outage surfaces here — a non-match still
|
||||||
|
// returns 200. Keep the message generic either way.
|
||||||
|
if (err.status === 429) setError('Too many requests. Please try again in a little while.')
|
||||||
|
else setError('Could not send the reset email right now. Please try again later.')
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
return (
|
||||||
|
<PlayerShell subtitle="Reset your password">
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', lineHeight: 1.6, textAlign: 'center' }}>
|
||||||
|
If an account exists for <strong style={{ color: 'var(--head)' }}>{email.trim()}</strong>, we’ve sent a link to
|
||||||
|
reset its password. Check your inbox (and spam) — the link expires in about an hour.
|
||||||
|
</p>
|
||||||
|
<p className="sans" style={{ textAlign: 'center', margin: '18px 0 0' }}>
|
||||||
|
<Link to="/account/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>Back to sign in</Link>
|
||||||
|
</p>
|
||||||
|
</PlayerShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlayerShell
|
||||||
|
subtitle="Reset your password"
|
||||||
|
footer={
|
||||||
|
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', color: 'var(--dim)', fontSize: '0.84rem' }}>
|
||||||
|
Remembered it?{' '}
|
||||||
|
<Link to="/account/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>Sign in</Link>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="sans" style={{ marginTop: 0, marginBottom: 18, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||||
|
Enter the email on your account and we’ll send you a link to choose a new password.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||||
|
<span className="field-label">Email</span>
|
||||||
|
<input type="email" autoComplete="email" autoFocus value={email} onChange={(e) => setEmail(e.target.value)} className="input" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>{error}</p>}
|
||||||
|
|
||||||
|
<button type="submit" disabled={busy} className="btn btn-primary" style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}>
|
||||||
|
{busy ? 'Sending…' : 'Send reset link'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</PlayerShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -122,14 +122,21 @@ export default function PlayerLogin() {
|
|||||||
<PlayerShell
|
<PlayerShell
|
||||||
subtitle="Player sign-in"
|
subtitle="Player sign-in"
|
||||||
footer={
|
footer={
|
||||||
canRegister && (
|
<div style={{ margin: '16px 0 0', textAlign: 'center' }}>
|
||||||
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', color: 'var(--dim)', fontSize: '0.84rem' }}>
|
<p className="sans" style={{ margin: 0, color: 'var(--dim)', fontSize: '0.84rem' }}>
|
||||||
New here?{' '}
|
<Link to="/account/forgot" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||||
<Link to="/account/register" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
Forgot your password?
|
||||||
Create an account
|
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
)
|
{canRegister && (
|
||||||
|
<p className="sans" style={{ margin: '8px 0 0', color: 'var(--dim)', fontSize: '0.84rem' }}>
|
||||||
|
New here?{' '}
|
||||||
|
<Link to="/account/register" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||||
|
Create an account
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form onSubmit={stage === 'totp' ? onSubmitTotp : onSubmit}>
|
<form onSubmit={stage === 'totp' ? onSubmitTotp : onSubmit}>
|
||||||
|
|||||||
115
client/src/routes/player/ResetPassword.jsx
Normal file
115
client/src/routes/player/ResetPassword.jsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
import PlayerShell from './PlayerShell.jsx'
|
||||||
|
|
||||||
|
// Public, token-gated reset page (/account/reset/:token). Validates the link, lets
|
||||||
|
// the user choose a new password, then sends them to sign in fresh. Setting the
|
||||||
|
// password revokes every existing session (web + mobile) server-side and does NOT
|
||||||
|
// log them in here — so a 2FA account still passes TOTP on the next sign-in.
|
||||||
|
export default function ResetPassword() {
|
||||||
|
const { token } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [username, setUsername] = useState(null) // whose account this link is for
|
||||||
|
const [loadErr, setLoadErr] = useState('')
|
||||||
|
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [done, setDone] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
api.getPasswordReset(token)
|
||||||
|
.then((r) => active && setUsername(r?.username || ''))
|
||||||
|
.catch((err) => active && setLoadErr(
|
||||||
|
err.status === 404 ? 'This reset link is invalid or has expired.' : 'Could not load this reset link.',
|
||||||
|
))
|
||||||
|
return () => { active = false }
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
async function onSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
if (password.length < 8) return setError('Password must be at least 8 characters.')
|
||||||
|
if (password !== confirm) return setError('The passwords do not match.')
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.resetPassword(token, password)
|
||||||
|
setDone(true)
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 404) setError('This reset link is invalid or has already been used.')
|
||||||
|
else if (err.status === 429) setError('Too many attempts. Please try again in a little while.')
|
||||||
|
else if (err.status === 400) setError(err.message || 'Please check your password and try again.')
|
||||||
|
else setError('Could not reset your password right now. Please try again later.')
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Invalid link ───────────────────────────────────────────────────────────
|
||||||
|
if (loadErr) {
|
||||||
|
return (
|
||||||
|
<PlayerShell subtitle="Reset your password">
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>{loadErr}</p>
|
||||||
|
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0' }}>
|
||||||
|
<Link to="/account/forgot" style={{ color: 'var(--accent)', textDecoration: 'none' }}>Request a new link</Link>
|
||||||
|
</p>
|
||||||
|
</PlayerShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (username === null) {
|
||||||
|
return (
|
||||||
|
<PlayerShell subtitle="Reset your password">
|
||||||
|
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}><span className="spin" /></div>
|
||||||
|
</PlayerShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Done ───────────────────────────────────────────────────────────────────
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<PlayerShell subtitle="Password updated">
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>
|
||||||
|
Your password has been reset. For your security, every existing session has been signed out.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/account/login', { replace: true })}
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center', marginTop: 20 }}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</PlayerShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reset form ─────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<PlayerShell subtitle="Reset your password">
|
||||||
|
<p className="sans" style={{ marginTop: 0, marginBottom: 18, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||||
|
Choose a new password{username ? <> for <strong style={{ color: 'var(--head)' }}>{username}</strong></> : null}.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
{/* A hidden username field helps password managers associate the credential. */}
|
||||||
|
{username ? <input type="text" name="username" autoComplete="username" value={username} readOnly hidden /> : null}
|
||||||
|
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||||
|
<span className="field-label">New password</span>
|
||||||
|
<input type="password" autoComplete="new-password" autoFocus value={password} onChange={(e) => setPassword(e.target.value)} className="input" />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||||
|
<span className="field-label">Confirm new password</span>
|
||||||
|
<input type="password" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} className="input" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>{error}</p>}
|
||||||
|
|
||||||
|
<button type="submit" disabled={busy} className="btn btn-primary" style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}>
|
||||||
|
{busy ? 'Saving…' : 'Set new password'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</PlayerShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -536,6 +536,27 @@ CREATE TABLE IF NOT EXISTS user_invites (
|
|||||||
INDEX idx_user_invites_status (status, expires_at)
|
INDEX idx_user_invites_status (status, expires_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Self-service password resets. A user requests a reset by email; a tokened link
|
||||||
|
-- is emailed to every active account on that address. Opening the link and setting
|
||||||
|
-- a new password rotates the hash and revokes all sessions (web + mobile). Only the
|
||||||
|
-- sha256 hash of the opaque token is stored — a DB read never yields a usable link,
|
||||||
|
-- same as user_invites / mobile_refresh_tokens. Single-use + short-lived (1h,
|
||||||
|
-- enforced in the model on top of expires_at). Also serves SSO-only accounts (null
|
||||||
|
-- password_hash) as their "set an initial password" path.
|
||||||
|
CREATE TABLE IF NOT EXISTS password_resets (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque token
|
||||||
|
user_id INT NOT NULL, -- the account this reset targets
|
||||||
|
status ENUM('pending','used') NOT NULL DEFAULT 'pending',
|
||||||
|
requested_ip VARCHAR(64) NULL, -- who asked (audit only)
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
used_at DATETIME NULL,
|
||||||
|
CONSTRAINT fk_password_resets_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_password_resets_user (user_id),
|
||||||
|
INDEX idx_password_resets_status (status, expires_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Discord bot moderation core (Phase 2). These tables are owned by the bot
|
-- Discord bot moderation core (Phase 2). These tables are owned by the bot
|
||||||
-- process (its own DB pool, bot/src/db.js) — the main server never reads or
|
-- process (its own DB pool, bot/src/db.js) — the main server never reads or
|
||||||
-- writes them. They live in the same physical database as everything else
|
-- writes them. They live in the same physical database as everything else
|
||||||
|
|||||||
@@ -71,6 +71,25 @@ const ssoStartLimiter = makeLimiter({
|
|||||||
message: 'Too many sign-in attempts. Please try again later.',
|
message: 'Too many sign-in attempts. Please try again later.',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Password-reset requests per IP. Each one can send email, so cap tighter than
|
||||||
|
// login to blunt email-bombing and enumeration timing probes. The endpoint always
|
||||||
|
// returns a generic success regardless of match, so honest users never see this.
|
||||||
|
const passwordResetRequestLimiter = makeLimiter({
|
||||||
|
windowMs: 60 * 60 * 1000,
|
||||||
|
max: 5,
|
||||||
|
label: 'password-reset-request',
|
||||||
|
message: 'Too many reset requests. Please try again later.',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset confirmations (token + new password) per IP. A wrong/expired token is a
|
||||||
|
// guessing surface; the token itself is 256-bit random, but cap anyway.
|
||||||
|
const passwordResetConfirmLimiter = makeLimiter({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 15,
|
||||||
|
label: 'password-reset-confirm',
|
||||||
|
message: 'Too many attempts. Please try again later.',
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
loginLimiter,
|
loginLimiter,
|
||||||
registerLimiter,
|
registerLimiter,
|
||||||
@@ -78,4 +97,6 @@ module.exports = {
|
|||||||
contactLimiter,
|
contactLimiter,
|
||||||
mobileRefreshLimiter,
|
mobileRefreshLimiter,
|
||||||
ssoStartLimiter,
|
ssoStartLimiter,
|
||||||
|
passwordResetRequestLimiter,
|
||||||
|
passwordResetConfirmLimiter,
|
||||||
}
|
}
|
||||||
|
|||||||
46
server/src/model/passwordResets/passwordResets.db.js
Normal file
46
server/src/model/passwordResets/passwordResets.db.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
|
const COLS = 'id, token_hash, user_id, status, requested_ip, expires_at, created_at, used_at'
|
||||||
|
|
||||||
|
async function insert({ tokenHash, userId, requestedIp, expiresAt }) {
|
||||||
|
const res = await query(
|
||||||
|
`INSERT INTO password_resets (token_hash, user_id, requested_ip, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
[tokenHash, userId, requestedIp ?? null, expiresAt],
|
||||||
|
)
|
||||||
|
return res.insertId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getById(id) {
|
||||||
|
const rows = await query(`SELECT ${COLS} FROM password_resets WHERE id = ? LIMIT 1`, [id])
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findByTokenHash(tokenHash) {
|
||||||
|
const rows = await query(`SELECT ${COLS} FROM password_resets WHERE token_hash = ? LIMIT 1`, [tokenHash])
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark used only if still pending (atomic guard against a double-use race).
|
||||||
|
// Returns rows changed (1 = we won, 0 = already used).
|
||||||
|
async function markUsed(id) {
|
||||||
|
const res = await query(
|
||||||
|
`UPDATE password_resets SET status = 'used', used_at = NOW()
|
||||||
|
WHERE id = ? AND status = 'pending'`,
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
return res.affectedRows || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate any still-pending resets for a user (e.g. after a successful reset,
|
||||||
|
// or when a fresh request supersedes older links). Idempotent.
|
||||||
|
async function invalidatePendingForUser(userId) {
|
||||||
|
const res = await query(
|
||||||
|
`UPDATE password_resets SET status = 'used', used_at = NOW()
|
||||||
|
WHERE user_id = ? AND status = 'pending'`,
|
||||||
|
[userId],
|
||||||
|
)
|
||||||
|
return res.affectedRows || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { insert, getById, findByTokenHash, markUsed, invalidatePendingForUser }
|
||||||
47
server/src/model/passwordResets/passwordResets.model.js
Normal file
47
server/src/model/passwordResets/passwordResets.model.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Self-service password resets. A user asks for a reset by email; a tokened link
|
||||||
|
// is emailed to every active account on that address. Opening the link and choosing
|
||||||
|
// a new password rotates the hash and revokes every session. The opaque token lives
|
||||||
|
// only in the emailed link — the DB stores just its sha256 hash (like user_invites
|
||||||
|
// and mobile refresh tokens), so a DB read never yields a usable reset link. Tokens
|
||||||
|
// are single-use and short-lived.
|
||||||
|
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const db = require('./passwordResets.db')
|
||||||
|
|
||||||
|
// Short by design: a recovery link is a live credential-reset capability, so it
|
||||||
|
// should not linger the way a 7-day invite does.
|
||||||
|
const DEFAULT_TTL_MINUTES = 60
|
||||||
|
|
||||||
|
function hashToken(raw) {
|
||||||
|
return crypto.createHash('sha256').update(String(raw)).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a reset for a specific user. Returns { id, token } — the plaintext token
|
||||||
|
// is returned ONCE (for the email link) and never stored or recoverable afterwards.
|
||||||
|
async function create({ userId, requestedIp, ttlMinutes = DEFAULT_TTL_MINUTES }) {
|
||||||
|
const token = crypto.randomBytes(32).toString('base64url')
|
||||||
|
const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000)
|
||||||
|
const id = await db.insert({ tokenHash: hashToken(token), userId, requestedIp, expiresAt })
|
||||||
|
return { id, token }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a pending, unexpired reset from its plaintext token, else null. Returns
|
||||||
|
// the RAW row (incl. user_id) for the confirm flow.
|
||||||
|
async function findValidByToken(token) {
|
||||||
|
if (!token) return null
|
||||||
|
const row = await db.findByTokenHash(hashToken(token))
|
||||||
|
if (!row || row.status !== 'pending') return null
|
||||||
|
if (new Date(row.expires_at).getTime() < Date.now()) return null
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically consume a pending reset (double-use-safe). Returns true if this call
|
||||||
|
// won the race and marked the token used.
|
||||||
|
async function consume(id) {
|
||||||
|
return (await db.markUsed(id)) === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retire any other pending links for this user after a successful reset.
|
||||||
|
const invalidatePendingForUser = (userId) => db.invalidatePendingForUser(userId)
|
||||||
|
|
||||||
|
module.exports = { create, findValidByToken, consume, invalidatePendingForUser, hashToken, DEFAULT_TTL_MINUTES }
|
||||||
@@ -31,6 +31,17 @@ async function findById(id) {
|
|||||||
return rows[0] || null
|
return rows[0] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// All ACTIVE accounts on an email address. Email is intentionally non-unique
|
||||||
|
// (SSO emails may repeat), so a reset request can legitimately match several
|
||||||
|
// accounts; the caller issues one reset link per row. Case-insensitive to match
|
||||||
|
// however the address was stored. Excludes disabled/banned accounts.
|
||||||
|
async function findActiveByEmail(email) {
|
||||||
|
return query(
|
||||||
|
"SELECT * FROM users WHERE email = ? AND status = 'active'",
|
||||||
|
[email],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function listUsers() {
|
async function listUsers() {
|
||||||
return query(`SELECT ${PUBLIC_COLS} FROM users ORDER BY id ASC`)
|
return query(`SELECT ${PUBLIC_COLS} FROM users ORDER BY id ASC`)
|
||||||
}
|
}
|
||||||
@@ -100,6 +111,7 @@ module.exports = {
|
|||||||
insertUser,
|
insertUser,
|
||||||
findByUsername,
|
findByUsername,
|
||||||
findById,
|
findById,
|
||||||
|
findActiveByEmail,
|
||||||
listUsers,
|
listUsers,
|
||||||
updateUser,
|
updateUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ async function getById(id) {
|
|||||||
return sanitize(await usersDb.findById(id))
|
return sanitize(await usersDb.findById(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Raw rows (incl. email/status) for every active account on an email address.
|
||||||
|
// Server-side only (password-reset request); email is non-unique so this may
|
||||||
|
// return several. Never sent to a client.
|
||||||
|
async function getActiveByEmail(email) {
|
||||||
|
if (!email) return []
|
||||||
|
return usersDb.findActiveByEmail(String(email).trim())
|
||||||
|
}
|
||||||
|
|
||||||
// Raw row incl. totp_secret — server-side only (TOTP setup/verify). Never sent
|
// Raw row incl. totp_secret — server-side only (TOTP setup/verify). Never sent
|
||||||
// to a client; sanitize() strips the secret from anything user-facing.
|
// to a client; sanitize() strips the secret from anything user-facing.
|
||||||
async function getRawById(id) {
|
async function getRawById(id) {
|
||||||
@@ -109,6 +117,7 @@ module.exports = {
|
|||||||
isDuplicateUsername,
|
isDuplicateUsername,
|
||||||
getRawByUsername,
|
getRawByUsername,
|
||||||
getById,
|
getById,
|
||||||
|
getActiveByEmail,
|
||||||
getRawById,
|
getRawById,
|
||||||
validatePassword,
|
validatePassword,
|
||||||
list,
|
list,
|
||||||
|
|||||||
@@ -3,9 +3,15 @@ const { body, param } = require('express-validator')
|
|||||||
|
|
||||||
const { login, register, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller')
|
const { login, register, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller')
|
||||||
const { getInvite, acceptInvite } = require('./invite.controller')
|
const { getInvite, acceptInvite } = require('./invite.controller')
|
||||||
|
const { requestReset, lookupReset, confirmReset } = require('./passwordReset.controller')
|
||||||
const { isLoggedIn } = require('../../../utils/auth')
|
const { isLoggedIn } = require('../../../utils/auth')
|
||||||
const { attachSession } = require('../../../auth/session.middleware')
|
const { attachSession } = require('../../../auth/session.middleware')
|
||||||
const { loginLimiter, registerLimiter } = require('../../../middleware/rateLimit')
|
const {
|
||||||
|
loginLimiter,
|
||||||
|
registerLimiter,
|
||||||
|
passwordResetRequestLimiter,
|
||||||
|
passwordResetConfirmLimiter,
|
||||||
|
} = require('../../../middleware/rateLimit')
|
||||||
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
|
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
|
||||||
const validate = require('../../../middleware/validate')
|
const validate = require('../../../middleware/validate')
|
||||||
const mobileRouter = require('./mobile.routes')
|
const mobileRouter = require('./mobile.routes')
|
||||||
@@ -120,6 +126,51 @@ authRouter.post(
|
|||||||
acceptInvite,
|
acceptInvite,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Self-service password reset (public, token-gated) ──────────────────────
|
||||||
|
// Request → email a tokened link; then validate the link and set a new password.
|
||||||
|
// The request step never reveals whether an email exists (always 200, generic).
|
||||||
|
authRouter.post(
|
||||||
|
'/password/forgot',
|
||||||
|
// #swagger.tags = ['Auth']
|
||||||
|
// #swagger.summary = 'Request a password-reset link by email'
|
||||||
|
// #swagger.description = 'Emails a single-use, ~1h reset link to every active account on the address. Always returns the same generic 200 whether or not the email matches (no account enumeration). Email is non-unique, so multiple accounts may each receive a link naming their username. Rate limited per IP.'
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email"], properties: { email: { type: "string", format: "email" } } } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Generic acknowledgement (sent if the account exists)', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||||
|
/* #swagger.responses[429] = { description: 'Too many requests', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
passwordResetRequestLimiter,
|
||||||
|
body('email').isString().trim().isEmail().isLength({ max: 255 }),
|
||||||
|
validate,
|
||||||
|
requestReset,
|
||||||
|
)
|
||||||
|
authRouter.get(
|
||||||
|
'/password/reset/:token',
|
||||||
|
// #swagger.tags = ['Auth']
|
||||||
|
// #swagger.summary = 'Validate a password-reset link'
|
||||||
|
// #swagger.description = 'Returns the target username for a valid, pending, unexpired reset link so the reset form can render. 404 for anything not currently usable (never distinguishes expired from used from never-existed).'
|
||||||
|
/* #swagger.responses[200] = { description: 'Reset link is valid', content: { "application/json": { schema: { type: "object", properties: { username: { type: "string" } } } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Invalid or expired reset link', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('token').isString().isLength({ min: 8, max: 128 }),
|
||||||
|
validate,
|
||||||
|
lookupReset,
|
||||||
|
)
|
||||||
|
authRouter.post(
|
||||||
|
'/password/reset/:token',
|
||||||
|
// #swagger.tags = ['Auth']
|
||||||
|
// #swagger.summary = 'Set a new password from a reset link'
|
||||||
|
// #swagger.description = 'Consumes the single-use link and sets the new password. Rotates the hash and revokes every existing session (web + mobile). Does NOT sign the user in — they log in fresh afterwards (so a 2FA account still passes TOTP). Rate limited per IP.'
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["password"], properties: { password: { type: "string", minLength: 8, maxLength: 64 } } } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Password changed', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Invalid, expired, or already-used reset link', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[429] = { description: 'Too many attempts', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
passwordResetConfirmLimiter,
|
||||||
|
param('token').isString().isLength({ min: 8, max: 128 }),
|
||||||
|
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||||
|
validate,
|
||||||
|
confirmReset,
|
||||||
|
)
|
||||||
|
|
||||||
authRouter.post(
|
authRouter.post(
|
||||||
'/logout',
|
'/logout',
|
||||||
// #swagger.tags = ['Auth']
|
// #swagger.tags = ['Auth']
|
||||||
|
|||||||
119
server/src/router/v1/auth/passwordReset.controller.js
Normal file
119
server/src/router/v1/auth/passwordReset.controller.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
// ── Self-service password reset (public, token-gated) ──────────────────────
|
||||||
|
//
|
||||||
|
// Three steps, all unauthenticated:
|
||||||
|
// 1. POST /auth/password/forgot { email } → email a tokened link
|
||||||
|
// 2. GET /auth/password/reset/:token → validate the link (for the form)
|
||||||
|
// 3. POST /auth/password/reset/:token { password } → set the new password
|
||||||
|
//
|
||||||
|
// Email is intentionally non-unique (SSO emails may repeat), so a request can
|
||||||
|
// match several accounts; each gets its own link, and the email names the
|
||||||
|
// username so the recipient knows which account it's for. The request step NEVER
|
||||||
|
// reveals whether an address exists — it always returns the same generic success
|
||||||
|
// (no user enumeration). Only the sha256 hash of each opaque token is stored, so a
|
||||||
|
// DB read never yields a usable link (same pattern as user_invites). Tokens are
|
||||||
|
// single-use + expire in ~1h. Setting a new password rotates the hash and revokes
|
||||||
|
// every session (web cookie cutoff + mobile refresh tokens). We do NOT auto-log-in
|
||||||
|
// afterwards: the user signs in fresh, so a 2FA account still passes TOTP.
|
||||||
|
|
||||||
|
const passwordResets = require('../../../model/passwordResets/passwordResets.model')
|
||||||
|
const users = require('../../../model/users/users.model')
|
||||||
|
const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
|
||||||
|
const activity = require('../../../model/activity/activity.model')
|
||||||
|
const mailer = require('../../../utils/mailer')
|
||||||
|
|
||||||
|
const log = require('../../../utils/logger')('auth-password-reset')
|
||||||
|
|
||||||
|
// Same generic answer whether or not the address matched — never leaks existence.
|
||||||
|
const GENERIC_OK = { message: 'If an account exists for that email, a reset link has been sent.' }
|
||||||
|
|
||||||
|
function baseUrl() {
|
||||||
|
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetUrl(token) {
|
||||||
|
return `${baseUrl()}/account/reset/${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /auth/password/forgot — request a reset. Always 200 with GENERIC_OK.
|
||||||
|
async function requestReset(req, res) {
|
||||||
|
const email = String(req.body.email || '').trim()
|
||||||
|
try {
|
||||||
|
// Bad/empty email: answer identically so probing the shape reveals nothing.
|
||||||
|
if (email) {
|
||||||
|
const accounts = await users.getActiveByEmail(email)
|
||||||
|
for (const account of accounts) {
|
||||||
|
try {
|
||||||
|
const { token } = await passwordResets.create({ userId: account.id, requestedIp: req.ip })
|
||||||
|
const result = await mailer.sendPasswordReset({
|
||||||
|
to: account.email,
|
||||||
|
resetUrl: resetUrl(token),
|
||||||
|
username: account.username,
|
||||||
|
})
|
||||||
|
if (!result.sent) {
|
||||||
|
log.warn('password reset email not sent (mail not configured)', { userId: account.id })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// A send failure for one account must not abort the others, nor change
|
||||||
|
// the generic response. The pending token simply expires unused.
|
||||||
|
log.error('password reset send error', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await activity.log({ req, action: 'account.password.reset.request', detail: { email, matched: accounts.length } })
|
||||||
|
log.info('password reset requested', { email, matched: accounts.length, ip: req.ip })
|
||||||
|
}
|
||||||
|
return res.json(GENERIC_OK)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('requestReset', err)
|
||||||
|
// Still generic — don't turn an internal error into an enumeration oracle.
|
||||||
|
return res.json(GENERIC_OK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /auth/password/reset/:token — validate a link so the form can render. 404
|
||||||
|
// for anything not currently usable (never distinguishes expired/used/never-was).
|
||||||
|
async function lookupReset(req, res) {
|
||||||
|
try {
|
||||||
|
const row = await passwordResets.findValidByToken(req.params.token)
|
||||||
|
if (!row) return res.status(404).json({ message: 'This reset link is invalid or has expired.' })
|
||||||
|
// Surface only the target username (nice for the form); never the email/token.
|
||||||
|
const user = await users.getById(row.user_id)
|
||||||
|
if (!user) return res.status(404).json({ message: 'This reset link is invalid or has expired.' })
|
||||||
|
return res.json({ username: user.username })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('lookupReset', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /auth/password/reset/:token — set the new password. Consumes the token
|
||||||
|
// atomically (double-use safe), rotates the hash, and revokes every session.
|
||||||
|
async function confirmReset(req, res) {
|
||||||
|
try {
|
||||||
|
const row = await passwordResets.findValidByToken(req.params.token)
|
||||||
|
if (!row) return res.status(404).json({ message: 'This reset link is invalid or has expired.' })
|
||||||
|
|
||||||
|
// Consume first: if we lost a double-submit race, stop before touching the
|
||||||
|
// password so a spent link can't set a password twice.
|
||||||
|
const won = await passwordResets.consume(row.id)
|
||||||
|
if (!won) return res.status(404).json({ message: 'This reset link has already been used.' })
|
||||||
|
|
||||||
|
// Rotate the hash. users.update bumps tokens_valid_after, revoking every web
|
||||||
|
// session issued before now ("reset password → sign out everywhere").
|
||||||
|
await users.update(row.user_id, { password: req.body.password })
|
||||||
|
// Web sessions are covered by the cutoff bump; mobile bearer sessions live in
|
||||||
|
// their own table and must be revoked explicitly.
|
||||||
|
await mobileSessions.revokeAllForUser(row.user_id)
|
||||||
|
// Retire any other outstanding links for this user (e.g. duplicate requests).
|
||||||
|
await passwordResets.invalidatePendingForUser(row.user_id)
|
||||||
|
|
||||||
|
await activity.log({ req, userId: row.user_id, action: 'account.password.reset.complete' })
|
||||||
|
log.info('password reset completed', { userId: row.user_id, ip: req.ip })
|
||||||
|
// No auto-login: the user signs in fresh, so a 2FA account still passes TOTP.
|
||||||
|
return res.json({ ok: true, message: 'Your password has been reset. You can sign in now.' })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('confirmReset', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { requestReset, lookupReset, confirmReset }
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -450,6 +450,200 @@
|
|||||||
"requestBody": {}
|
"requestBody": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/auth/password/forgot": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Auth"
|
||||||
|
],
|
||||||
|
"summary": "Request a password-reset link by email",
|
||||||
|
"description": "Emails a single-use, ~1h reset link to every active account on the address. Always returns the same generic 200 whether or not the email matches (no account enumeration). Email is non-unique, so multiple accounts may each receive a link naming their username. Rate limited per IP.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Generic acknowledgement (sent if the account exists)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Message"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Validation error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Too many requests",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"email": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "email"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/auth/password/reset/{token}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Auth"
|
||||||
|
],
|
||||||
|
"summary": "Validate a password-reset link",
|
||||||
|
"description": "Returns the target username for a valid, pending, unexpired reset link so the reset form can render. 404 for anything not currently usable (never distinguishes expired from used from never-existed).",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "token",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Reset link is valid",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"username": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Invalid or expired reset link",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Auth"
|
||||||
|
],
|
||||||
|
"summary": "Set a new password from a reset link",
|
||||||
|
"description": "Consumes the single-use link and sets the new password. Rotates the hash and revokes every existing session (web + mobile). Does NOT sign the user in — they log in fresh afterwards (so a 2FA account still passes TOTP). Rate limited per IP.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "token",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Password changed",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Message"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Validation error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Invalid, expired, or already-used reset link",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Too many attempts",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"password"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"password": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 8,
|
||||||
|
"maxLength": 64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/auth/logout": {
|
"/api/v1/auth/logout": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
82
server/test/passwordResets.test.js
Normal file
82
server/test/passwordResets.test.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user