Sistine Starter

认证系统

Better Auth 配置与使用指南

认证系统

Sistine Starter 使用 Better Auth 作为认证解决方案,支持邮箱密码登录和 Google OAuth。本指南将介绍如何配置认证系统、保护路由以及使用认证 API。

Better Auth 简介

Better Auth 是一个现代化的 TypeScript 认证库,具有以下特点:

  • 类型安全: 完整的 TypeScript 支持
  • 框架无关: 支持 Next.js、React、Svelte、Vue 等
  • 开箱即用: 内置邮箱密码、OAuth、魔法链接等多种认证方式
  • 灵活扩展: 通过 hooks 和插件系统轻松扩展
  • 安全优先: 遵循最佳安全实践(密码哈希、CSRF 保护等)

相比 NextAuth.js,Better Auth 提供更好的类型推断和更简洁的 API。

配置步骤

1. 生成密钥

Better Auth 需要一个至少 32 字符的密钥用于加密会话和 Token。

生成方法:

openssl rand -base64 32

将生成的密钥添加到 .env.local:

BETTER_AUTH_SECRET="your-super-secret-key-generated-above"

2. 配置基础 URL

设置应用的基础 URL,用于生成回调链接:

# 开发环境
BETTER_AUTH_URL="http://localhost:3000"

# 生产环境
BETTER_AUTH_URL="https://yourdomain.com"

3. 配置 Google OAuth (可选)

如果你想启用 Google 登录,需要在 Google Cloud Console 中创建 OAuth 凭据。

步骤 A: 创建 Google OAuth 凭据

  1. 访问 Google Cloud Console

  2. 创建新项目或选择现有项目

  3. 启用 Google+ API:

    • 左侧菜单 > APIs & Services > Library
    • 搜索 "Google+ API" 并启用
  4. 创建 OAuth 2.0 凭据:

    • 左侧菜单 > APIs & Services > Credentials
    • 点击 "Create Credentials" > "OAuth client ID"
    • 应用类型: Web application
    • 名称: Sistine Starter
  5. 配置授权重定向 URI:

    # 开发环境
    http://localhost:3000/api/auth/callback/google
    
    # 生产环境
    https://yourdomain.com/api/auth/callback/google
  6. 保存后获取 Client IDClient Secret

步骤 B: 配置环境变量

将凭据添加到 .env.local:

AUTH_GOOGLE_ID="123456789-xxxxxxxxxxxxx.apps.googleusercontent.com"
AUTH_GOOGLE_SECRET="GOCSPX-xxxxxxxxxxxxxxxx"

4. 配置信任的源 (可选)

如果你有多个域名需要访问 API,配置信任的源:

BETTER_AUTH_TRUSTED_ORIGINS="http://localhost:3000,https://yourdomain.com,https://preview.yourdomain.com"

多个源用逗号分隔。

服务端配置

Better Auth 的服务端配置位于 lib/auth.ts:

import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { createAuthMiddleware } from "better-auth/api";
import { db } from "./db";
import { refundCredits } from "./credits";

const defaultTrustedOrigins = ["http://localhost:3000"];

const trustedOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
  ? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(",")
      .map((origin) => origin.trim())
      .filter(Boolean)
  : defaultTrustedOrigins;

export const auth = betterAuth({
  // 数据库适配器 (使用 Drizzle ORM)
  database: drizzleAdapter(db, {
    provider: "pg",
  }),

  // 应用基础 URL
  baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",

  // 加密密钥
  secret: process.env.BETTER_AUTH_SECRET,

  // 启用邮箱密码登录
  emailAndPassword: {
    enabled: true,
  },

  // 配置 Google OAuth
  socialProviders: {
    google: {
      clientId: process.env.AUTH_GOOGLE_ID!,
      clientSecret: process.env.AUTH_GOOGLE_SECRET!,
    },
  },

  // 信任的源
  trustedOrigins,

  // 注册后赠送积分的 Hook
  hooks: {
    after: createAuthMiddleware(async (ctx) => {
      // 监听用户注册事件 (邮箱和 OAuth)
      if (ctx.path.startsWith("/sign-up")) {
        const newSession = ctx.context.newSession;
        if (newSession) {
          try {
            // 赠送 300 积分作为注册奖励
            await refundCredits(
              newSession.user.id,
              300,
              "registration_bonus"
            );
            console.log(`[Auth] New user registered, granted 300 credits: ${newSession.user.email}`);
          } catch (error) {
            console.error("[Auth] Failed to grant registration bonus:", error);
          }
        }
      }
    }),
  },
});

export { hashPassword } from "better-auth/crypto";

关键配置说明

配置项说明
database使用 Drizzle 适配器连接 PostgreSQL
baseURL应用的公开 URL,用于生成回调链接
secret加密密钥,必须至少 32 字符
emailAndPassword启用邮箱密码登录
socialProviders.google配置 Google OAuth
trustedOrigins允许的 CORS 源
hooks.after注册后执行的钩子函数

客户端使用

创建客户端实例

客户端配置位于 lib/auth-client.ts:

"use client";

import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
});

export const { signIn, signUp, signOut, useSession } = authClient;

在组件中使用

获取当前会话

"use client";

import { useSession } from "@/lib/auth-client";

export function ProfileComponent() {
  const session = useSession();

  if (session.isPending) {
    return <div>加载中...</div>;
  }

  if (!session.data) {
    return <div>未登录</div>;
  }

  return (
    <div>
      <p>欢迎, {session.data.user.name}!</p>
      <p>邮箱: {session.data.user.email}</p>
      <p>积分: {session.data.user.credits}</p>
    </div>
  );
}

邮箱密码登录

"use client";

import { signIn } from "@/lib/auth-client";
import { useState } from "react";

export function LoginForm() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    const result = await signIn.email({
      email,
      password,
    });

    if (result.error) {
      setError(result.error.message);
    } else {
      // 登录成功,重定向到仪表板
      window.location.href = "/dashboard";
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="邮箱"
        required
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="密码"
        required
      />
      {error && <p className="text-red-500">{error}</p>}
      <button type="submit">登录</button>
    </form>
  );
}

邮箱密码注册

"use client";

import { signUp } from "@/lib/auth-client";

export function SignUpForm() {
  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const formData = new FormData(e.currentTarget);

    const result = await signUp.email({
      email: formData.get("email") as string,
      password: formData.get("password") as string,
      name: formData.get("name") as string,
    });

    if (result.error) {
      alert(result.error.message);
    } else {
      // 注册成功,用户将自动获得 300 积分
      window.location.href = "/dashboard";
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" placeholder="姓名" required />
      <input name="email" type="email" placeholder="邮箱" required />
      <input name="password" type="password" placeholder="密码" required />
      <button type="submit">注册</button>
    </form>
  );
}

Google OAuth 登录

"use client";

import { signIn } from "@/lib/auth-client";

export function GoogleLoginButton() {
  const handleGoogleLogin = async () => {
    await signIn.social({
      provider: "google",
      callbackURL: "/dashboard",
    });
  };

  return (
    <button onClick={handleGoogleLogin}>
      使用 Google 登录
    </button>
  );
}

登出

"use client";

import { signOut } from "@/lib/auth-client";
import { useRouter } from "next/navigation";

export function LogoutButton() {
  const router = useRouter();

  const handleLogout = async () => {
    await signOut();
    router.push("/");
  };

  return <button onClick={handleLogout}>退出登录</button>;
}

服务端保护路由

方法 1: 使用 SessionGuard 组件

SessionGuard 是一个客户端组件,用于保护需要登录才能访问的页面。

组件代码 (features/auth/components/session-guard.tsx):

"use client";

import * as React from "react";
import { useRouter, usePathname } from "next/navigation";
import { useLocale } from 'next-intl';
import { useSession } from "@/lib/auth-client";

export function SessionGuard({ children }: { children: React.ReactNode }) {
  const router = useRouter();
  const session = useSession();
  const locale = useLocale();

  React.useEffect(() => {
    if (!session.isPending && !session.data) {
      // 未登录,重定向到登录页
      router.replace(`/${locale}/login`);
    }
  }, [router, session.data, session.isPending]);

  if (session.isPending) {
    // 加载中显示占位符
    return (
      <div className="flex min-h-screen items-center justify-center">
        <div className="h-12 w-12 animate-pulse rounded-full bg-muted" />
      </div>
    );
  }

  if (!session.data?.user) {
    return null;
  }

  return <>{children}</>;
}

使用示例:

// app/[locale]/(protected)/dashboard/page.tsx
import { SessionGuard } from "@/features/auth/components/session-guard";

export default function DashboardPage() {
  return (
    <SessionGuard>
      <div>
        <h1>仪表板</h1>
        <p>这是受保护的页面,只有登录用户才能访问。</p>
      </div>
    </SessionGuard>
  );
}

方法 2: 在 API 路由中验证

在 API 路由中验证用户身份:

// app/api/user/profile/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";

export async function GET(request: NextRequest) {
  // 从请求头中获取会话
  const session = await auth.api.getSession({ headers: request.headers });

  if (!session) {
    return NextResponse.json(
      { error: "Unauthorized" },
      { status: 401 }
    );
  }

  // 已登录,返回用户信息
  return NextResponse.json({
    user: session.user,
  });
}

方法 3: 在 Server Action 中验证

// app/actions/update-profile.ts
"use server";

import { auth } from "@/lib/auth";
import { headers } from "next/headers";

export async function updateProfile(name: string) {
  const session = await auth.api.getSession({ headers: await headers() });

  if (!session) {
    throw new Error("Unauthorized");
  }

  // 更新用户资料
  // ...

  return { success: true };
}

新用户注册赠送 300 积分

Sistine Starter 在用户注册时自动赠送 300 积分作为新人礼包。这个功能通过 Better Auth 的 hooks.after 实现。

实现机制

lib/auth.ts:36-56 中:

hooks: {
  after: createAuthMiddleware(async (ctx) => {
    // 监听用户注册事件 (邮箱和 OAuth)
    if (ctx.path.startsWith("/sign-up")) {
      const newSession = ctx.context.newSession;
      if (newSession) {
        try {
          // 赠送 300 积分作为注册奖励
          await refundCredits(
            newSession.user.id,
            300,
            "registration_bonus"
          );
          console.log(`[Auth] New user registered, granted 300 credits: ${newSession.user.email}`);
        } catch (error) {
          console.error("[Auth] Failed to grant registration bonus:", error);
        }
      }
    }
  }),
},

工作流程

  1. 用户注册: 通过邮箱密码或 Google OAuth 注册
  2. Better Auth 创建账户: 插入 useraccount
  3. Hook 触发: ctx.path.startsWith("/sign-up") 检测到注册事件
  4. 发放积分: 调用 refundCredits(userId, 300, "registration_bonus")
  5. 更新数据库:
    • user.credits 增加 300
    • creditLedger 插入记录: { delta: 300, reason: 'registration_bonus' }

积分账本记录

creditLedger 表中会生成如下记录:

id: "uuid-xxx"
userId: "user_123"
delta: 300
reason: "registration_bonus"
paymentId: null
createdAt: 2025-10-15T08:30:00.000Z

自定义赠送金额

如需修改赠送积分数量,编辑 lib/auth.ts:44:

await refundCredits(
  newSession.user.id,
  500, // 改为 500 积分
  "registration_bonus"
);

最佳实践

1. 密钥管理

  • ✅ 使用至少 32 字符的随机密钥
  • ✅ 开发和生产使用不同的密钥
  • ✅ 定期轮换生产环境密钥
  • ✅ 永远不要将密钥提交到 Git

2. OAuth 回调 URL

确保回调 URL 与环境匹配:

环境回调 URL
本地开发http://localhost:3000/api/auth/callback/google
预览环境https://preview.yourdomain.com/api/auth/callback/google
生产环境https://yourdomain.com/api/auth/callback/google

3. 会话管理

Better Auth 自动管理会话,默认配置:

  • 会话有效期: 7 天
  • 自动续期: 是
  • 存储位置: 数据库 (session 表)

如需自定义会话时长,在 lib/auth.ts 中添加:

export const auth = betterAuth({
  // ...
  session: {
    expiresIn: 60 * 60 * 24 * 30, // 30 天 (秒)
    updateAge: 60 * 60 * 24, // 每 24 小时更新一次
  },
});

4. 密码要求

Better Auth 默认的密码要求:

  • 最少 8 字符
  • 无复杂度要求

如需自定义密码要求,在 lib/auth.ts 中配置:

export const auth = betterAuth({
  // ...
  emailAndPassword: {
    enabled: true,
    minPasswordLength: 10,
    maxPasswordLength: 128,
  },
});

5. 错误处理

始终处理认证错误:

const result = await signIn.email({ email, password });

if (result.error) {
  switch (result.error.status) {
    case 401:
      setError("邮箱或密码错误");
      break;
    case 403:
      setError("账户已被禁用");
      break;
    default:
      setError("登录失败,请稍后重试");
  }
}

6. 多因素认证 (未来计划)

Better Auth 支持 MFA,可通过插件启用:

import { twoFactor } from "better-auth/plugins";

export const auth = betterAuth({
  // ...
  plugins: [
    twoFactor({
      issuer: "Sistine AI",
    }),
  ],
});

常见问题

如何获取当前登录用户?

客户端:

const session = useSession();
const user = session.data?.user;

服务端 (API 路由):

const session = await auth.api.getSession({ headers: request.headers });
const user = session?.user;

服务端 (Server Component):

import { auth } from "@/lib/auth";
import { headers } from "next/headers";

const session = await auth.api.getSession({ headers: await headers() });

如何实现"记住我"功能?

Better Auth 默认支持持久化会话,无需额外配置。

如何自定义注册字段?

修改 signUp 调用时传入的数据:

await signUp.email({
  email: "user@example.com",
  password: "password123",
  name: "张三",
  // 自定义字段需要在 user 表中添加对应列
});

如何发送邮箱验证链接?

Better Auth 内置邮箱验证功能,需要配置 verification 插件:

import { verification } from "better-auth/plugins";

export const auth = betterAuth({
  // ...
  plugins: [
    verification({
      sendEmail: async (user, url) => {
        // 发送验证邮件
        await sendEmail({
          to: user.email,
          subject: "验证你的邮箱",
          html: `点击链接验证: <a href="${url}">${url}</a>`,
        });
      },
    }),
  ],
});

如何实现密码重置?

Better Auth 支持密码重置,需要配置 resetPassword 插件:

import { resetPassword } from "better-auth/plugins";

export const auth = betterAuth({
  // ...
  plugins: [
    resetPassword({
      sendResetPasswordEmail: async (user, url) => {
        // 发送密码重置邮件
        await sendEmail({
          to: user.email,
          subject: "重置你的密码",
          html: `点击链接重置密码: <a href="${url}">${url}</a>`,
        });
      },
    }),
  ],
});

如何禁用 Google OAuth?

.env.local 中删除 AUTH_GOOGLE_IDAUTH_GOOGLE_SECRET,并从 lib/auth.ts 中移除 socialProviders 配置。

如何添加其他 OAuth 提供商?

Better Auth 支持多种 OAuth 提供商,在 lib/auth.ts 中添加:

socialProviders: {
  google: { /* ... */ },
  github: {
    clientId: process.env.AUTH_GITHUB_ID!,
    clientSecret: process.env.AUTH_GITHUB_SECRET!,
  },
  facebook: {
    clientId: process.env.AUTH_FACEBOOK_ID!,
    clientSecret: process.env.AUTH_FACEBOOK_SECRET!,
  },
},

会话是否支持跨域?

是的,通过 trustedOrigins 配置信任的源即可支持跨域。

如何查看所有登录会话?

查询 session 表:

import { db } from "@/lib/db";
import { session } from "@/lib/db/schema";
import { eq } from "drizzle-orm";

const userSessions = await db
  .select()
  .from(session)
  .where(eq(session.userId, userId));

如何强制用户登出所有设备?

删除用户的所有会话:

import { db } from "@/lib/db";
import { session } from "@/lib/db/schema";
import { eq } from "drizzle-orm";

await db.delete(session).where(eq(session.userId, userId));

下一步