后端注册时验证邮箱

注册时验证邮箱

打开「注册必须验证邮箱」后,验证变成一次独立的服务端动作:验码通过即落一条一次性证明,注册只检查本次请求带没带它。页面代码里没有验证码参数,也没有「已验证」这个布尔值。

站点的注册接口本来不校验邮箱emailname 一样,是一个可选的展示字段。 所以在页面里写「先发码、验过了再调注册」,那个顺序完全在浏览器的控制之下——跳过前两步直接调注册, 不需要任何技巧。要让「验证过」真的拦住注册,得由服务端在建号的同一次调用里检查。

平台的做法是把验证做成一次独立的服务端动作:验码通过后服务端记下一条一次性「证明」, 票据放进 httpOnly cookie;注册时只检查本次请求带没带项目策略要求的证明。注册的调用签名里没有验证码参数

先打开开关

在编辑器的 Auth → 设置 & OAuth 里打开「注册必须验证邮箱」。 它需要项目已经接入可用的邮件集成(见 使用集成发送邮件与验证码), 否则打开等于把注册关死,平台会直接拒绝并指向集成面板。

没打开这个开关的项目,行为与以前完全一致:注册不需要验证码, user.email_verified_at 恒为 null。所以不要把这个字段当成「邮箱填了没有」来用。

页面代码:三步

需要 talizen 客户端 0.2.35 及以上。

import { useAuth, startVerification, confirmVerification } from 'talizen/auth'

const { register } = useAuth()

// ① 发码
await startVerification({ channel: 'email', to: email, purpose: 'register' })

// ② 验码:通过后证明落在服务端,浏览器只拿到一个不可伪造的票据(httpOnly cookie)
await confirmVerification({ channel: 'email', to: email, purpose: 'register', code })

// ③ 注册:没有 code 参数,也不需要传票据——浏览器自动带上,服务端按项目策略校验
await register({ account: email, email, password })

票据你读不到也不需要读。页面代码里不存在「验证过了」这个布尔值,因此也就没有「忘记检查」这回事。

证明的四条规则

规则为什么
绑定收件人:注册时填的 email 必须与验过的地址逐字相同否则攻击者验证自己的邮箱、注册时填别人的,前面全部白做
绑定用途:purpose 不同的证明不能互用为「订阅邮件列表」索取的码不该能完成一次注册
一次性:注册成功即消费掉同一次验证不该完成两件事
10 分钟过期够走完「填密码、提交」,短到捡到票据也没用

channel 目前只支持 email;写 sms 会明确报错,而不是悄悄换成邮件发出去。

站点自己的规则:邀请码、域名白名单

邮箱验证由平台强制,但邀请码、@company.com 域名白名单、注册赠额这类规则平台管不了。 写在页面代码里它们只是建议——攻击者直接调注册接口就绕过去了。要让它们真的生效, 在同一个设置面板里打开「禁止前端方式注册」,然后把注册放进 Func:

import type { TalizenFuncContext } from 'talizen/func-runtime'

export async function complete(input, ctx: TalizenFuncContext) {
  const invite = ctx.db.get('invites', input.invite)
  if (!invite || invite.used) return { ok: false, reason: 'bad_invite' }

  // 同样没有 code / proof 参数:证明由平台从本次请求里读,Func 代码碰不到票据
  const user = ctx.auth.register({
    account: input.email,
    email: input.email,
    password: input.password,
    profile: { invited_by: invite.owner },
  })

  ctx.db.update('invites', invite.id, { used: true, used_by: user.id })
  return { ok: true }
}

打开之后,页面代码直接调注册会收到 403,注册只能走你的 Func。发码/验码在 Func 里对应 ctx.verify.start()ctx.verify.confirm();注册成功后的会话 cookie 由平台下发, Func 没有签发登录态或直接改密码的能力

与 ctx.email.sendCode 的分界

两套东西看起来都在「发验证码」,区别是结果由谁记录

用途结果
ctx.email.sendCode / verifyCode站点自用:下单确认、退订确认只回到你的代码里,平台不记录任何东西
startVerification / ctx.verify身份证明平台记录,并被注册消费

所以不要ctx.email.verifyCode() 去做注册前的把关:它返回的 true 只是一个值,服务端不会因此认为这个邮箱被证明过。

注册之后

验证过的注册会写下 user.email_verified_at。想让未验证的老用户功能受限, 在 Func 里判断它,不要在登录时强制——那会把存量用户全部锁在门外:

export async function createPost(input, ctx: TalizenFuncContext) {
  const user = ctx.auth.requireUser()
  if (!user.email_verified_at) return { ok: false, reason: 'verify_email_first' }
  // ...
}

已经注册过的邮箱

访客用一个已经有账号的邮箱申请注册时,默认行为是:接口返回与正常情况 完全一致的结果,但那个地址收到的邮件换成「你已经注册过了,直接登录」,而不是验证码。

这不是漏洞,是刻意的。开启验证之后 register 已经不是枚举口(拿不到证明就走不到重复检查), 发码接口成了唯一的探测面;如果它对「已占用」返回不同的结果,任何人都能拿它逐个探测 哪些邮箱在你的站点上有账号。攻击者看不到那封邮件,所以枚举不到;真实用户又不会拿到一个注册不成的码。

所以不要在页面代码里自己加一个「这个邮箱是不是已被占用」的接口去做前置检查—— 那正是默认行为要挡住的东西。

不在乎枚举的站点(内部工具、B 端后台)可以在 Auth → 设置 里打开 「提示邮箱已被注册」:此时 startVerification 直接返回 409, 体验更好、也不白发一封信。

错误与边界

情况行为
没验证就注册 / 票据过期403,提示需要先完成验证
注册填的邮箱与验过的不一致403,明确说不匹配
验证码不对、已过期、根本没发过统一 400,不区分——否则接口成了枚举探测面
同一收件人频繁索取验证码429。与集成里的限频、每日上限共用同一套计数
项目没接邮件集成就打开开关保存被拒绝,指向集成面板
开着「禁止前端方式注册」时页面直接调注册403,指向你自己的 Func

验证码本身的位数、有效期、猜错上限等由集成配置,见 使用集成发送邮件与验证码;Func 的其余能力见 使用 Func 构建站点后端能力

Render diagnostics