登录

基于 better-auth 的无密码登录:Google 与 One Tap、可选的 GitHub、邮件验证码,以及会话、认证配置规则、账号删除与数据导出。

ShipKit 用 better-auth 处理登录,全程没有密码:没有重置密码流程,没有注册/登录之分,也就没有密码可以泄露。一共三种登录方式,用其中任何一种第一次登录时都会自动创建账号。

方式是否必需需要什么
Google(跳转 + One Tap)必需GOOGLE_CLIENT_ID、GOOGLE_CLIENT_SECRET,以及 appConfig.googleClientId 中的 client id
GitHub可选GITHUB_CLIENT_ID 和 GITHUB_CLIENT_SECRET,要么都填,要么都不填
邮件验证码始终开启真正发信需要 RESEND_API_KEY;没有它时验证码会打印在服务端日志里

服务端配置在 src/features/auth/server/auth.ts,客户端在 src/features/auth/client.ts。/login 页面和页内登录弹窗共用同一个登录面板:src/features/auth/components/login-panel.tsx。

Google 与 One Tap

Google 是基础登录方式,所以它的两个环境变量是必填的:缺了它们,src/core/env.ts 会让应用直接启动失败。/google-oauth skill 会带你走完 Google Cloud 控制台的配置,要点如下:

  • 创建类型为 Web application 的 OAuth 客户端。
  • 重定向 URI:<APP_URL>/api/auth/callback/google。
  • JavaScript 来源:<APP_URL>。本地开发时 http://localhost:3000 和 http://localhost 都要加,One Tap 需要不带端口的那一条。
  • 把 id 和 secret 写进 .dev.vars,并把同一个 client id 填到 src/config/app-config.ts 的 googleClientId(它分开发和生产两个值)。

One Tap 是未登录访客在页面角落看到的 Google 登录提示。它挂在营销页布局和登录面板上(src/features/auth/components/one-tap.tsx),不用离开当前页面就能完成登录。它校验的是 JavaScript 来源而不是重定向 URI,所以如果 Google 按钮能用、One Tap 却不出现,多半是少配了来源。另外,用户关掉提示后 Google 会冷却一段时间不再弹出,没看到提示不一定是 bug。

GitHub

在 GitHub 创建一个 OAuth app,回调地址填 <APP_URL>/api/auth/callback/github,然后设置两个变量。都设了,Google 旁边就会出现“使用 GitHub 继续”按钮;都不设,按钮根本不会渲染。登录界面会向服务端询问配置了哪些方式(src/features/auth/server/fns.ts 里的 getAuthProvidersFn),因此按钮和实际可用的登录方式不会不一致。

邮件验证码

邮箱表单会发送一个 6 位验证码,10 分钟内有效。验证码以哈希形式保存,输错三次即作废。发送请求在进入 better-auth 之前先经过限流(src/routes/api/auth.$.ts),基于 PUBLIC_RATE_LIMIT 绑定,按 IP 和按邮箱地址各限一次,换 IP 也没法轰炸同一个收件箱。

没有 RESEND_API_KEY 时不会真正发信,验证码会打印在开发服务器日志里:

[auth] no mail provider — code for you@example.com: 123456

这样刚 clone 下来的项目也能用邮箱登录。上线前请先配置好 Resend,见邮件与通知。

会话

会话基于 cookie,有效期 7 天,最多每天续期一次。应用里没有任何手写的会话检查:

  • 页面:_app 路由组下的页面由 src/routes/_app.tsx 统一守卫。没有会话就跳转到 /login?redirect=<原本要去的页面>,登录后回到那个页面。跳转只接受同源路径(src/features/auth/redirect.ts)。
  • Server function:从 src/core/server/fn.ts 的 authedFn 或 adminFn 开始写,handler 的 context 里会带上有类型的 session。
export const myThingFn = authedFn.handler(async ({ context }) => {
  const userId = context.session.user.id
  // …
})

完整的数据通道见 服务端函数,adminFn 能保证什么、不能保证什么见权限。better-auth 只在逻辑上让记录过期,所以有一个每晚运行的任务(auth.purge-expired)负责删除过期的会话、验证记录和 API key。

登录、退出、资料修改、角色变更、封禁以及 API key 的变动,都会由 src/features/auth/server/audit-hook.ts 写入审计日志。

修改认证配置

better-auth 的 CLI 跑在 Node 里,无法导入 cloudflare:workers,所以配置分成两个文件:

  1. src/features/auth/server/auth.ts:Worker 实际运行的配置。
  2. 仓库根目录的 auth.cli-config.ts:插件和用户字段与前者保持一致的镜像,只用来生成 schema。

新增插件或用户字段时,两个文件都要改,然后重新生成表结构和迁移:

bun run auth:generate   # 重写 src/features/auth/schema.ts
bun run db:generate     # 为这次改动生成 drizzle 迁移
bun run db:migrate:local

社交登录方式不会增加数据库列,所以新增一个只需要改 auth.ts(再在 src/core/env.ts 和 .dev.vars.example 里加上它的密钥)。迁移的细节见数据库。

用户的语言

每个用户行都有一个 locale 列:注册时从访客的语言 cookie 写入,之后可以在设置 → 偏好设置里修改。邮件会用这个语言发送,见国际化。

删除账号

在设置里删除账号分两步。

  1. 关闭:账号被标记 deletedAt,所有会话和 API key 被删除,同时发出一封“账号已关闭”邮件,其他数据都保留。关闭后的账号用任何方式都无法再登录。如果会话已经超过一天(没有密码可以再输一遍,最近登录过就算确认),或者某个功能的守卫拒绝(比如还有生效中的订阅),请求会被拒绝。
  2. 清除:保留期过后,每晚的任务会彻底抹掉账号。保留期默认 30 天,可在管理后台的设置里修改,最少 7 天。清除前三天会再发一封提醒邮件。数据库里的行随用户级联删除;数据库之外的东西(比如 R2 里上传的头像)由各功能的处理函数清理。

清除之前,管理员可以在后台的用户页面恢复账号。恢复后用户照常重新登录即可,已吊销的会话和 key 不会恢复。

各功能通过 src/core/account/events.ts 接入这套流程:onAccountDeletionGuard 用来拒绝删除,onBeforeAccountDelete 和 onAccountDeleted 用来清理,onAccountCreated 处理新账号需要做的初始化。每个功能在 src/core/account/handlers.ts 里用一行 import 注册。

导出数据

设置 → 我的数据会把账号相关的全部数据下载成一个 JSON:用户行、会话、已关联的登录方式,外加每个功能各一个 key。功能通过 onAccountExport('<key>', handler) 贡献自己那一部分,比如邮件日志会导出发给该用户的邮件模板和标题。每次导出都会记入审计日志。