认证
模板使用 Better Auth、Drizzle 适配器与数据库会话。随附认证界面适配 Cloudflare,按路由提供密码登录、可选 OAuth 供应商、邮件验证码注册、验证码密码恢复,以及服务端管理员守卫。
已提供的能力
- 公开路由:/login、/register、/forgot-password、/reset-password
- 登录后路由:/me 提供资料与账单入口,/me/security 管理当前数据库登录,/admin 提供已授权的运营操作
- 服务端入口:
~/server/auth的auth、resolveCurrentSubject、currentLoginSignOut - 密码辅助函数:
~/server/auth/password的requestRegistrationCode、registerWithEmailCode、requestPasswordReset、resetPasswordWithCode、changeOwnPassword - 会话字段:
session.user.id、session.user.role、session.user.status - 内置角色和状态:user、admin、editor,以及 active、disabled、invited
路由模型
/register- 签发六位邮件验证码,再由
registerWithEmailCode原子地消费凭证并创建密码账户
- 签发六位邮件验证码,再由
/login- 通过 Better Auth 原生
signIn.email登录 - 仅在环境变量已配置时显示 Discord、GitHub、Google 按钮
- 通过 Better Auth 原生
/forgot-password- 接收邮箱并调用
requestPasswordReset
- 接收邮箱并调用
/reset-password- 使用
email、code、newPassword完成密码恢复
- 使用
/me- 要求已认证会话,显示资料与账单入口
/me/security- 使用当前
loginSessions能力观察和撤销账户数据库登录;不将保留的旧授权记录变成当前会话
- 使用当前
/admin- 使用服务端管理员守卫,不信任客户端路由状态
读取当前用户
在 Server Components、路由处理器和 Server Actions 中使用 auth():
每次调用都验证当前数据库会话并读取当前账户,返回新鲜的公开会话或 null。存储不可用时抛错,不回退到缓存身份。需要明确的 unauthenticated、account_unavailable、service_unavailable 结果时,使用 resolveCurrentSubject()。只有 kind: "ready" 携带当前身份;资源归属仍由对应领域负责。
import { redirect } from "next/navigation";
import { auth } from "~/server/auth";
export default async function AccountPage() {
const session = await auth();
if (!session?.user?.id) {
redirect("/login");
}
return <div>Signed in as {session.user.email}</div>;
}登录
共享浏览器客户端是 @t3template/sdk/auth 的 authClient,使用位于 /api/auth 的官方 Better Auth React 客户端,不需要会话 provider。authClient.useSession() 返回 data、isPending、error、refetch;展示只使用公开用户字段。出现 error 时不要显示旧身份。受保护操作始终重新检查服务端。
随附表单通过受保护的操作适配原生登录:
"use client";
import { signInWithEmail } from "@t3template/sdk/auth/sign-in";
export async function passwordSignIn(formData: FormData) {
const result = await signInWithEmail(formData);
if (result.kind === "authenticated") window.location.assign(result.destination);
return result;
}适配器调用 authClient.signIn.email({ email, password, callbackURL })。OAuth 使用 authClient.signIn.social({ provider, callbackURL, errorCallbackURL, disableRedirect: true })。客户端禁用自动跳转,使操作可在用户停止等待后丢弃迟到响应。应用回调路径会被验证;外部供应商授权 URL 仅来自原生端点。
供应商可选 discord、github、google,各自只有配置 ID/secret 对后才启用。所有环境的密码登录均使用实际存储凭据。开发账户创建归 Bootstrap,不存在自动开发凭据回退。
OAuth 在生产环境也可选。所有实际登录运行时都需要至少 32 字符的 AUTH_SECRET;密码登录不需要第三方身份账户。请生成自己的随机密钥,不使用文档占位值。
退出登录
使用随附 /logout 确认和恢复页面,或在 Server Action 调用 Auth 拥有的操作:
"use server";
import { redirect } from "next/navigation";
import { currentLoginSignOut } from "~/server/auth";
export async function signOutCurrentLogin(confirmed: boolean) {
const result = await currentLoginSignOut.end({ confirmed });
if (result.kind === "signed_out") redirect("/login");
return result;
}end 仅撤销原始记录的登录,再清除其本地 cookie。分别处理 confirmation_required、service_unavailable、outcome_unknown、cookie_unavailable。observe() 检查当前权限,但不证明是之前哪条命令造成的。显式 clearLocal() 回退只清理当前浏览器,不保证已复制令牌被撤销。页面在确认退出或本地清理后通知 Better Auth 会话存储。本地清理与持久会话撤销是独立结果。
注册
/register 先申请验证码,再连同新账户字段提交。验证码由服务端生成;有效期、用途、限流与密码验证均归服务端。
import { requestRegistrationCode, registerWithEmailCode } from "~/server/auth/password";
// Request phase: the email transport may accept, reject or have an unknown result.
const request = await requestRegistrationCode("user@example.com");
// Submission phase: code comes from the user-entered form, never a client verification flag.
const result = await registerWithEmailCode({ email: "user@example.com", code: userEnteredCode, password: "securepassword123", name: "Jane Doe" });
if (result.ok) {
// The account and single-use proof commit are confirmed. The browser now calls native signIn.email.
} else if (result.status === "outcome_unknown") {
// Offer sign-in or recovery before creating another registration attempt.
}默认持久化适配器在同一事务批处理中提交账户创建与准确凭证消费。重复邮箱不能替换已有账户。无效、过期、用途错误或已消费的验证码不会创建账户。注册、恢复和账户密码修改采用相同的 8–128 字符规则,密码内容不会被 trim。
accepted 表示传输层接受邮件,不代表已到达收件箱。确定的 not_sent 只移除该次签发;发送结果未知时保留凭证,直到过期或显式替换。使用最新申请的验证码,不要盲目重试未知结果。
非生产环境中页面使用 DEV_AUTH_CODE,跳过外部邮件。注册前仍必须点击发送以创建持久凭证。固定开发验证码不能绕过签发或消费。旧独立辅助函数 verifyEmailCode 与 registerPasswordUser 保留给可信兼容调用方;把它们组合为两个操作不能提供原子账户注册。
密码恢复
密码恢复使用验证码,通过以下真实辅助函数完成:
import { requestPasswordReset, resetPasswordWithCode } from "~/server/auth/password";
await requestPasswordReset({ email: "user@example.com" });
await resetPasswordWithCode({ email: "user@example.com", code: "123456", newPassword: "newpassword123" });生产投递需要有效 SMTP_* 配置。非生产环境的重置流程使用 DEV_AUTH_CODE,不发送真实邮件,但仍需要先申请恢复。
符合条件、账户不存在、仅 OAuth 账户,以及账户相关存储或投递失败,恢复请求均返回相同公开 accepted 响应。该响应不确认账户存在或邮件送达。输入与限流失败仍可指导用户处理。相同响应内容不能保证抵御时间分析。
只有密码、凭证消费、安全历史事实与账户 sessionVersion 增量共同提交后,resetPasswordWithCode 才返回 reset。该增量使此前所有登录授权失效,包括当前登录;成功后需重新登录。提交结果未知时,先检查能否用新密码登录,再决定是否申请另一次重置。提交后通知失败不会撤销新密码或会话撤销。没有密码哈希的账户继续使用已配置供应商登录,恢复不能为它设置首个密码。
替换登录展示
随附登录卡片消费登录路由的 useLoginOperation 与 LoginViewProps。替换视图使用相同状态和命令处理编辑、密码提交、已启用 OAuth 方法、停止本地等待及检查当前会话。视图不负责 Better Auth 配置、密码策略、返回目标验证,也不另建认证实现。
操作只将邮箱放入本地存储,密码仅临时保留。等待中的重复提交被阻止;取消后的迟到响应不导航;传输结果不确定时提供新鲜服务端会话检查。精简测试渲染器执行同一协议,但不证明真实 OAuth 或 SMTP 投递。
保护路由与 Actions
普通登录后界面应在请求处理位置检查 auth():
import { NextResponse } from "next/server";
import { auth } from "~/server/auth";
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return NextResponse.json({ userId: session.user.id, role: session.user.role, status: session.user.status });
}仅管理员可用的界面使用仓库守卫函数:
import { requireAdmin } from "~/server/security/admin-auth";
export default async function AdminPage() {
await requireAdmin();
return <div>Admin tools</div>;
}根 middleware 不是认证保护边界。本仓库中它为 Cloudflare 日志与分析补充请求头,因此受保护页面、路由处理器与 Server Actions 必须自行执行认证。
配置
主要环境变量如下:
APP_URL="http://localhost:3101"
AUTH_SECRET="generate-your-own-random-secret-at-least-32-characters"
AUTH_LOGIN_REDIRECT="/me"
AUTH_DISCORD_ID="your-discord-client-id"
AUTH_DISCORD_SECRET="your-discord-client-secret"
AUTH_GITHUB_ID="your-github-client-id"
AUTH_GITHUB_SECRET="your-github-client-secret"
AUTH_GOOGLE_ID="your-google-client-id.apps.googleusercontent.com"
AUTH_GOOGLE_SECRET="your-google-client-secret"
DEV_AUTH_CODE="123456"
SMTP_HOST="smtp.example.com"
SMTP_PORT="587"
SMTP_USER="smtp-user"
SMTP_PASS="smtp-pass"
SMTP_FROM="Product <no-reply@example.com>"说明:
APP_URL是认证重定向与回调使用的规范 origin,应与当前本地、预览或生产环境一致。- 登录与注册完成默认前往
/me。AUTH_LOGIN_REDIRECT可选择受支持且安全的内部目的地址;敏感路由独立授权请求。退场的示例与账户中心路径不能作为返回目标。 - 配置 origin 应与可信部署入口一致,不要将请求头或客户端状态当作身份。
当前运行时与权限
~/server/auth 拥有服务端身份、账户生命周期与退出命令。Next /api/auth 处理器挂载选定 Better Auth 原生端点。原生注册、重置、账户更新及管理员写入受到限制,不能绕过应用凭证、连续性与审计规则。
- 数据库会话是当前登录来源。旧 JWT 授权记录只保留为历史,不能认证请求。
auth()与resolveCurrentSubject()在每次受保护操作中读取当前角色和状态。停用、已删除、已撤销或不支持的账户不能依赖客户端缓存字段。~/server/auth/types的PublicSession只包含expires与安全用户字段。公开会话读取省略会话令牌、ID、私有账户代次与凭据快照。- 密码重置/修改与账户级撤销使变更前签发的会话失效,刷新不能恢复权限。
- Security 拥有操作权限。editor 可进入选定内容后台,其他账户或商业管理需要对应权限。菜单可见性不授予权限。
已检查的示例
英文资源旁的 src/app/en/docs/guides/authentication/_examples/server-auth.ts 与 _examples/client-auth.ts 按公开导出检查文档调用。本地操作测试覆盖安全返回目标、原生错误分类、重复提交排除、取消后的响应与恢复。集成测试使用真实原生登录与当前数据库会话。这些检查不证明外部 OAuth 同意或生产邮件送达。
故障排查
重定向或 OAuth 回调使用了错误主机
- 将
APP_URL设为当前运行环境的准确 origin - 将 OAuth 回调注册为
${APP_URL}/api/auth/callback/<provider>
未收到密码重置验证码
- 配置
SMTP_HOST、SMTP_PORT、SMTP_USER、SMTP_PASS、SMTP_FROM - 非生产环境使用
DEV_AUTH_CODE,不发送真实邮件
权限变化未立即显示
- 确认受保护操作调用公开服务端
auth()或适当领域守卫,而非缓存客户端状态或令牌字段。 - 角色变化后刷新浏览器展示的会话。密码重置或授权撤销后重新登录;旧会话仍无效。
- 存储故障应视为不可用,不增加使用缓存角色/状态授权的回退。
下一步
基本账户资料
~/server/auth 的 readOwnProfile() 提供安全当前账户字段 id、name、email、image、hasPassword,结果为 ready、unauthenticated、account_unavailable 或 service_unavailable。/me 消费此查询,而不直接查询用户表。每个资料/密码命令独立检查当前权限。账户界面保持精简的资料与账单。/me/security 使用 loginSessions.list、revoke、observe 管理当前数据库登录,撤销其他登录前要求确认,当前登录则前往 /logout。隐私申请有独立 /me/privacy 入口,不会自动导出或清除领域数据。个人 API 凭据与通知偏好不属于此账户界面。