mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-28 00:18:58 -07:00
[www] password/email recovery (#146)
* pw/email recovery * fix migration
This commit is contained in:
@@ -3,12 +3,19 @@ import { describeRoute, openAPIRouteHandler, validator } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
beginEmailCode,
|
||||
cancelEmailCode,
|
||||
completeEmailCode,
|
||||
createAccount,
|
||||
DEFAULT_USERNAME_CHANGES,
|
||||
defaultAccount,
|
||||
EMAIL_CODE_TTL_SECONDS,
|
||||
emailSender,
|
||||
getAccount,
|
||||
getAccountByEmail,
|
||||
getAccountByUsername,
|
||||
getAccountsByIds,
|
||||
pendingEmailCode,
|
||||
SEARCH_LIMIT,
|
||||
searchAccounts,
|
||||
updateAccount,
|
||||
@@ -25,6 +32,8 @@ import { validateAndGetAccountId } from '@repo/jwt'
|
||||
// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a
|
||||
// value — the enum has no runtime dependencies.
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
import { buildEndpoints } from '../../ns/src/endpoints'
|
||||
import { emailChangeEmail } from './email-change-email'
|
||||
import {
|
||||
AccountDto,
|
||||
BannerImageRequest,
|
||||
@@ -34,6 +43,9 @@ import {
|
||||
CreateAccountRequest,
|
||||
CreateAccountResult,
|
||||
DisplayNameRequest,
|
||||
EmailConfirmRequest,
|
||||
EmailConfirmResponse,
|
||||
EmailRefusal,
|
||||
EmailRequest,
|
||||
EmojiRequest,
|
||||
form,
|
||||
@@ -41,6 +53,7 @@ import {
|
||||
IdentityFlagsRequest,
|
||||
json,
|
||||
ParentalControl,
|
||||
PendingEmailResponse,
|
||||
PhoneRequest,
|
||||
PrivacySettings,
|
||||
ProfileImageRequest,
|
||||
@@ -55,7 +68,7 @@ import { resolveWhitelistedEmoji, WHITELISTED_EMOJIS } from './whitelisted-emoji
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { DescribeRouteOptions } from 'hono-openapi'
|
||||
import type { Account } from '@repo/domain'
|
||||
import type { Account, EmailCodeIssue } from '@repo/domain'
|
||||
import type { App } from './context'
|
||||
|
||||
/**
|
||||
@@ -241,6 +254,101 @@ async function pushAccountUpdate(c: Context<App>, account: Account): Promise<voi
|
||||
/** The empty-body 401 every auth-gated route returns; reused across their specs. */
|
||||
const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
|
||||
|
||||
/** What the email routes say when the address is already on another account. */
|
||||
const EMAIL_TAKEN = 'That email address is already on another account.'
|
||||
|
||||
/** What `POST /account/me/email/confirm` says to any code that doesn't take. */
|
||||
const EMAIL_CODE_WRONG =
|
||||
'That code is wrong or has expired. Save the address again to be sent a new one.'
|
||||
|
||||
/** What the email routes say to a repeat within the cooldown. */
|
||||
const EMAIL_TOO_SOON =
|
||||
'A code was emailed less than two minutes ago. Enter that one, or try again in a minute.'
|
||||
|
||||
/** What the email routes say once the account's codes for the day are spent. */
|
||||
const EMAIL_QUOTA =
|
||||
'Too many codes have been emailed for this account today. Please try again tomorrow.'
|
||||
|
||||
/** The refusal for a code that was not issued, by why. */
|
||||
const emailCodeRefusal = (refused: 'cooldown' | 'quota') => ({
|
||||
success: false,
|
||||
error: refused === 'cooldown' ? EMAIL_TOO_SOON : EMAIL_QUOTA,
|
||||
})
|
||||
|
||||
/** What the email routes say when the mail service refused the send. */
|
||||
const EMAIL_NOT_SENT = 'The confirmation email could not be sent. Please try again later.'
|
||||
|
||||
/** The account's current address, or null when it has none (absent or empty). */
|
||||
const currentEmail = (account: Account | null): string | null => account?.email?.trim() || null
|
||||
|
||||
/**
|
||||
* Send one of the email-change codes. False when the mail service refused it, in which case
|
||||
* the code is dropped (cooldown included) — nothing went out, so nothing to wait on, and the
|
||||
* player can try again at once. The usual cause is the sender's domain not being enabled for
|
||||
* Email Sending (DEPLOYING.md): an operator's problem, hence the log.
|
||||
*/
|
||||
async function sendEmailCode(
|
||||
c: Context<App>,
|
||||
input: {
|
||||
purpose: 'email_change' | 'email_verify'
|
||||
to: string
|
||||
code: string
|
||||
newEmail: string
|
||||
currentEmail: string | null
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const id = (await authedId(c))!
|
||||
const message = emailChangeEmail({
|
||||
to: input.to,
|
||||
from: emailSender(c.env),
|
||||
domain: c.env.DOMAIN,
|
||||
www: buildEndpoints(c.env.DOMAIN, c.env.SUBDOMAINS).WWW,
|
||||
code: input.code,
|
||||
newEmail: input.newEmail,
|
||||
// Step 2's letter reads as "confirm this address" whether or not there is a current
|
||||
// one: by then the current address has had its say.
|
||||
currentEmail: input.purpose === 'email_change' ? input.currentEmail : null,
|
||||
validMinutes: Math.round(EMAIL_CODE_TTL_SECONDS / 60),
|
||||
})
|
||||
try {
|
||||
await c.env.EMAIL.send(message)
|
||||
} catch (err) {
|
||||
await cancelEmailCode(c.env.DB, input.purpose, id)
|
||||
logger.error('could not send the email-change code', {
|
||||
accountId: id,
|
||||
purpose: input.purpose,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return false
|
||||
}
|
||||
logger.info('email-change code sent', { accountId: id, purpose: input.purpose })
|
||||
return true
|
||||
}
|
||||
|
||||
/** What `GET /account/me/email/pending` serves: the live step, if any. */
|
||||
async function pendingEmailChange(
|
||||
c: Context<App>,
|
||||
id: number
|
||||
): Promise<{ pending: string | null; sentTo: string | null }> {
|
||||
const verify = await pendingEmailCode(c.env.DB, 'email_verify', id)
|
||||
if (verify?.payload) return { pending: verify.payload, sentTo: verify.payload }
|
||||
const change = await pendingEmailCode(c.env.DB, 'email_change', id)
|
||||
if (change?.payload) {
|
||||
const current = currentEmail(await getAccount(c.env.DB, id))
|
||||
return { pending: change.payload, sentTo: current ?? change.payload }
|
||||
}
|
||||
return { pending: null, sentTo: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a D1 error is the `idx_account_email_lower` UNIQUE index refusing a second holder
|
||||
* of an address — two saves racing past the lookup. D1 surfaces SQLite's message verbatim.
|
||||
*/
|
||||
function isUniqueEmailViolation(err: unknown): boolean {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return message.includes('UNIQUE constraint failed') && message.includes('email_lower')
|
||||
}
|
||||
|
||||
/** Bearer-JWT security requirement, for the auth-gated routes. */
|
||||
const AUTHED = [{ bearerAuth: [] }]
|
||||
|
||||
@@ -631,18 +739,59 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// Set the player's email (persisted on the account row; surfaced by /account/me).
|
||||
// Change the player's email — STAGED, not applied. An address belongs to one account and is
|
||||
// what password recovery goes to, so moving it is the one profile change a stolen session
|
||||
// must not be able to make alone. Two steps, each a six-digit code (@repo/domain's
|
||||
// email-code-db.ts: one attempt, half an hour, and a cooldown on resends):
|
||||
//
|
||||
// 1. `email_change` — to the CURRENT address, when the account has one: its owner approves
|
||||
// giving it up. A stolen session can't pass this. Skipped for an account with no
|
||||
// confirmed address, which has nobody to ask.
|
||||
// 2. `email_verify` — to the NEW address: proves it is reachable before it becomes the
|
||||
// recovery address, so a typo can never be stored. This is the step that applies the
|
||||
// change. Issued as step 1 is spent, or at once when there was no step 1.
|
||||
//
|
||||
// `/account/me` keeps serving the old address until step 2 lands. A player with NO address
|
||||
// who mistypes one just enters another: a different address always gets a fresh code (the
|
||||
// cooldown only refuses a repeat to the SAME address), since nothing confirmed is at stake.
|
||||
// With a confirmed address, every new attempt starts again at step 1, and step 1's cooldown
|
||||
// applies whatever the new address is — it is the current inbox being written to.
|
||||
//
|
||||
// The game posts this and reads `{ success: true }`, as it always did; it has no screen for
|
||||
// the code, so the emails' links finish the change on the website. A same-address save (a
|
||||
// case change, or re-saving what is there) applies at once — nothing to approve.
|
||||
//
|
||||
// An address another account holds (case-insensitively) is refused with a sentence in the
|
||||
// body, which the website shows, where the malformed-address refusal is an empty 400. Same
|
||||
// status for both: a 400 is what the game client is known to cope with on this route. The
|
||||
// lookup is what the player reads; the UNIQUE index on `email_lower` is the backstop for a
|
||||
// race, re-checked when the change is applied.
|
||||
.post(
|
||||
'/account/me/email',
|
||||
describeRoute({
|
||||
tags: ['Profile'],
|
||||
summary: 'Set email',
|
||||
description: 'Persisted; surfaced only by `/account/me`. Not broadcast.',
|
||||
summary: 'Change email (sends a confirmation code)',
|
||||
description: [
|
||||
'STAGES the change. An account with a confirmed address is sent a six-digit code',
|
||||
'THERE first (its owner approves the change); once that code is posted to',
|
||||
'`POST /account/me/email/confirm`, a second code goes to the NEW address, and',
|
||||
'posting that one applies the change. An account with no address gets only the',
|
||||
'second step. Each email links to the website’s `/confirm-email` page.',
|
||||
'`{ success: true }` means a code was sent. Re-saving the current address applies',
|
||||
'at once. An address on another account (case-insensitive) is refused with',
|
||||
'`{ success: false, error }`; so is a repeat within two minutes of the last code,',
|
||||
'except that an account with no address may enter a DIFFERENT address at any time.',
|
||||
'The malformed-address refusal has no body.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: { description: 'Not a syntactically valid address (empty body)' },
|
||||
200: json(SuccessResponse, 'Code sent (or the same address re-saved)'),
|
||||
400: json(
|
||||
EmailRefusal,
|
||||
'Not a syntactically valid address (empty body), or already on another account / asked again too soon'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
502: json(EmailRefusal, 'The confirmation email could not be sent'),
|
||||
},
|
||||
}),
|
||||
validator('form', EmailRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
|
||||
@@ -650,11 +799,152 @@ const app = new Hono<App>()
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const { email } = c.req.valid('form')
|
||||
await updateAccount(c.env.DB, id, { email })
|
||||
const taken = { success: false, error: EMAIL_TAKEN }
|
||||
const holder = await getAccountByEmail(c.env.DB, email)
|
||||
if (holder !== null && holder.accountId !== id) return c.json(taken, 400)
|
||||
|
||||
const current = currentEmail(await getAccount(c.env.DB, id))
|
||||
if (current !== null && current.toLowerCase() === email.toLowerCase()) {
|
||||
await updateAccount(c.env.DB, id, { email })
|
||||
return c.json({ success: true })
|
||||
}
|
||||
|
||||
let issue: EmailCodeIssue
|
||||
if (current === null) {
|
||||
// Step 2 straight away. A different address than the one last staged is never
|
||||
// a repeat, so it skips the cooldown — the typo case. (Not the daily cap.)
|
||||
const staged = await pendingEmailCode(c.env.DB, 'email_verify', id)
|
||||
issue = await beginEmailCode(c.env.DB, 'email_verify', id, email, {
|
||||
force: staged?.payload !== email,
|
||||
})
|
||||
} else {
|
||||
// Step 1; a step 2 left over from an earlier attempt is voided, since the current
|
||||
// address has not approved THIS one.
|
||||
await cancelEmailCode(c.env.DB, 'email_verify', id)
|
||||
issue = await beginEmailCode(c.env.DB, 'email_change', id, email)
|
||||
}
|
||||
if (issue.refused) return c.json(emailCodeRefusal(issue.refused), 400)
|
||||
const code = issue.code
|
||||
|
||||
const sent = await sendEmailCode(c, {
|
||||
purpose: current === null ? 'email_verify' : 'email_change',
|
||||
to: current ?? email,
|
||||
code,
|
||||
newEmail: email,
|
||||
currentEmail: current,
|
||||
})
|
||||
if (!sent) return c.json({ success: false, error: EMAIL_NOT_SENT }, 502)
|
||||
return c.json({ success: true })
|
||||
}
|
||||
)
|
||||
|
||||
// The change awaiting a code, for the website to pick up where it left off after a reload
|
||||
// (and right after signup, which saves the address and lands on the account page).
|
||||
.get(
|
||||
'/account/me/email/pending',
|
||||
describeRoute({
|
||||
tags: ['Profile'],
|
||||
summary: 'The email change awaiting confirmation',
|
||||
description: [
|
||||
'The address a code is out for, and where the code went — the current address while',
|
||||
'it is approving the change, the new one while it is confirming itself — or nulls when',
|
||||
'no change is in flight (none staged, or the code lapsed or was spent by a wrong guess).',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(PendingEmailResponse, 'The pending change, or nulls'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(await pendingEmailChange(c, id))
|
||||
}
|
||||
)
|
||||
|
||||
// Spend the pending code. The code is what approves the step; the session says whose. One
|
||||
// guess: a wrong code voids the step (and the change), and the address has to be saved again.
|
||||
// The refusal reads the same whether the code was wrong, lapsed or never issued. Which step
|
||||
// is being confirmed follows from what is pending — only one ever is.
|
||||
.post(
|
||||
'/account/me/email/confirm',
|
||||
describeRoute({
|
||||
tags: ['Profile'],
|
||||
summary: 'Confirm an email change step with the emailed code',
|
||||
description: [
|
||||
'Spends the code staged by `POST /account/me/email`. When it was the CURRENT address',
|
||||
'approving the change, a second code is sent to the new address and `applied` is',
|
||||
'false, with `pending`/`sentTo` saying so; when it was the new address confirming',
|
||||
'itself, the change is applied and `applied` is true. The code works ONCE: a wrong one',
|
||||
'voids the change. The refusal is the same sentence whether the code was wrong, has',
|
||||
'lapsed (30 minutes) or was never issued. The address is re-checked against other',
|
||||
'accounts as it lands.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(EmailConfirmResponse, 'Step confirmed; `applied` says whether the change landed'),
|
||||
400: json(
|
||||
EmailRefusal,
|
||||
'No code, a code that is wrong / lapsed / not issued, or an address another account took meanwhile'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
502: json(EmailRefusal, 'The second code’s email could not be sent'),
|
||||
},
|
||||
}),
|
||||
validator('form', EmailConfirmRequest, (r, c) =>
|
||||
r.success
|
||||
? undefined
|
||||
: c.json({ success: false, error: 'Enter the code from the email.' }, 400)
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const { code } = c.req.valid('form')
|
||||
|
||||
// Step 2, if it is the one out; else step 1.
|
||||
const verifying = (await pendingEmailCode(c.env.DB, 'email_verify', id)) !== null
|
||||
const purpose = verifying ? 'email_verify' : 'email_change'
|
||||
const result = await completeEmailCode(c.env.DB, purpose, id, code)
|
||||
if (result.outcome !== 'ok' || !result.payload) {
|
||||
logger.info('email change refused', { accountId: id, purpose, outcome: result.outcome })
|
||||
return c.json({ success: false, error: EMAIL_CODE_WRONG }, 400)
|
||||
}
|
||||
const email = result.payload
|
||||
const taken = { success: false, error: EMAIL_TAKEN }
|
||||
const holder = await getAccountByEmail(c.env.DB, email)
|
||||
if (holder !== null && holder.accountId !== id) return c.json(taken, 400)
|
||||
|
||||
if (!verifying) {
|
||||
// The current address has approved; now the new one proves itself. Forced past the
|
||||
// cooldown: step 1 just gated this, and the previous verify code (if any) went out
|
||||
// at least a step-1 cooldown ago.
|
||||
const next = await beginEmailCode(c.env.DB, 'email_verify', id, email, { force: true })
|
||||
// The day's cap can bite here too; the approval is spent, so say so plainly.
|
||||
if (next.refused) return c.json(emailCodeRefusal(next.refused), 400)
|
||||
const sent = await sendEmailCode(c, {
|
||||
purpose: 'email_verify',
|
||||
to: email,
|
||||
code: next.code,
|
||||
newEmail: email,
|
||||
currentEmail: currentEmail(await getAccount(c.env.DB, id)),
|
||||
})
|
||||
if (!sent) return c.json({ success: false, error: EMAIL_NOT_SENT }, 502)
|
||||
logger.info('email change approved by the current address', { accountId: id })
|
||||
return c.json({ success: true, applied: false, pending: email, sentTo: email })
|
||||
}
|
||||
|
||||
try {
|
||||
await updateAccount(c.env.DB, id, { email })
|
||||
} catch (err) {
|
||||
if (isUniqueEmailViolation(err)) return c.json(taken, 400)
|
||||
throw err
|
||||
}
|
||||
logger.info('email changed', { accountId: id })
|
||||
return c.json({ success: true, applied: true, pending: null, sentTo: null })
|
||||
}
|
||||
)
|
||||
|
||||
// Set the player's phone (persisted on the account row).
|
||||
.post(
|
||||
'/account/me/phone',
|
||||
|
||||
@@ -15,6 +15,24 @@ export type Env = SharedHonoEnv & {
|
||||
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
|
||||
// push AccountUpdate/SelfAccountUpdate notifications on profile mutations.
|
||||
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
||||
/**
|
||||
* Cloudflare Email Sending (`send_email` in wrangler.jsonc). The one thing this worker
|
||||
* mails is the code that confirms an email change (`POST /account/me/email`), from
|
||||
* `EMAIL_FROM` below (read through `emailSender`); `auth` binds the same for password
|
||||
* recovery. The sender's domain has to be onboarded onto Email Sending (see DEPLOYING.md)
|
||||
* or every send fails — reported to the player as the email not going out. Locally and
|
||||
* in tests the binding logs the message instead of sending it.
|
||||
*/
|
||||
EMAIL: SendEmail
|
||||
/** See `auth`: the address emails come from; unset means `no-reply@<DOMAIN>`. */
|
||||
EMAIL_FROM?: string
|
||||
/**
|
||||
* Base domain, e.g. `recflare.net`, injected for every worker at deploy time
|
||||
* (`--var DOMAIN`). The confirmation email links to `www.<domain>/confirm-email`.
|
||||
*/
|
||||
DOMAIN: string
|
||||
/** The same `SUBDOMAINS` JSON `ns` applies: a `www` override moves that link's host. */
|
||||
SUBDOMAINS: string
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* The email that confirms a change of address, as the message builder Cloudflare Email
|
||||
* Sending takes (`env.EMAIL.send(...)`). A pure function of its inputs, so the message can
|
||||
* be read in a test without a mail service in the way.
|
||||
*
|
||||
* Two different letters, one per step of the change (see `/account/me/email`):
|
||||
*
|
||||
* - To the CURRENT address, when the account has one: "someone signed in to your account
|
||||
* wants to change its email to X". The point of confirming at all — a stolen session
|
||||
* can't move the address, and with it the password recovery, somewhere else.
|
||||
* - To the NEW address, once the current one has approved (or at once, when there is none):
|
||||
* "confirm this is yours". Proves the address being added is reachable before it is
|
||||
* stored, so a typo never becomes the recovery address.
|
||||
*
|
||||
* Either way the code is printed and prefilled into a link to the website's confirm page,
|
||||
* which needs the account's own session to post it: the code approves the change, the
|
||||
* session says whose.
|
||||
*/
|
||||
|
||||
import { escapeHtml } from '@repo/domain'
|
||||
|
||||
/** The website's confirm page, which reads `code` off the query and wants a session. */
|
||||
export const CONFIRM_EMAIL_PATH = '/confirm-email'
|
||||
|
||||
export interface EmailChangeEmailInput {
|
||||
/** Where the email goes: the current address when there is one, else the new one. */
|
||||
to: string
|
||||
/** The address it comes from; must be on a domain enabled for Email Sending. */
|
||||
from: string
|
||||
/** The server's base domain, named as the sender and in the copy. */
|
||||
domain: string
|
||||
/** The website's origin (`https://www.<domain>`), which the confirm link is built on. */
|
||||
www: string
|
||||
/** The six-digit code, in plaintext — the only place it is written down. */
|
||||
code: string
|
||||
/** The address the account would change to. */
|
||||
newEmail: string
|
||||
/** The address this letter asks to give up, or null for the letter to the new address. */
|
||||
currentEmail: string | null
|
||||
/** How long the code is good for, in minutes, for the copy. */
|
||||
validMinutes: number
|
||||
}
|
||||
|
||||
/** The confirm link: the website's page with the code already filled in. */
|
||||
export function confirmEmailUrl(www: string, code: string): string {
|
||||
const url = new URL(CONFIRM_EMAIL_PATH, www)
|
||||
url.searchParams.set('code', code)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function emailChangeEmail(input: EmailChangeEmailInput) {
|
||||
const link = confirmEmailUrl(input.www, input.code)
|
||||
const changing = input.currentEmail !== null
|
||||
const subject = changing
|
||||
? `Confirm the new email address on your ${input.domain} account`
|
||||
: `Confirm your ${input.domain} email address`
|
||||
const opening = changing
|
||||
? `Someone signed in to your ${input.domain} account asked to change its email address from this one to ${input.newEmail}.`
|
||||
: `This address is being set on a ${input.domain} account. Confirm it to finish.`
|
||||
const closing = changing
|
||||
? `The code works once and for ${input.validMinutes} minutes. If you didn't ask for this, don't enter it — nothing changes without it, and this address stays on the account. Someone else may have your password: sign in and change it, or use "Forgot password".`
|
||||
: `The code works once and for ${input.validMinutes} minutes. If you didn't set this address on an account, ignore this email — nothing changes without the code.`
|
||||
const text = [
|
||||
opening,
|
||||
'',
|
||||
`Your code is: ${input.code}`,
|
||||
'',
|
||||
'Confirm it here (you will need to sign in):',
|
||||
link,
|
||||
'',
|
||||
closing,
|
||||
].join('\n')
|
||||
const html = [
|
||||
`<p>${escapeHtml(opening)}</p>`,
|
||||
`<p>Your code is: <strong style="font-size:1.5em;letter-spacing:0.15em">${input.code}</strong></p>`,
|
||||
`<p><a href="${escapeHtml(link)}">Confirm it here</a> (you will need to sign in).</p>`,
|
||||
`<p>${escapeHtml(closing)}</p>`,
|
||||
].join('\n')
|
||||
return {
|
||||
to: input.to,
|
||||
from: { email: input.from, name: input.domain },
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,38 @@ export const EmailRequest = z.object({
|
||||
.describe('A syntactically valid address (RFC 5321/5322, so at most 254); otherwise 400'),
|
||||
})
|
||||
|
||||
/** `POST /account/me/email/confirm` form body. */
|
||||
export const EmailConfirmRequest = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Enter the code from the email.')
|
||||
.describe('The six digits from the email'),
|
||||
})
|
||||
|
||||
/** `POST /account/me/email/confirm` response body. */
|
||||
export const EmailConfirmResponse = z.object({
|
||||
success: z.literal(true),
|
||||
applied: z.boolean().describe('Whether the change has landed, or a second code is now out'),
|
||||
pending: z.string().nullable().describe('The new address, while a second code is out'),
|
||||
sentTo: z.string().nullable().describe('Where that second code went (the new address)'),
|
||||
})
|
||||
|
||||
/** `GET /account/me/email/pending` response body. */
|
||||
export const PendingEmailResponse = z.object({
|
||||
pending: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('The address awaiting confirmation, or null when no change is in flight'),
|
||||
sentTo: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('Where the code went: the current address, or the new one when there was none'),
|
||||
})
|
||||
|
||||
/** The `{ success: false, error }` the email routes refuse with. */
|
||||
export const EmailRefusal = z.object({ success: z.literal(false), error: z.string() })
|
||||
|
||||
export const PhoneRequest = z.object({
|
||||
// No shape rule on purpose: the client sends E.164 (`+15552223333`), which the name
|
||||
// rule above would reject outright by eating the leading `+`.
|
||||
|
||||
@@ -4,7 +4,12 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import '../../accounts.app'
|
||||
|
||||
import { SCHEMA_DDL } from '@repo/domain'
|
||||
import {
|
||||
EMAIL_CODE_SCHEMA_DDL,
|
||||
hashPassword,
|
||||
RATE_LIMIT_SCHEMA_DDL,
|
||||
SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
import { WHITELISTED_EMOJIS } from '../../whitelisted-emojis'
|
||||
|
||||
@@ -22,6 +27,10 @@ beforeAll(async () => {
|
||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
||||
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// The codes an email change is confirmed with (owned by `auth`'s migrations).
|
||||
for (const stmt of EMAIL_CODE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// The counters that cap how many of those go out per account and day.
|
||||
for (const stmt of RATE_LIMIT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
const insert = env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
await env.DB.batch([
|
||||
insert.bind(JSON.stringify({ accountId: 0, username: 'RecRoom', displayName: 'Rec Room' })),
|
||||
@@ -668,18 +677,265 @@ describe('auth-gated endpoints', () => {
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
test('POST /account/me/email persists the email, surfaced by /account/me', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
|
||||
...form({ email: 'ners@recroom.com' }),
|
||||
/** POST an address for an account, as the website and the game do. */
|
||||
const postEmail = async (id: string, email: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/account/me/email`, {
|
||||
...form({ email }),
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('888')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
headers: { ...(await bearer(id)), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
/** POST a code to confirm the account's staged change. */
|
||||
const confirmEmail = async (id: string, code: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/account/me/email/confirm`, {
|
||||
...form({ code }),
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(id)), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
const pendingEmail = async (id: string) =>
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}/account/me/email/pending`, {
|
||||
headers: await bearer(id),
|
||||
})
|
||||
).json()) as { pending: string | null; sentTo: string | null }
|
||||
const emailOf = async (id: string) =>
|
||||
(
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer(id) })
|
||||
).json()) as { email: string }
|
||||
).email
|
||||
type Purpose = 'email_change' | 'email_verify'
|
||||
/** The staged step's row. Only the code's hash is stored; `plantCode` puts a known one in. */
|
||||
const stagedRow = (id: string, purpose: Purpose) =>
|
||||
env.DB.prepare(
|
||||
'SELECT code_hash AS codeHash, payload, created_at AS createdAt FROM email_code WHERE account_id = ?1 AND purpose = ?2'
|
||||
)
|
||||
.bind(Number(id), purpose)
|
||||
.first<{ codeHash: string | null; payload: string; createdAt: number }>()
|
||||
const plantCode = async (id: string, purpose: Purpose, code: string) => {
|
||||
await env.DB.prepare(
|
||||
'UPDATE email_code SET code_hash = ?2 WHERE account_id = ?1 AND purpose = ?3'
|
||||
)
|
||||
.bind(Number(id), await hashPassword(code), purpose)
|
||||
.run()
|
||||
}
|
||||
/** Set an address the way a player does, through every step the account needs. */
|
||||
const setEmail = async (id: string, email: string) => {
|
||||
const staged = await postEmail(id, email)
|
||||
if (staged.status !== 200) return staged
|
||||
if ((await pendingEmail(id)).sentTo !== email) {
|
||||
await plantCode(id, 'email_change', '100000')
|
||||
const approved = await confirmEmail(id, '100000')
|
||||
if (approved.status !== 200) return approved
|
||||
}
|
||||
await plantCode(id, 'email_verify', '100001')
|
||||
return confirmEmail(id, '100001')
|
||||
}
|
||||
|
||||
test('POST /account/me/email on an account with no address: one code, to the new address', async () => {
|
||||
const res = await postEmail('888', 'ners@recroom.com')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
|
||||
// The stored email is now returned by the self account (was a null stub).
|
||||
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('888') })
|
||||
expect(((await me.json()) as { email: string }).email).toBe('ners@recroom.com')
|
||||
// Staged, not applied: the self account still has no email, and the pending read says
|
||||
// where the code went — the NEW address, since there is nobody else to ask.
|
||||
expect(await emailOf('888')).toBe('')
|
||||
expect(await pendingEmail('888')).toEqual({
|
||||
pending: 'ners@recroom.com',
|
||||
sentTo: 'ners@recroom.com',
|
||||
})
|
||||
const row = await stagedRow('888', 'email_verify')
|
||||
expect(row?.payload).toBe('ners@recroom.com')
|
||||
expect(row?.codeHash).toMatch(/^[^:]+:[^:]+$/)
|
||||
expect(await stagedRow('888', 'email_change')).toBeNull()
|
||||
|
||||
await plantCode('888', 'email_verify', '123456')
|
||||
const ok = await confirmEmail('888', '123456')
|
||||
expect(ok.status).toBe(200)
|
||||
expect(await ok.json()).toEqual({ success: true, applied: true, pending: null, sentTo: null })
|
||||
expect(await emailOf('888')).toBe('ners@recroom.com')
|
||||
expect(await pendingEmail('888')).toEqual({ pending: null, sentTo: null })
|
||||
|
||||
// Spent: the same code again is refused.
|
||||
expect((await confirmEmail('888', '123456')).status).toBe(400)
|
||||
})
|
||||
|
||||
test('with no address on file, a mistyped address can be replaced at once; the same one waits', async () => {
|
||||
await postEmail('892', 'typo@exmaple.com')
|
||||
const first = await stagedRow('892', 'email_verify')
|
||||
|
||||
// A DIFFERENT address right away: a fresh code, to it.
|
||||
expect((await postEmail('892', 'right@example.com')).status).toBe(200)
|
||||
const second = await stagedRow('892', 'email_verify')
|
||||
expect(second?.payload).toBe('right@example.com')
|
||||
expect(second?.codeHash).not.toBe(first?.codeHash)
|
||||
expect((await pendingEmail('892')).sentTo).toBe('right@example.com')
|
||||
|
||||
// The SAME address again within the cooldown is a repeat, and refused.
|
||||
const repeat = await postEmail('892', 'right@example.com')
|
||||
expect(repeat.status).toBe(400)
|
||||
expect(((await repeat.json()) as { error: string }).error).toMatch(/less than two minutes/)
|
||||
expect(await stagedRow('892', 'email_verify')).toEqual(second)
|
||||
})
|
||||
|
||||
test('with an address on file, the current address approves and then the new one confirms', async () => {
|
||||
expect((await setEmail('889', 'first@example.com')).status).toBe(200)
|
||||
expect(await emailOf('889')).toBe('first@example.com')
|
||||
|
||||
// Step 1: the code goes to the address being given up, not the new one.
|
||||
expect((await postEmail('889', 'second@example.com')).status).toBe(200)
|
||||
expect(await pendingEmail('889')).toEqual({
|
||||
pending: 'second@example.com',
|
||||
sentTo: 'first@example.com',
|
||||
})
|
||||
expect(await stagedRow('889', 'email_verify')).toBeNull()
|
||||
expect(await emailOf('889')).toBe('first@example.com')
|
||||
|
||||
// Approving it applies nothing yet: step 2's code goes to the new address.
|
||||
await plantCode('889', 'email_change', '111111')
|
||||
const approved = await confirmEmail('889', '111111')
|
||||
expect(approved.status).toBe(200)
|
||||
expect(await approved.json()).toEqual({
|
||||
success: true,
|
||||
applied: false,
|
||||
pending: 'second@example.com',
|
||||
sentTo: 'second@example.com',
|
||||
})
|
||||
expect(await stagedRow('889', 'email_change')).toBeNull()
|
||||
expect(await pendingEmail('889')).toEqual({
|
||||
pending: 'second@example.com',
|
||||
sentTo: 'second@example.com',
|
||||
})
|
||||
expect(await emailOf('889')).toBe('first@example.com')
|
||||
|
||||
// Step 2 lands it.
|
||||
await plantCode('889', 'email_verify', '222222')
|
||||
const done = await confirmEmail('889', '222222')
|
||||
expect(await done.json()).toEqual({ success: true, applied: true, pending: null, sentTo: null })
|
||||
expect(await emailOf('889')).toBe('second@example.com')
|
||||
})
|
||||
|
||||
test('a wrong code at either step voids the change; a new attempt starts again at step 1', async () => {
|
||||
expect((await setEmail('893', 'keep@example.com')).status).toBe(200)
|
||||
|
||||
// Wrong at step 1: burned, and the right code no longer works.
|
||||
await postEmail('893', 'next@example.com')
|
||||
await plantCode('893', 'email_change', '333333')
|
||||
const wrong = await confirmEmail('893', '999999')
|
||||
expect(wrong.status).toBe(400)
|
||||
expect(await wrong.json()).toEqual({
|
||||
success: false,
|
||||
error: 'That code is wrong or has expired. Save the address again to be sent a new one.',
|
||||
})
|
||||
expect((await stagedRow('893', 'email_change'))?.codeHash).toBeNull()
|
||||
expect(await pendingEmail('893')).toEqual({ pending: null, sentTo: null })
|
||||
expect((await confirmEmail('893', '333333')).status).toBe(400)
|
||||
|
||||
// Past the cooldown, try again: step 1 again, then wrong at step 2.
|
||||
await env.DB.prepare(
|
||||
"UPDATE email_code SET created_at = created_at - 600 WHERE account_id = 893 AND purpose = 'email_change'"
|
||||
).run()
|
||||
expect((await postEmail('893', 'next@example.com')).status).toBe(200)
|
||||
await plantCode('893', 'email_change', '444444')
|
||||
expect((await confirmEmail('893', '444444')).status).toBe(200)
|
||||
await plantCode('893', 'email_verify', '555555')
|
||||
expect((await confirmEmail('893', '000000')).status).toBe(400)
|
||||
expect((await stagedRow('893', 'email_verify'))?.codeHash).toBeNull()
|
||||
expect((await confirmEmail('893', '555555')).status).toBe(400)
|
||||
expect(await emailOf('893')).toBe('keep@example.com')
|
||||
|
||||
// A fresh attempt (cooldown passed) starts at step 1 again, whatever the address: the
|
||||
// current address has approved nothing yet.
|
||||
await env.DB.prepare(
|
||||
"UPDATE email_code SET created_at = created_at - 600 WHERE account_id = 893 AND purpose = 'email_change'"
|
||||
).run()
|
||||
expect((await postEmail('893', 'other@example.com')).status).toBe(200)
|
||||
expect((await pendingEmail('893')).sentTo).toBe('keep@example.com')
|
||||
expect(await stagedRow('893', 'email_verify')).toBeNull()
|
||||
})
|
||||
|
||||
test('with an address on file, a second attempt within the cooldown is refused whatever the address, and re-saving the same address applies at once', async () => {
|
||||
expect((await setEmail('890', 'cool@example.com')).status).toBe(200)
|
||||
await postEmail('890', 'cooler@example.com')
|
||||
const first = await stagedRow('890', 'email_change')
|
||||
// A different new address is still the current inbox being written to: refused.
|
||||
const again = await postEmail('890', 'coolest@example.com')
|
||||
expect(again.status).toBe(400)
|
||||
expect(((await again.json()) as { error: string }).error).toMatch(/less than two minutes/)
|
||||
expect(await stagedRow('890', 'email_change')).toEqual(first)
|
||||
|
||||
// Re-saving the current address in another case: no code, applied directly.
|
||||
expect((await postEmail('890', 'COOL@example.com')).status).toBe(200)
|
||||
expect(await emailOf('890')).toBe('COOL@example.com')
|
||||
})
|
||||
|
||||
test('POST /account/me/email sends an account at most five codes a day, typos included', async () => {
|
||||
// With no address on file every different address gets a code at once — until the
|
||||
// day's five are spent, when the sixth is refused with a reason, not silently.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect((await postEmail('894', `try${i}@example.com`)).status).toBe(200)
|
||||
}
|
||||
const sixth = await postEmail('894', 'try5@example.com')
|
||||
expect(sixth.status).toBe(400)
|
||||
expect(((await sixth.json()) as { error: string }).error).toMatch(/today/)
|
||||
// The fifth is still the one staged, and still works.
|
||||
expect((await pendingEmail('894')).pending).toBe('try4@example.com')
|
||||
await plantCode('894', 'email_verify', '123456')
|
||||
expect((await confirmEmail('894', '123456')).status).toBe(200)
|
||||
expect(await emailOf('894')).toBe('try4@example.com')
|
||||
})
|
||||
|
||||
test('POST /account/me/email/confirm refuses an empty code and a code with nothing staged', async () => {
|
||||
const empty = await confirmEmail('891', '')
|
||||
expect(empty.status).toBe(400)
|
||||
const none = await confirmEmail('891', '123456')
|
||||
expect(none.status).toBe(400)
|
||||
expect(await none.json()).toEqual({
|
||||
success: false,
|
||||
error: 'That code is wrong or has expired. Save the address again to be sent a new one.',
|
||||
})
|
||||
})
|
||||
|
||||
test('the email routes 401 without a token', async () => {
|
||||
const pending = await exports.default.fetch(`${ORIGIN}/account/me/email/pending`)
|
||||
expect(pending.status).toBe(401)
|
||||
// A well-formed body, so the refusal is for the missing token and not the form.
|
||||
const confirm = await exports.default.fetch(`${ORIGIN}/account/me/email/confirm`, {
|
||||
...form({ code: '123456' }),
|
||||
method: 'POST',
|
||||
})
|
||||
expect(confirm.status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /account/me/email refuses an address another account holds, case-insensitively', async () => {
|
||||
expect((await setEmail('886', 'shared@example.com')).status).toBe(200)
|
||||
|
||||
// Another account, same address in different case: refused with a sentence the
|
||||
// website shows — a 400 like the malformed-address refusal, but with a body.
|
||||
const taken = await postEmail('887', 'Shared@Example.com')
|
||||
expect(taken.status).toBe(400)
|
||||
expect(await taken.json()).toEqual({
|
||||
success: false,
|
||||
error: 'That email address is already on another account.',
|
||||
})
|
||||
expect(await emailOf('887')).toBe('')
|
||||
|
||||
// The holder may save it again (a no-op), and may change case — no code either way.
|
||||
expect((await postEmail('886', 'shared@example.com')).status).toBe(200)
|
||||
expect((await postEmail('886', 'SHARED@example.com')).status).toBe(200)
|
||||
expect(await emailOf('886')).toBe('SHARED@example.com')
|
||||
|
||||
// A change that was staged while the address was free is refused as it lands, if
|
||||
// another account took the address meanwhile.
|
||||
expect((await postEmail('887', 'contested@example.com')).status).toBe(200)
|
||||
expect((await setEmail('886', 'contested@example.com')).status).toBe(200)
|
||||
await plantCode('887', 'email_verify', '100002')
|
||||
const late = await confirmEmail('887', '100002')
|
||||
expect(late.status).toBe(400)
|
||||
expect(((await late.json()) as { error: string }).error).toMatch(/already on another account/)
|
||||
|
||||
// Once the holder moves on, the address is free.
|
||||
expect((await setEmail('886', 'moved@example.com')).status).toBe(200)
|
||||
expect((await setEmail('887', 'shared@example.com')).status).toBe(200)
|
||||
})
|
||||
|
||||
test('GET /openapi.json documents every route', async () => {
|
||||
@@ -706,6 +962,7 @@ describe('auth-gated endpoints', () => {
|
||||
'GET /',
|
||||
'GET /account/bulk',
|
||||
'GET /account/me',
|
||||
'GET /account/me/email/pending',
|
||||
'GET /account/search',
|
||||
'GET /account/username/{name}',
|
||||
'GET /account/{id}',
|
||||
@@ -716,6 +973,7 @@ describe('auth-gated endpoints', () => {
|
||||
'POST /account/bulk',
|
||||
'POST /account/create',
|
||||
'POST /account/me/email',
|
||||
'POST /account/me/email/confirm',
|
||||
'POST /account/me/phone',
|
||||
'PUT /account/me/bannerimage',
|
||||
'PUT /account/me/bio',
|
||||
@@ -871,16 +1129,17 @@ describe('name, email and bio validation', () => {
|
||||
}
|
||||
|
||||
// `someone@localhost` is in the ACCEPTED list on purpose: it's valid per the RFC,
|
||||
// and an undeliverable address costs nothing here.
|
||||
for (const email of [
|
||||
// and an undeliverable address costs nothing here. One account per address: a second
|
||||
// save on the same account within two minutes is refused for the cooldown, not the syntax.
|
||||
for (const [i, email] of [
|
||||
'someone@example.com',
|
||||
'first.last+tag@mail.example.co.uk',
|
||||
'someone@localhost',
|
||||
]) {
|
||||
].entries()) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
|
||||
...form({ email }),
|
||||
method: 'POST',
|
||||
headers,
|
||||
headers: await authed(`881${i}`),
|
||||
})
|
||||
expect(res.status, email).toBe(200)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user