# 注册时验证邮箱｜Creght

> 用 startVerification / confirmVerification 证明邮箱归属，注册由平台按项目策略强制校验；需要邀请码、域名白名单等自有规则时把注册收口到 Func。

[![Creght](https://ugc.talizen.com/_assets/site/2061660904709165056/1780797461299__creght_logo.png)API for AI](/)

[查看 llms.txt](/llms.txt)

概览

- [Creght AI 编程指南](/api.md)

AI 可发现性

- [如何优化 llms.txt](/api/optimize-llms-txt.md)

站点配置

- [实现基于域名的多语言路由](/api/domain-locale-routing.md)

后端

- [使用 Func 构建站点后端能力](/api/func-backend.md)
- [使用 Func 接入支付宝电脑网站支付](/api/func-alipay-payment.md)
- [注册时验证邮箱](/api/auth-verified-registration.md)

集成

- [使用集成发送邮件与验证码](/api/func-email-integration.md)

本页目录

- [先打开开关](#switch)
- [页面代码：三步](#page)
- [证明的四条规则](#rules)
- [站点自己的规则：邀请码、域名白名单](#func)
- [与 ctx.email.sendCode 的分界](#boundary)
- [注册之后](#after)
- [已经注册过的邮箱](#taken)
- [错误与边界](#errors)

后端/注册时验证邮箱

# 注册时验证邮箱

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

复制 Markdown 链接

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

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

## 先打开开关

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

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

## 页面代码：三步

需要 `talizen` 客户端 0.2.35 及以上。

```typescript
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：

```typescript
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 没有签发登录态或直接改密码的能力**。

**不要在 Func 里先验后注册**

不要写
「 `ctx.verify.confirm()` 拿到 `true`，然后自己判断要不要建号」这种两段式。
证明由平台在 `ctx.auth.register()` 里消费，你手上没有码也不需要有——两处校验必然漂移，
而漂移的表现是「其中一条路还能绕」，没有任何症状。

## 与 ctx.email.sendCode 的分界

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

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

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

## 注册之后

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

```typescript
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 |

验证码本身的位数、有效期、猜错上限等由集成配置，见
[使用集成发送邮件与验证码](/api/func-email-integration.md)；Func 的其余能力见
[使用 Func 构建站点后端能力](/api/func-backend.md)。

![Creght](https://ugc.talizen.com/_assets/site/2061660904709165056/1780797461299__creght_logo.png)

此网站使用 [Creght](/) 创建

![微信客服](https://fsu.creght.com/site/2066727200882692096/1785119134612__image.png)

微信客服

## 链接

- [价格](/price.md)
- [解决方案](/solution.md)
- [客户案例](/customers.md)
- [帮助中心](/help.md)
- [联系我们](/contact.md)
- [更新记录 & 博客](/blogs.md)
- [退款说明](/tuikuan.md)

## 资源

- [全部资源](/resources.md)
- [模板](/templates.md)
- [组件库](https://creghtlib.site.creght.com)
- [动效库](/effects.md)
- [Figma to Creght](/figma2creght.md)
- [API](/api.md)

## 产品对比

- [对比上线了](/creght-vs-sxl.md)
- [对比凡科建站](/creght-vs-fkw.md)
- [自己写代码 vs Creght](/compare/self-coding.md)
- [外包 vs 自己做](/compare/outsourcing.md)

## 协议

- [用户协议](/legal/terms.md)
- [隐私政策](/legal/privacy.md)
- [可接受使用政策](/legal/acceptable-use.md)

## 社交媒体

- [小红书](https://www.xiaohongshu.com/user/profile/5a38606811be10715f4895b6)
- [哔哩哔哩](https://space.bilibili.com/513308095)

[蜀ICP备2023038192号-2](https://beian.miit.gov.cn)
