登录
基于 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,所以配置分成两个文件:
src/features/auth/server/auth.ts:Worker 实际运行的配置。- 仓库根目录的
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 写入,之后可以在设置 → 偏好设置里修改。邮件会用这个语言发送,见国际化。
删除账号
在设置里删除账号分两步。
- 关闭:账号被标记
deletedAt,所有会话和 API key 被删除,同时发出一封“账号已关闭”邮件,其他数据都保留。关闭后的账号用任何方式都无法再登录。如果会话已经超过一天(没有密码可以再输一遍,最近登录过就算确认),或者某个功能的守卫拒绝(比如还有生效中的订阅),请求会被拒绝。 - 清除:保留期过后,每晚的任务会彻底抹掉账号。保留期默认 30 天,可在管理后台的设置里修改,最少 7 天。清除前三天会再发一封提醒邮件。数据库里的行随用户级联删除;数据库之外的东西(比如 R2 里上传的头像)由各功能的处理函数清理。
清除之前,管理员可以在后台的用户页面恢复账号。恢复后用户照常重新登录即可,已吊销的会话和 key 不会恢复。
各功能通过 src/core/account/events.ts 接入这套流程:onAccountDeletionGuard 用来拒绝删除,onBeforeAccountDelete 和 onAccountDeleted 用来清理,onAccountCreated 处理新账号需要做的初始化。每个功能在 src/core/account/handlers.ts 里用一行 import 注册。
导出数据
设置 → 我的数据会把账号相关的全部数据下载成一个 JSON:用户行、会话、已关联的登录方式,外加每个功能各一个 key。功能通过 onAccountExport('<key>', handler) 贡献自己那一部分,比如邮件日志会导出发给该用户的邮件模板和标题。每次导出都会记入审计日志。