feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)
Makes `users.email` unique, de-duplicates the addresses an upgrade will find, and builds the self-service change-and-verify flow that did not exist. The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED` column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan specified. Every case-insensitive collation this server offers is also accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are two different mailboxes. The plan's index would have refused the second address forever and the de-duplication would have nulled a legitimate account's. A requested address is STAGED in `email_pending` and only a tokened link installs it, so a typo cannot silently redirect account-recovery mail. `isDuplicateUsername()` now distinguishes the two indexes. All five call sites branch on it; each answers differently on purpose, because a public form, an IdP callback, a half-completed invite and an admin screen do not owe the same person the same amount of truth. SSO reads the IdP's actual `email_verified`/`verified` claim instead of inferring verification from an address merely being present. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model'
|
||||
const registries = require('../../../modules/registries')
|
||||
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
||||
const emailConfig = require('../../../model/emailConfig/emailConfig.model')
|
||||
const emailDedupe = require('../../../model/emailDedupe/emailDedupe.model')
|
||||
const forumSettings = require('../../../model/teams/teamForumSettings.model')
|
||||
const pushDispatch = require('../../../utils/pushDispatch')
|
||||
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
||||
@@ -89,6 +90,60 @@ async function emailWarning() {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1b — the de-duplication cleared some accounts' addresses so a UNIQUE
|
||||
// index could go on (ENGAGEMENT.md Phase 1b / §0.6 finding 1). Same posture as
|
||||
// the email warning above: narrow, self-clearing, and silent on the installs it
|
||||
// does not concern.
|
||||
//
|
||||
// It has to be said out loud for the same reason G22 did. Nothing broke visibly —
|
||||
// those users can still log in — but they can no longer receive password-reset or
|
||||
// engagement mail, and they are the only people who can fix that, so somebody has
|
||||
// to tell the operator to go and ask them.
|
||||
//
|
||||
// Never fails the dashboard.
|
||||
async function emailDedupeWarning() {
|
||||
try {
|
||||
const n = await emailDedupe.countUnacknowledged()
|
||||
if (!n) return null
|
||||
return {
|
||||
code: 'EMAIL_DEDUPE',
|
||||
message:
|
||||
`${n} account${n === 1 ? '' : 's'} shared an email address with another account and had it ` +
|
||||
'cleared when addresses became unique. They can still sign in, but cannot receive password-reset ' +
|
||||
'or notification email until they set a new address. Review who was affected and contact them.',
|
||||
href: '/admin/users',
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('dashboard email dedupe warning check failed', { message: err.message })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/users/email-dedupe-report — who was cleared, and what they lost.
|
||||
async function emailDedupeReport(req, res) {
|
||||
try {
|
||||
return res.json(await emailDedupe.list())
|
||||
} catch (err) {
|
||||
log.error('emailDedupeReport', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/users/email-dedupe-report/acknowledge — dismiss the warning. The
|
||||
// rows stay: the report is a record of what the upgrade did, and losing it would
|
||||
// leave no way to answer "why does this user have no address?" later.
|
||||
async function acknowledgeEmailDedupeReport(req, res) {
|
||||
try {
|
||||
const n = await emailDedupe.acknowledgeAll()
|
||||
await activity.log({ req, action: 'admin.email_dedupe.acknowledge', detail: { count: n } })
|
||||
log.info('email dedupe report acknowledged', { count: n, by: req.user.username })
|
||||
return res.json({ ok: true, acknowledged: n })
|
||||
} catch (err) {
|
||||
log.error('acknowledgeEmailDedupeReport', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function dashboard(req, res) {
|
||||
try {
|
||||
return res.json({
|
||||
@@ -101,7 +156,7 @@ async function dashboard(req, res) {
|
||||
posts: await posts.counts(),
|
||||
users: await users.count(),
|
||||
},
|
||||
warnings: [await emailWarning()].filter(Boolean),
|
||||
warnings: [await emailWarning(), await emailDedupeWarning()].filter(Boolean),
|
||||
recent_activity: await activity.list({ limit: 10 }),
|
||||
})
|
||||
} catch (err) {
|
||||
@@ -562,6 +617,14 @@ async function updateSettings(req, res) {
|
||||
// endpoint takes arbitrary keys either way, and an unrecognised value resolves
|
||||
// to `disabled` on read — the module's gate fails closed, which is the right
|
||||
// direction for "may this player mint a game account".
|
||||
// Engagement Phase 1b verification gate: 'on'/'off' only, so a typo cannot land
|
||||
// a value that reads as neither and silently resolves to off.
|
||||
if (settings.EMAIL_VERIFICATION_KEY in updates) {
|
||||
const v = updates[settings.EMAIL_VERIFICATION_KEY]
|
||||
if (v !== 'on' && v !== 'off') {
|
||||
return res.status(400).json({ message: 'Invalid email_verification_required value' })
|
||||
}
|
||||
}
|
||||
// App Links toggle is a boolean stored as a 'true'/'false' string; accept a real
|
||||
// boolean or those two strings and normalize, reject anything else.
|
||||
if (settings.MOBILE_APP_LINKS_KEY in updates) {
|
||||
@@ -847,13 +910,27 @@ async function createUser(req, res) {
|
||||
if (await users.getRawByUsername(req.body.username)) {
|
||||
return res.status(409).json({ message: 'Username already taken' })
|
||||
}
|
||||
const user = await users.createUser({
|
||||
username: req.body.username,
|
||||
password: req.body.password,
|
||||
role: req.body.role || 'admin',
|
||||
email: req.body.email || null,
|
||||
status: req.body.status || 'active',
|
||||
})
|
||||
let user
|
||||
try {
|
||||
user = await users.createUser({
|
||||
username: req.body.username,
|
||||
password: req.body.password,
|
||||
role: req.body.role || 'admin',
|
||||
email: req.body.email || null,
|
||||
status: req.body.status || 'active',
|
||||
})
|
||||
} catch (err) {
|
||||
// Before Phase 1b this had no catch at all, so a duplicate address became
|
||||
// an opaque 500 for an admin who could see nothing wrong with the form.
|
||||
// An admin may be told the real reason: they can already list every account.
|
||||
if (users.isDuplicateEmail(err)) {
|
||||
return res.status(409).json({ message: 'Another account already uses that email address.' })
|
||||
}
|
||||
if (users.isDuplicateUsername(err)) {
|
||||
return res.status(409).json({ message: 'Username already taken' })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'user.create',
|
||||
@@ -885,13 +962,25 @@ async function updateUser(req, res) {
|
||||
return res.status(400).json({ message: 'Cannot demote the last admin' })
|
||||
}
|
||||
}
|
||||
const user = await users.update(id, {
|
||||
username: req.body.username,
|
||||
password: req.body.password,
|
||||
role: req.body.role,
|
||||
email: req.body.email,
|
||||
status: req.body.status,
|
||||
})
|
||||
let user
|
||||
try {
|
||||
user = await users.update(id, {
|
||||
username: req.body.username,
|
||||
password: req.body.password,
|
||||
role: req.body.role,
|
||||
email: req.body.email,
|
||||
status: req.body.status,
|
||||
})
|
||||
} catch (err) {
|
||||
// Same as createUser: an uncaught duplicate address was an opaque 500.
|
||||
if (users.isDuplicateEmail(err)) {
|
||||
return res.status(409).json({ message: 'Another account already uses that email address.' })
|
||||
}
|
||||
if (users.isDuplicateUsername(err)) {
|
||||
return res.status(409).json({ message: 'Username already taken' })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
await activity.log({ req, action: 'user.update', detail: { id } })
|
||||
// Distinct audit trail for the security-sensitive fields (role & status),
|
||||
// so a promotion/ban is greppable beyond the generic user.update entry.
|
||||
@@ -1055,6 +1144,8 @@ module.exports = {
|
||||
getUser,
|
||||
createUser,
|
||||
updateUser,
|
||||
emailDedupeReport,
|
||||
acknowledgeEmailDedupeReport,
|
||||
deleteUser,
|
||||
listUserTrustedDevices,
|
||||
revokeUserTrustedDevice,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// isn't configured); the DB stores only its hash.
|
||||
|
||||
const invites = require('../../../model/invites/invites.model')
|
||||
const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const mailer = require('../../../utils/mailer')
|
||||
|
||||
@@ -34,6 +35,18 @@ async function create(req, res) {
|
||||
return res.status(400).json({ message: 'A valid email and role are required.' })
|
||||
}
|
||||
try {
|
||||
// Catch a collision HERE rather than at accept time (Phase 1b decision 1).
|
||||
// Uniqueness makes an invite to an already-held address unfulfillable, and
|
||||
// discovering that after the invitee has clicked the link and chosen a
|
||||
// password is a bad place to find out. Telling an authenticated admin that
|
||||
// one of their own users holds an address is not the enumeration surface the
|
||||
// public register form is — the admin can already list every account.
|
||||
const existing = await users.getActiveByEmail(email)
|
||||
if (existing.length) {
|
||||
log.info('invite refused: address already held', { email, by: req.user.username })
|
||||
return res.status(409).json({ message: 'An account already uses that email address.' })
|
||||
}
|
||||
|
||||
const { invite, token } = await invites.create({ email, role, invitedBy: req.user.id })
|
||||
const url = acceptUrl(token)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ invitesRouter.post(
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email","role"], properties: { email: { type: "string" }, role: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Invite created', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'An account already uses that email address. Addresses are unique, so such an invite could never be accepted; it is refused here rather than at accept time, after the invitee has clicked the link.', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('email').isEmail().isLength({ max: 255 }),
|
||||
body('role').isIn(['admin', 'editor', 'moderator', 'player']),
|
||||
|
||||
@@ -30,6 +30,31 @@ usersRouter.get(
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.listUsers,
|
||||
)
|
||||
// The Phase 1b de-duplication report. Declared BEFORE '/:id' — Express matches in
|
||||
// order, so a literal segment registered after a parameterised one is never
|
||||
// reached ('email-dedupe-report' would bind as :id).
|
||||
usersRouter.get(
|
||||
'/email-dedupe-report',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Accounts whose email was cleared by de-duplication (admin only)'
|
||||
// #swagger.description = 'When email addresses became unique, accounts sharing an address kept only the earliest-created one; the rest had their address cleared. These users can still sign in but cannot receive password-reset or notification email until they set a new address, so they are the ones to contact.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The affected accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/EmailDedupeEntry" } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.emailDedupeReport,
|
||||
)
|
||||
usersRouter.post(
|
||||
'/email-dedupe-report/acknowledge',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Dismiss the de-duplication warning (admin only)'
|
||||
// #swagger.description = 'Marks the report acknowledged so it stops appearing as a dashboard warning. The rows are kept as a record of what the upgrade did.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Acknowledged', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, acknowledged: { type: "integer" } } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.acknowledgeEmailDedupeReport,
|
||||
)
|
||||
usersRouter.post(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// two surfaces were deleted.
|
||||
|
||||
const users = require('../../../model/users/users.model')
|
||||
const emailVerifications = require('../../../model/emailVerifications/emailVerifications.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
|
||||
const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
|
||||
@@ -22,6 +23,7 @@ const usernamePolicy = require('../../../auth/usernamePolicy')
|
||||
const loginProtection = require('../../../middleware/loginProtection')
|
||||
const botScore = require('../../../middleware/botScore')
|
||||
const totp = require('../../../utils/totp')
|
||||
const mailer = require('../../../utils/mailer')
|
||||
|
||||
const log = require('../../../utils/logger')('account')
|
||||
|
||||
@@ -37,6 +39,10 @@ async function getAccount(req, res) {
|
||||
username: req.user.username,
|
||||
role: req.user.role,
|
||||
email: req.user.email || null,
|
||||
email_verified: Boolean(req.user.email_verified),
|
||||
// The address awaiting its link, so the screen can say "check your inbox"
|
||||
// rather than looking as though the change silently failed.
|
||||
email_pending: (raw && raw.email_pending) || null,
|
||||
status: req.user.status || 'active',
|
||||
totp_enabled: Boolean(req.user.totp_enabled),
|
||||
has_password: Boolean(raw && raw.password_hash),
|
||||
@@ -133,6 +139,127 @@ async function changePassword(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Email address (engagement Phase 1b) ────────────────────────────────────
|
||||
|
||||
function baseUrl() {
|
||||
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function verifyUrl(token) {
|
||||
return `${baseUrl()}/account/verify-email/${token}`
|
||||
}
|
||||
|
||||
// Mint a verification and mail it. Shared by the change and resend paths so the
|
||||
// quota, the supersede and the log line cannot drift between them. Returns a
|
||||
// { ok } or { ok: false, status, message } the caller can hand straight back.
|
||||
async function issueVerification(req, email) {
|
||||
if (await emailVerifications.sendQuotaExhausted(req.user.id)) {
|
||||
log.warn('email verification quota exhausted', { id: req.user.id, ip: req.ip })
|
||||
return { ok: false, status: 429, message: 'Too many verification emails. Try again later.' }
|
||||
}
|
||||
// A fresh request supersedes every older link — otherwise an address the user
|
||||
// typed by mistake stays installable for a day.
|
||||
await emailVerifications.invalidatePendingForUser(req.user.id)
|
||||
const { token } = await emailVerifications.create({ userId: req.user.id, email, requestedIp: req.ip })
|
||||
try {
|
||||
const result = await mailer.sendEmailVerification({ to: email, verifyUrl: verifyUrl(token), username: req.user.username })
|
||||
if (!result.sent) {
|
||||
// Unlike a password reset there is no enumeration reason to pretend: the
|
||||
// caller typed this address themselves and is entitled to know why nothing
|
||||
// arrived. The pending address stays staged so a later resend works.
|
||||
log.warn('verification email not sent (mail not configured)', { id: req.user.id })
|
||||
return { ok: true, emailed: false, reason: 'NOT_CONFIGURED' }
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('verification send failed', err)
|
||||
return { ok: true, emailed: false, reason: 'SEND_FAILED' }
|
||||
}
|
||||
return { ok: true, emailed: true }
|
||||
}
|
||||
|
||||
// PATCH /account/email - ask to set or change the caller's own address.
|
||||
//
|
||||
// The address is STAGED, not installed: `email` keeps receiving mail until the
|
||||
// link is used, so a typo cannot silently redirect this account's password-reset
|
||||
// mail to a mailbox its owner does not control.
|
||||
//
|
||||
// The current password is required when the account has one. An address is where
|
||||
// account recovery lands, so repointing it is a credential-grade act; an
|
||||
// SSO-provisioned account with no password hash is exempt, exactly as
|
||||
// changePassword already carves out.
|
||||
async function changeEmail(req, res) {
|
||||
const email = String(req.body.email || '').trim()
|
||||
try {
|
||||
const raw = await users.getRawById(req.user.id)
|
||||
if (!raw) return res.status(401).json({ message: 'Unauthorized' })
|
||||
|
||||
if (raw.password_hash) {
|
||||
const ok = await users.validatePassword(raw, req.body.currentPassword || '')
|
||||
if (!ok) {
|
||||
loginProtection.recordFailure(req.ip)
|
||||
botScore.recordLoginFailure(req.ip)
|
||||
log.warn('changeEmail wrong current password', { id: req.user.id, ip: req.ip })
|
||||
return res.status(400).json({ message: 'Your current password is incorrect.' })
|
||||
}
|
||||
}
|
||||
|
||||
if (raw.email && raw.email.toLowerCase() === email.toLowerCase()) {
|
||||
return res.status(400).json({ message: 'That is already your email address.' })
|
||||
}
|
||||
|
||||
// Stage it. This is also where a collision with a live address FIRST shows up
|
||||
// cheaply, but it is not the guard that matters - email_pending is deliberately
|
||||
// not unique, so the real arbitration happens at verification time against the
|
||||
// UNIQUE index. Answering identically in both places is what keeps this from
|
||||
// becoming an address-existence oracle.
|
||||
await users.setPendingEmail(req.user.id, email)
|
||||
|
||||
const issued = await issueVerification(req, email)
|
||||
if (!issued.ok) return res.status(issued.status).json({ message: issued.message })
|
||||
|
||||
await activity.log({ req, action: 'account.email.change_requested' })
|
||||
log.info('account email change requested', { id: req.user.id })
|
||||
return res.json({ email_pending: email, emailed: Boolean(issued.emailed), reason: issued.reason || null })
|
||||
} catch (err) {
|
||||
log.error('changeEmail', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /account/email/resend - re-send the link for the address already staged.
|
||||
async function resendEmailVerification(req, res) {
|
||||
try {
|
||||
const raw = await users.getRawById(req.user.id)
|
||||
if (!raw) return res.status(401).json({ message: 'Unauthorized' })
|
||||
if (!raw.email_pending) {
|
||||
return res.status(400).json({ message: 'There is no email address awaiting confirmation.' })
|
||||
}
|
||||
const issued = await issueVerification(req, raw.email_pending)
|
||||
if (!issued.ok) return res.status(issued.status).json({ message: issued.message })
|
||||
log.info('account email verification resent', { id: req.user.id })
|
||||
return res.json({ email_pending: raw.email_pending, emailed: Boolean(issued.emailed), reason: issued.reason || null })
|
||||
} catch (err) {
|
||||
log.error('resendEmailVerification', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /account/email/pending - abandon a staged address (a typo, or a change
|
||||
// of mind). Retires the outstanding links too, so the abandoned address cannot be
|
||||
// installed afterwards by a link already sitting in a mailbox.
|
||||
async function cancelEmailChange(req, res) {
|
||||
try {
|
||||
await users.clearPendingEmail(req.user.id)
|
||||
await emailVerifications.invalidatePendingForUser(req.user.id)
|
||||
await activity.log({ req, action: 'account.email.change_cancelled' })
|
||||
log.info('account email change cancelled', { id: req.user.id })
|
||||
return res.json({ ok: true })
|
||||
} catch (err) {
|
||||
log.error('cancelEmailChange', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: generate a fresh secret (stored but not yet enabled) and return the
|
||||
// otpauth URL + a QR data URL for the user to scan. Overwrites any pending,
|
||||
// not-yet-confirmed secret. Refuses if TOTP is already enabled.
|
||||
@@ -396,6 +523,9 @@ async function generateRecoveryCodes(req, res) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
changeEmail,
|
||||
resendEmailVerification,
|
||||
cancelEmailChange,
|
||||
getAccount,
|
||||
changeUsername,
|
||||
changePassword,
|
||||
|
||||
@@ -132,6 +132,20 @@ async function register(req, res) {
|
||||
role: 'player',
|
||||
})
|
||||
} catch (err) {
|
||||
// Two unique indexes, two different answers (§0.6 finding 2). Before Phase
|
||||
// 1b this branch caught both and told an email collision it was a username
|
||||
// one — the single field the user had NOT collided on.
|
||||
//
|
||||
// The email answer is deliberately generic and deliberately NOT scored: a
|
||||
// truthful "that address already has an account" makes account existence
|
||||
// queryable through a public form, and treating an honest typo on a
|
||||
// colleague's address as an attack would push a legitimate user toward an
|
||||
// IP ban. The real reason is logged and never returned — note the driver's
|
||||
// message embeds the address, which is a second reason it stays server-side.
|
||||
if (users.isDuplicateEmail(err)) {
|
||||
log.warn('register rejected: email already registered', { username: check.name, ip: req.ip })
|
||||
return res.status(400).json({ message: 'Registration failed. Please check your details and try again.' })
|
||||
}
|
||||
// The UNIQUE index is the source of truth for the uniqueness race — a
|
||||
// concurrent duplicate loses here and gets a clean 409.
|
||||
if (users.isDuplicateUsername(err)) {
|
||||
|
||||
100
server/src/router/v1/auth/emailVerify.controller.js
Normal file
100
server/src/router/v1/auth/emailVerify.controller.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// ── Email-address verification (public, token-gated) ───────────────────────
|
||||
//
|
||||
// The confirm half of the Phase 1b change-and-verify flow. The request half is
|
||||
// authenticated and lives on /auth/me/account/email; this half is deliberately
|
||||
// NOT, because the link is opened from a mailbox, routinely on a device that is
|
||||
// not logged in — requiring a session here would strand exactly the users the
|
||||
// flow exists to serve.
|
||||
//
|
||||
// That is safe because the token IS the proof: it is opaque, single-use,
|
||||
// short-lived, stored only as a sha256, and it carries the user and the address
|
||||
// it was minted for. Using it installs an address on that account and does
|
||||
// nothing else — it grants no session, no access, and no way to read anything.
|
||||
// Compare passwordReset.controller, which is the same posture for a strictly
|
||||
// more powerful capability.
|
||||
//
|
||||
// GET /auth/email/verify/:token -> validate the link so the page can render
|
||||
// POST /auth/email/verify/:token -> install the address
|
||||
|
||||
const emailVerifications = require('../../../model/emailVerifications/emailVerifications.model')
|
||||
const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
|
||||
const log = require('../../../utils/logger')('auth-email-verify')
|
||||
|
||||
const INVALID = 'This confirmation link is invalid or has expired.'
|
||||
|
||||
// GET /auth/email/verify/:token — validate a link so the page can render. 404 for
|
||||
// anything not currently usable, never distinguishing expired from used from
|
||||
// never-was.
|
||||
async function lookup(req, res) {
|
||||
try {
|
||||
const row = await emailVerifications.findValidByToken(req.params.token)
|
||||
if (!row) return res.status(404).json({ message: INVALID })
|
||||
const user = await users.getById(row.user_id)
|
||||
if (!user) return res.status(404).json({ message: INVALID })
|
||||
// The address is echoed because the person holding this link is the person it
|
||||
// was mailed to — they already know it. The username tells them which account
|
||||
// they are about to attach it to.
|
||||
return res.json({ username: user.username, email: row.email })
|
||||
} catch (err) {
|
||||
log.error('lookup', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /auth/email/verify/:token — install the address.
|
||||
async function confirm(req, res) {
|
||||
try {
|
||||
const row = await emailVerifications.findValidByToken(req.params.token)
|
||||
if (!row) return res.status(404).json({ message: INVALID })
|
||||
|
||||
// Consume first: if we lost a double-submit race, stop before touching the
|
||||
// account so a spent link cannot be replayed.
|
||||
const won = await emailVerifications.consume(row.id)
|
||||
if (!won) return res.status(404).json({ message: INVALID })
|
||||
|
||||
let installed
|
||||
try {
|
||||
installed = await users.promotePendingEmail(row.user_id, row.email)
|
||||
} catch (err) {
|
||||
// The UNIQUE index is the arbiter, and it fires here rather than at request
|
||||
// time because a pending address reserves nothing: between staging and
|
||||
// confirming, someone else may have verified the same address first.
|
||||
//
|
||||
// ANTI-ENUMERATION: the answer is the generic INVALID, identical to an
|
||||
// expired or already-used link. Saying "that address is taken" would turn
|
||||
// this endpoint into an oracle for which addresses hold accounts — the same
|
||||
// posture passwordReset.controller keeps. The real reason is logged, never
|
||||
// returned; note the driver's message embeds the address, which is another
|
||||
// reason it must not travel to a client.
|
||||
if (users.isDuplicateEmail(err)) {
|
||||
log.warn('email verification lost to an existing address', { userId: row.user_id })
|
||||
await users.clearPendingEmail(row.user_id).catch(() => {})
|
||||
return res.status(404).json({ message: INVALID })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
// The guard rejected it: the user has since asked for a different address, so
|
||||
// this token is stale even though it had not expired. Same generic answer.
|
||||
if (!installed) {
|
||||
log.info('email verification superseded by a later request', { userId: row.user_id })
|
||||
return res.status(404).json({ message: INVALID })
|
||||
}
|
||||
|
||||
// Retire any other outstanding links for this user — one address is now proved
|
||||
// and the others must not be installable behind the user's back.
|
||||
await emailVerifications.invalidatePendingForUser(row.user_id)
|
||||
|
||||
await activity.log({ req, userId: row.user_id, action: 'account.email.verified' })
|
||||
log.info('account email verified', { userId: row.user_id, ip: req.ip })
|
||||
// No session is issued: this proves control of a mailbox, not of an account.
|
||||
return res.json({ ok: true, message: 'Your email address has been confirmed.' })
|
||||
} catch (err) {
|
||||
log.error('confirm', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { lookup, confirm }
|
||||
50
server/src/router/v1/auth/emailVerify.router.js
Normal file
50
server/src/router/v1/auth/emailVerify.router.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// Auth · Email — the confirm half of the self-service email change. Public but
|
||||
// token-gated: validate a link, then install the address it proves.
|
||||
//
|
||||
// Mounted at /api/v1/auth/email by auth/index.js, so the routes below emit
|
||||
// GET|POST /auth/email/verify/:token.
|
||||
//
|
||||
// Requesting a change is a different, AUTHENTICATED route —
|
||||
// PATCH /auth/me/account/email. This half is unauthenticated on purpose: the link
|
||||
// is opened from a mailbox, often on a device with no session.
|
||||
//
|
||||
// One anti-enumeration property is load-bearing and must survive any edit here:
|
||||
// every unusable link answers with the same 404, and so does a link that lost the
|
||||
// address to another account. Distinguishing "already taken" from "expired" would
|
||||
// make this endpoint an oracle for which addresses hold accounts.
|
||||
|
||||
const express = require('express')
|
||||
const { param } = require('express-validator')
|
||||
|
||||
const { lookup, confirm } = require('./emailVerify.controller')
|
||||
const { emailVerifyConfirmLimiter } = require('../../../middleware/rateLimit')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
const emailRouter = express.Router()
|
||||
|
||||
emailRouter.get(
|
||||
'/verify/:token',
|
||||
// #swagger.tags = ['Auth']
|
||||
// #swagger.summary = 'Validate an email-confirmation link'
|
||||
// #swagger.description = 'Returns the target username and the address the link proves, so the confirmation page can render. 404 for anything not currently usable (never distinguishes expired from used from never-existed).'
|
||||
/* #swagger.responses[200] = { description: 'Confirmation link is valid', content: { "application/json": { schema: { type: "object", properties: { username: { type: "string" }, email: { type: "string", format: "email" } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Invalid or expired confirmation link', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('token').isString().isLength({ min: 8, max: 128 }),
|
||||
validate,
|
||||
lookup,
|
||||
)
|
||||
emailRouter.post(
|
||||
'/verify/:token',
|
||||
// #swagger.tags = ['Auth']
|
||||
// #swagger.summary = 'Confirm an email address from its link'
|
||||
// #swagger.description = 'Consumes the single-use link and installs the address on the account, marking it verified. Issues no session — it proves control of a mailbox, not of an account. Answers 404 for an unusable link AND for an address another account has since verified, deliberately: the two are indistinguishable to a caller so the endpoint cannot be used to test which addresses hold accounts.'
|
||||
/* #swagger.responses[200] = { description: 'Address confirmed', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Invalid, expired, superseded, or already-used confirmation link', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[429] = { description: 'Too many attempts', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
emailVerifyConfirmLimiter,
|
||||
param('token').isString().isLength({ min: 8, max: 128 }),
|
||||
validate,
|
||||
confirm,
|
||||
)
|
||||
|
||||
module.exports = emailRouter
|
||||
@@ -26,6 +26,7 @@ const loginRouter = require('./login.router')
|
||||
const registerRouter = require('./register.router')
|
||||
const inviteRouter = require('./invite.router')
|
||||
const passwordRouter = require('./password.router')
|
||||
const emailRouter = require('./emailVerify.router')
|
||||
const sessionRouter = require('./session.router')
|
||||
|
||||
const authRouter = express.Router()
|
||||
@@ -54,6 +55,7 @@ authRouter.use('/login', loginRouter)
|
||||
authRouter.use('/register', registerRouter)
|
||||
authRouter.use('/invite', inviteRouter)
|
||||
authRouter.use('/password', passwordRouter)
|
||||
authRouter.use('/email', emailRouter)
|
||||
|
||||
// The two singletons that own no path segment of their own: POST /logout and
|
||||
// GET /me. Mounted at the group root and **last**, because `use('/me', …)` above
|
||||
|
||||
@@ -55,6 +55,20 @@ async function acceptInvite(req, res) {
|
||||
emailVerified: true, // they proved control of the address by using the link
|
||||
})
|
||||
} catch (err) {
|
||||
// The address on the invite is already held. Admin invites are checked for
|
||||
// this at CREATION (POST /admin/invites 409s), so reaching here means the
|
||||
// address was claimed in the window between the invite going out and the
|
||||
// invitee clicking — a race, not the ordinary case. It still has to be
|
||||
// survivable: the invitee has already clicked a link and typed a password,
|
||||
// and an opaque 500 at that point is the worst possible moment to fail.
|
||||
if (users.isDuplicateEmail(err)) {
|
||||
log.warn('invite accept rejected: address already held', { inviteId: row.id })
|
||||
return res.status(409).json({
|
||||
message:
|
||||
'This invitation cannot be completed because its email address is already in use. ' +
|
||||
'Ask an administrator for a new invitation.',
|
||||
})
|
||||
}
|
||||
if (users.isDuplicateUsername(err)) {
|
||||
return res.status(409).json({ message: 'That username is already taken.' })
|
||||
}
|
||||
|
||||
@@ -79,6 +79,50 @@ meRouter.patch(
|
||||
account.changePassword,
|
||||
)
|
||||
|
||||
// Email address (engagement Phase 1b). The change is STAGED and only a tokened
|
||||
// link installs it, so these three routes never alter the address that is
|
||||
// currently receiving mail. The confirm half is public and lives at
|
||||
// /auth/email/verify/:token, because the link is opened from a mailbox.
|
||||
meRouter.patch(
|
||||
'/account/email',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Request a new email address (self, any role)'
|
||||
// #swagger.description = 'Stages the address and emails a confirmation link. The account keeps its current address until that link is used, so a mistyped address cannot redirect password-reset mail. Requires currentPassword when the account has a password; SSO-provisioned accounts with no password are exempt.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ChangeEmailRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Address staged; a confirmation link was sent', content: { "application/json": { schema: { $ref: "#/components/schemas/PendingEmail" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error, wrong current password, or already your address', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[429] = { description: 'Too many verification emails', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
accountChangeLimiter,
|
||||
body('email').isString().trim().isEmail().isLength({ max: 255 }),
|
||||
body('currentPassword').optional({ values: 'falsy' }).isString(),
|
||||
validate,
|
||||
account.changeEmail,
|
||||
)
|
||||
meRouter.post(
|
||||
'/account/email/resend',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Re-send the confirmation link for the pending address'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Confirmation link re-sent', content: { "application/json": { schema: { $ref: "#/components/schemas/PendingEmail" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'No address is awaiting confirmation', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[429] = { description: 'Too many verification emails', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
accountChangeLimiter,
|
||||
account.resendEmailVerification,
|
||||
)
|
||||
meRouter.delete(
|
||||
'/account/email/pending',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Abandon the pending email address'
|
||||
// #swagger.description = 'Clears the staged address and retires its outstanding links, so a confirmation email already delivered can no longer install it.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Pending address cleared', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
account.cancelEmailChange,
|
||||
)
|
||||
|
||||
// TOTP self-enrollment (disable requires a valid current code; it does not take
|
||||
// a password).
|
||||
meRouter.post(
|
||||
|
||||
@@ -29,7 +29,7 @@ passwordRouter.post(
|
||||
'/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.description = 'Emails a single-use, ~1h reset link to the active account on the address. Always returns the same generic 200 whether or not the email matches (no account enumeration). 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" } } } } */
|
||||
|
||||
@@ -5,13 +5,16 @@
|
||||
// 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
|
||||
// Addresses are unique since engagement Phase 1b, so a request matches at most one
|
||||
// account; the loop below is kept because it costs nothing and the email names the
|
||||
// username anyway. 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.
|
||||
//
|
||||
// Reset mail is deliberately NOT gated on email_verified. The verification gate
|
||||
// (Phase 1b) governs opt-in ENGAGEMENT mail; applying it to account recovery would
|
||||
// lock out every user carrying an address from before verification existed. 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.
|
||||
|
||||
|
||||
@@ -192,8 +192,12 @@ async function callback(req, res) {
|
||||
// Auto-provision a `player` from an SSO profile when no identity is linked yet
|
||||
// and registration allows SSO sign-up. Derives a unique username (reserved-name
|
||||
// safe) with a bounded retry against the UNIQUE index, captures the provider
|
||||
// email, links the identity, and audit-logs the provision. Returns the new user,
|
||||
// or null if a unique username couldn't be found.
|
||||
// email, links the identity, and audit-logs the provision.
|
||||
//
|
||||
// Returns { user } on success, or { error } naming why it failed. It used to
|
||||
// return the user or a bare null, which was enough while username was the only
|
||||
// unique index; since Phase 1b there are two ways to fail and they need different
|
||||
// things said to the person in front of the browser.
|
||||
async function provisionSsoPlayer(req, providerId, profile) {
|
||||
const base = usernamePolicy.deriveUsernameBase(profile)
|
||||
for (let attempt = 0; attempt < PROVISION_MAX_TRIES; attempt++) {
|
||||
@@ -203,9 +207,17 @@ async function provisionSsoPlayer(req, providerId, profile) {
|
||||
username: candidate,
|
||||
role: 'player',
|
||||
email: profile.email || null,
|
||||
// The built-in providers only return an email the IdP has verified, so
|
||||
// treat a supplied address as verified (skips the eventual re-verify).
|
||||
emailVerified: Boolean(profile.email),
|
||||
// Honour what the IdP actually ASSERTED, not the mere presence of an
|
||||
// address. The old `Boolean(profile.email)` marked every SSO address
|
||||
// verified, which made email_verified too weak a signal to mean anything
|
||||
// (§0.6 finding 3). An IdP that omits the claim leaves the address
|
||||
// unverified and the user proves it through the ordinary flow.
|
||||
//
|
||||
// Forward-only, by decision: existing rows keep the verified flag they
|
||||
// were given. Retroactively demoting live users is the G22 mistake — a
|
||||
// safe default applied backwards to a running system without telling
|
||||
// anyone.
|
||||
emailVerified: profile.emailVerified === true,
|
||||
})
|
||||
await userIdentities.link({
|
||||
userId: user.id,
|
||||
@@ -215,8 +227,24 @@ async function provisionSsoPlayer(req, providerId, profile) {
|
||||
})
|
||||
await activity.log({ req, userId: user.id, action: 'auth.sso.provision', detail: { provider: providerId } })
|
||||
log.info('sso player provisioned', { provider: providerId, id: user.id, username: user.username })
|
||||
return user
|
||||
return { user }
|
||||
} catch (err) {
|
||||
// An EMAIL collision can never be cleared by trying another username, so
|
||||
// retrying is not merely useless — it burns every candidate and returns
|
||||
// null, and the log then blames usernames for a conflict that was never
|
||||
// about them (§0.6 finding 2). Stop, and say which it was.
|
||||
//
|
||||
// This is not the enumeration surface the register form is: the caller has
|
||||
// already authenticated with the IdP, and the address is one the IdP
|
||||
// asserted for them. Naming the real reason here is what makes the failure
|
||||
// diagnosable instead of opaque.
|
||||
if (users.isDuplicateEmail(err)) {
|
||||
log.warn('sso provision: address already held by another account', {
|
||||
provider: providerId,
|
||||
subject: profile.subject,
|
||||
})
|
||||
return { error: 'email_in_use' }
|
||||
}
|
||||
// Username collided with a concurrent/existing account — try the next
|
||||
// suffix. Any other error is real; propagate it.
|
||||
if (users.isDuplicateUsername(err)) continue
|
||||
@@ -224,7 +252,7 @@ async function provisionSsoPlayer(req, providerId, profile) {
|
||||
}
|
||||
}
|
||||
log.error('sso provision: exhausted username candidates', { provider: providerId, base })
|
||||
return null
|
||||
return { error: 'error' }
|
||||
}
|
||||
|
||||
// Trusted-device skip for the SSO paths — the exact analogue of the check in
|
||||
@@ -267,8 +295,9 @@ async function finishLogin(req, res, providerId, kind, tx, profile) {
|
||||
log.warn('sso login refused: no linked account', { provider: providerId })
|
||||
return res.redirect(loginError('not_linked', portal))
|
||||
}
|
||||
user = await provisionSsoPlayer(req, providerId, profile)
|
||||
if (!user) return res.redirect(loginError('error', portal))
|
||||
const provisioned = await provisionSsoPlayer(req, providerId, profile)
|
||||
if (provisioned.error) return res.redirect(loginError(provisioned.error, portal))
|
||||
user = provisioned.user
|
||||
}
|
||||
|
||||
// Status gate (parity with local login): a disabled/banned account can't
|
||||
@@ -384,12 +413,12 @@ async function resolveMobileSsoUser(req, res, sess, providerId, profile) {
|
||||
res.redirect(appError(sess, 'not_linked'))
|
||||
return null
|
||||
}
|
||||
const user = await provisionSsoPlayer(req, providerId, profile)
|
||||
if (!user) {
|
||||
res.redirect(appError(sess, 'error'))
|
||||
const provisioned = await provisionSsoPlayer(req, providerId, profile)
|
||||
if (provisioned.error) {
|
||||
res.redirect(appError(sess, provisioned.error))
|
||||
return null
|
||||
}
|
||||
return user
|
||||
return provisioned.user
|
||||
}
|
||||
|
||||
async function finishMobileLogin(req, res, providerId, kind, tx, profile) {
|
||||
@@ -535,6 +564,11 @@ async function finishLink(req, res, providerId, tx, profile) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// Exported for tests only. The behaviour that matters is a COUNT — on an email
|
||||
// conflict it must stop rather than work through every username candidate — and
|
||||
// that is not observable through the route handlers without stubbing most of the
|
||||
// OAuth flow to watch a loop it never reaches.
|
||||
provisionSsoPlayer,
|
||||
listProviders,
|
||||
start,
|
||||
linkStart,
|
||||
|
||||
Reference in New Issue
Block a user