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']
|
||||
|
||||
Reference in New Issue
Block a user