[www] password/email recovery (#146)

* pw/email recovery

* fix migration
This commit is contained in:
devin
2026-10-27 23:49:52 -04:00
committed by GitHub
parent fd30182f3d
commit cc14c208b2
31 changed files with 4104 additions and 539 deletions
+196 -1
View File
@@ -4,11 +4,16 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { z } from 'zod'
import {
beginEmailCode,
completeEmailCode,
countAccountsByDeviceId,
countAccountsBySignupIp,
createAccount,
EMAIL_CODE_TTL_SECONDS,
emailSender,
GAME_VERSION,
getAccount,
getAccountByEmail,
getAccountByUsername,
getAccountsByIds,
getPasswordHash,
@@ -16,6 +21,7 @@ import {
hashPassword,
isRolloutGameVersion,
isSupportedGameVersion,
isValidEmail,
rolloutGameBuilds,
RoomInstanceType,
setLastLoginTime,
@@ -24,6 +30,7 @@ import {
setPresence,
subRoomDataBlob,
supportedGameBuilds,
takeRateLimit,
updateAccount,
verifyPassword,
writeAuditLog,
@@ -63,6 +70,9 @@ import {
OAuthError,
PlatformIdsRequest,
PlatformType,
RecoverPasswordRequest,
RecoverPasswordResponse,
ResetPasswordRequest,
RestrictionDto,
roleFilter,
roleLookup,
@@ -80,7 +90,8 @@ import {
linkPlatformIdentity,
parseRoleIdList,
} from './platform-db'
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
import { recoveryEmail } from './recovery-email'
import { consumeRefreshToken, issueRefreshToken, revokeRefreshTokens } from './refresh-db'
import { verifySteamTicket } from './steam-ticket'
import {
approveDeviceGrant,
@@ -116,6 +127,19 @@ const TOKEN_SCOPE =
*/
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
/**
* Password-recovery requests one IP may make per hour (`POST /account/recoverpassword`),
* whatever addresses they name and whether or not any is on an account. The route is
* unauthenticated and answers every address the same, so this is what stops one source
* from working through a list of addresses — the per-account daily cap on codes
* (@repo/domain's email-code-db.ts) is what protects each mailbox. Past the cap a request
* is answered as if it had succeeded and does nothing; generous enough that a household
* behind one address is never caught by it. A request with no `CF-Connecting-IP` (not a
* thing at the edge; tests) is not counted.
*/
const RECOVERY_REQUESTS_PER_IP = 10
const RECOVERY_REQUESTS_PER_IP_WINDOW_SECONDS = 60 * 60
/**
* The website's "Sign in with Discord". `www` completes the Discord OAuth2 exchange (it
* alone holds the client secret), then posts an `assertion` — an HS256 token signed with
@@ -1827,6 +1851,177 @@ const app = new Hono<App>()
}
)
// "Forgot password" — the game posts the address on the account and nothing else, so the
// address is how the account is found (which is why it is unique across accounts). A
// six-digit code is emailed, typed out and prefilled into a link to the website's
// `/reset-password` page, where the new password is chosen (`/account/resetpassword`
// below): the game has no screen to finish a recovery on.
//
// Unauthenticated, and so answered the same way whether or not the address is on an
// account: `Success: true`, "we've sent an email if there is one". The route is otherwise
// a way to ask which addresses have accounts here. The refusals it does voice are the
// ones the player can act on — no address, a malformed one, the mail not going out —
// in the client's PascalCase `{ Success, Error, error_id }` envelope, which is the one
// shape it has been seen to read for this route. See @repo/domain's email-code-db.ts for the code's
// rules (one attempt, a cooldown on resends, a 30-minute life).
.post(
'/account/recoverpassword',
describeRoute({
tags: ['Account'],
summary: 'Email a password-recovery code',
description: [
'Emails a six-digit code to the address, with a link to the website’s reset page',
'(`/reset-password?email=…&code=…`) where the new password is chosen via',
'`POST /account/resetpassword`. Answers `Success: true` whether or not the address',
'is on an account, so the route can’t be used to find out which addresses are.',
'A new request replaces the pending code; one is sent per address at most every',
'two minutes. The code works once: a wrong guess voids it and the recovery has to',
'be requested again. Refusals — no address, a malformed one, the email failing to',
'send — are `Success: false` with the reason in `Error`.',
].join(' '),
requestBody: form(RecoverPasswordRequest, 'The address on the account'),
responses: {
200: json(RecoverPasswordResponse, 'Sent, or nothing to send to; `Error` says if not'),
},
}),
async (c) => {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const email = typeof body.email === 'string' ? body.email.trim() : ''
const refuse = (error: string) => c.json({ Success: false, Error: error, error_id: null })
if (email === '') return refuse('Enter the email address on your account.')
if (!isValidEmail(email)) return refuse("That doesn't look like an email address.")
// Counted before the lookup, so a run through unknown addresses spends it too.
const clientIp = c.req.header('cf-connecting-ip') ?? ''
if (clientIp !== '') {
const ip = await takeRateLimit(
c.env.DB,
`recoverpassword:ip:${clientIp}`,
RECOVERY_REQUESTS_PER_IP,
RECOVERY_REQUESTS_PER_IP_WINDOW_SECONDS
)
if (!ip.allowed) {
logger.warn('password recovery rate-limited by ip; not sent', { count: ip.count })
return c.json({ Success: true, Error: null, error_id: null })
}
}
const account = await getAccountByEmail(c.env.DB, email)
if (account === null) {
// Answered exactly like a sent email, see above. Logged, since a run of these
// from one place is somebody probing.
logger.info('password recovery requested for an address on no account')
return c.json({ Success: true, Error: null, error_id: null })
}
const issue = await beginEmailCode(c.env.DB, 'password_reset', account.accountId)
if (issue.refused) {
// Within the cooldown, or the account's codes for the day are spent: answered
// like a sent email either way, so a loop learns nothing from the refusal.
logger.info('password recovery not resent', {
accountId: account.accountId,
refused: issue.refused,
})
return c.json({ Success: true, Error: null, error_id: null })
}
const code = issue.code
const message = recoveryEmail({
// The stored address, not the posted one: same account either way (the lookup
// is case-insensitive), and the stored one is the one the player chose to be
// reached at.
to: account.email ?? email,
from: emailSender(c.env),
domain: c.env.DOMAIN,
www: buildEndpoints(c.env.DOMAIN, c.env.SUBDOMAINS).WWW,
code,
validMinutes: Math.round(EMAIL_CODE_TTL_SECONDS / 60),
})
try {
await c.env.EMAIL.send(message)
} catch (err) {
// Told to the player: they would otherwise wait on an email that is never
// coming. The usual cause is the sender's domain not being enabled for Email
// Sending (see DEPLOYING.md), which is an operator's problem, hence the log.
logger.error('could not send the password recovery email', {
accountId: account.accountId,
error: err instanceof Error ? err.message : String(err),
})
return refuse('The recovery email could not be sent. Please try again later.')
}
logger.info('password recovery email sent', { accountId: account.accountId })
return c.json({ Success: true, Error: null, error_id: null })
}
)
// The other half: the website's `/reset-password` page posts the address, the code from
// the email and the new password. Unauthenticated — the code IS the credential. A wrong
// code voids the pending recovery (one guess per email), and the refusal reads the same
// whether the code was wrong, lapsed or never issued, so nothing here says whether a
// recovery is in flight for an address. Success sets the password and signs the account
// out everywhere else by revoking its refresh tokens. This is our own route, not the
// client's: the game never calls it, so it answers in the `{ success, error }` shape the
// website's other forms read.
.post(
'/account/resetpassword',
describeRoute({
tags: ['Account'],
summary: 'Finish a password recovery with the emailed code',
description: [
'Sets the password of the account under `email` when `code` is the one its recovery',
'email carried. Unauthenticated; the code is the credential, and it works ONCE — a',
'wrong code voids the recovery, and `POST /account/recoverpassword` has to be posted',
'again for a new one. The refusal is the same sentence whether the code was wrong,',
'has lapsed (30 minutes) or was never issued. On success the account’s refresh',
'tokens are revoked, so other sessions sign out. Posted by the website’s',
'`/reset-password` page, never by the game.',
].join(' '),
requestBody: form(ResetPasswordRequest, 'Address, emailed code and the new password'),
responses: {
200: json(ChangePasswordResponse, 'Password set'),
400: json(
ChangePasswordResponse,
'Missing field, or the code is wrong, lapsed or not issued (`error` says which, if the player can act on it)'
),
},
}),
async (c) => {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const email = typeof body.email === 'string' ? body.email.trim() : ''
const code = typeof body.code === 'string' ? body.code.trim() : ''
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : ''
const refuse = (error: string) => c.json({ success: false, error }, 400)
if (email === '') return refuse('Enter the email address the code was sent to.')
if (code === '') return refuse('Enter the code from the email.')
if (newPassword === '') return refuse('You must enter a new password.')
const WRONG_CODE =
'That code is wrong or has expired. Request a new recovery email and try again.'
const account = await getAccountByEmail(c.env.DB, email)
if (account === null) {
logger.info('password reset posted for an address on no account')
return refuse(WRONG_CODE)
}
const { outcome } = await completeEmailCode(
c.env.DB,
'password_reset',
account.accountId,
code
)
if (outcome !== 'ok') {
logger.info('password reset refused', { accountId: account.accountId, outcome })
return refuse(WRONG_CODE)
}
const ok = await setPasswordHash(c.env.DB, account.accountId, await hashPassword(newPassword))
if (!ok) return c.body(null, 404)
const revoked = await revokeRefreshTokens(c.env.DB, account.accountId)
logger.info('password reset by email', { accountId: account.accountId, revoked })
return c.json({ success: true })
}
)
// The moderation restrictions on the caller's account — chat mutes and the like. STUB:
// nothing here issues restrictions, so every caller is unrestricted and the list is
// empty. An EMPTY ARRAY is the normal unrestricted answer, not null and not a 404: the