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:
@@ -55,6 +55,8 @@ import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
||||
// Player portal
|
||||
import PlayerLogin from './routes/player/PlayerLogin.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 PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
|
||||
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
|
||||
@@ -166,6 +168,8 @@ export default function App() {
|
||||
{/* Player portal */}
|
||||
<Route path="/account/login" element={<PlayerLogin />} />
|
||||
<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
|
||||
element={
|
||||
|
||||
@@ -54,6 +54,14 @@ export const api = {
|
||||
req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }),
|
||||
loginTotp: (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
|
||||
// the callback, so only the code is sent). Returns { user, returnTo }.
|
||||
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
|
||||
subtitle="Player sign-in"
|
||||
footer={
|
||||
canRegister && (
|
||||
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', color: 'var(--dim)', fontSize: '0.84rem' }}>
|
||||
New here?{' '}
|
||||
<Link to="/account/register" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Create an account
|
||||
<div style={{ margin: '16px 0 0', textAlign: 'center' }}>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--dim)', fontSize: '0.84rem' }}>
|
||||
<Link to="/account/forgot" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</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}>
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user