API
用户与登录注册时验证邮箱

注册时验证邮箱

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

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

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

先打开开关

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

没打开这个开关的项目,行为与以前完全一致:注册不需要验证码。 这个开关只作用于页面代码发起的注册;注册方式选「仅后端函数」时它不会出现, 因为那条路的校验全在你的 Func 代码里。

页面代码:三步

需要 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。

改成这个模式之后,页面代码直接调注册、以及直接调发码接口,都会收到 403:注册只能走你的 Func。 此时校验完全由你的代码执行,平台不做检查——所以面板上也不再有「注册必须验证邮箱」 这个开关,没有需要配置的东西。验不过就别往下调 register,顺序就是代码本身:

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

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

  ctx.verify.start({ channel: 'email', to: input.email, purpose: 'register' })
  return { ok: true }
}

export function complete(input, ctx: TalizenFuncContext) {
  // 这是另一次请求,sendCode 通过了不代表这次也通过,必须重查
  const invite = ctx.db.get('invites', input.invite)
  if (!invite || invite.used) return { ok: false, reason: 'bad_invite' }

  // 先验码。Func 里 confirm 只返回布尔,没有票据也没有 cookie
  if (!ctx.verify.confirm({
    channel: 'email',
    to: input.email,
    purpose: 'register',
    code: input.code,
  })) return { ok: false, reason: 'bad_code' }

  // 走到这里就说明验过了。register 的签名里没有 code、没有票据、也没有「已验证」参数
  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 }
}

注册成功后的会话 cookie 仍由平台下发,Func 没有签发登录态的能力。改密码是另一件事,Func 可以做——见 找回密码与修改密码

与 ctx.email.sendCode 的分界

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

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

页面代码发起的注册这条路上,ctx.email.verifyCode()true 起不到把关作用——注册消费的是平台记录的那条证明。注册收口到 Func 时两者都能用来把关(平台都不检查), 但注册仍应优先用 ctx.verify:只有它会顺带做邮箱占用检查。

注册之后

平台不会在用户上留下「这个人验过邮箱」的标记——没有 email_verified_at 这样的字段,也不要自己建一个来模仿它。验证是注册时的一个步骤,不是用户身上的一个状态位。

原因是这个标记没有可用的信息量:开了验证的项目,每个账号都验过;没开的项目,一个都没验过。 它只在「项目中途打开验证」这一种情况下才区分得出新旧账号,而那种情况下真正该做的是给存量用户 补一次验证流程,不是读一个字段去限制他们。

确实需要按用户区分权限时,把它当业务状态放进自己的表(例如 members.status), 由你的 Func 来写和判断——那样它的含义由你定义,也不会被误当成平台的保证。

已经注册过的邮箱

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

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

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

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

错误与边界

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

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

Render diagnostics