ProductReadyProductReady

身份验证

使用 Better Auth 进行用户身份验证 - 注册、登录、OAuth 和会话管理

身份验证

ProductReady 使用 Better Auth 实现现代化、安全的身份验证。本指南涵盖了从基础的邮箱/密码登录到 OAuth 集成的所有内容。

为什么选择 Better Auth? 现代化、类型安全、框架无关,且积极维护。比 NextAuth/Auth.js 简单得多。


快速概览

支持的认证方式

  • ✅ 邮箱 + 密码
  • ✅ OAuth(GitHub、Google)
  • ✅ 魔术链接(仅邮箱登录)- 可配置
  • ✅ 会话管理
  • ✅ 邮箱验证 - 可选

工作原理

  1. 用户注册/登录
  2. Better Auth 创建会话
  3. 会话存储在数据库和 Cookie 中
  4. tRPC 检查受保护路由的会话

注册与登录

邮箱 + 密码流程

用户可以使用邮箱和密码创建账户。

注册页面/sign-up(或自定义位置)

示例组件

'use client';

import { authClient } from '~/lib/auth.client';
import { useState } from 'react';

export function SignUpForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [name, setName] = useState('');

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    
    try {
      await authClient.signUp.email({
        email,
        password,
        name,
      });
      
      // 重定向到控制面板
      window.location.href = '/dashboard';
    } catch (error) {
      console.error('注册失败:', error);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="姓名"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <input
        type="email"
        placeholder="邮箱"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <input
        type="password"
        placeholder="密码"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit">注册</button>
    </form>
  );
}

登录 类似 - 使用 authClient.signIn.email()

await authClient.signIn.email({
  email,
  password,
});

OAuth(社交登录)

GitHub OAuth

让用户使用 GitHub 登录。

步骤 1:创建 GitHub OAuth 应用

  1. 前往 GitHub 设置 → 开发者设置
  2. 点击 "新建 OAuth 应用"
  3. 填写信息:
    • 应用名称:你的应用名称
    • 主页 URLhttp://localhost:3000(开发环境)或 https://yourapp.com(生产环境)
    • 授权回调 URLhttp://localhost:3000/api/auth/callback/github
  4. 点击 "注册应用"
  5. 复制 Client IDClient Secret

步骤 2:添加环境变量

.env
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret

步骤 3:在应用中使用

'use client';

import { authClient } from '~/lib/auth.client';

export function GitHubButton() {
  function handleGitHubLogin() {
    authClient.signIn.social({
      provider: 'github',
      callbackURL: '/dashboard',
    });
  }

  return (
    <button onClick={handleGitHubLogin}>
      <GitHubIcon /> 使用 GitHub 登录
    </button>
  );
}

Google OAuth

与 GitHub 类似:

步骤 1:创建 Google OAuth 凭据

  1. 前往 Google Cloud Console
  2. 创建项目(或选择现有项目)
  3. 启用 Google+ API
  4. 前往 凭据创建凭据OAuth 客户端 ID
  5. 选择 Web 应用
  6. 添加授权重定向 URI:http://localhost:3000/api/auth/callback/google
  7. 复制 Client IDClient Secret

步骤 2:环境变量

.env
GOOGLE_CLIENT_ID=your_google_client_id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your_google_client_secret

步骤 3:在应用中使用

authClient.signIn.social({
  provider: 'google',
  callbackURL: '/dashboard',
});

会话管理

检查用户是否已登录

服务端(tRPC、API 路由):

import { auth } from '~/lib/auth';
import { headers } from 'next/headers';

export async function GET() {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  if (!session) {
    return new Response('未授权', { status: 401 });
  }

  return Response.json({ user: session.user });
}

客户端(React 组件):

'use client';

import { authClient } from '~/lib/auth.client';
import { useEffect, useState } from 'react';

export function UserProfile() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    authClient.getSession().then((session) => {
      if (session) {
        setUser(session.user);
      }
    });
  }, []);

  if (!user) return <div>未登录</div>;

  return <div>欢迎,{user.name}!</div>;
}

登出

'use client';

import { authClient } from '~/lib/auth.client';

export function LogoutButton() {
  async function handleLogout() {
    await authClient.signOut();
    window.location.href = '/';
  }

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

受保护的路由(tRPC)

使用 protectedProcedure 要求身份验证:

import { createTRPCRouter, protectedProcedure } from '../trpc';

export const usersRouter = createTRPCRouter({
  me: protectedProcedure.query(async ({ ctx }) => {
    // ctx.session.user 保证存在
    return ctx.session.user;
  }),

  updateProfile: protectedProcedure
    .input(z.object({ name: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const userId = ctx.session.user.id;
      
      await ctx.db
        .update(users)
        .set({ name: input.name })
        .where(eq(users.id, userId));
      
      return { success: true };
    }),
});

工作原理

  1. protectedProcedure 检查有效会话
  2. 如果没有会话 → 抛出错误(前端显示登录提示)
  3. 如果会话存在 → 在处理程序中可以访问 ctx.session.user

中间件(页面保护)

使用中间件保护整个页面:

创建 src/middleware.ts

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { auth } from '~/lib/auth';

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // 保护 /dashboard 路由
  if (pathname.startsWith('/dashboard')) {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session) {
      const url = request.nextUrl.clone();
      url.pathname = '/login';
      url.searchParams.set('from', pathname);
      return NextResponse.redirect(url);
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*'],
};

现在所有 /dashboard/* 路由都需要登录!


邮箱验证(可选)

为新注册用户启用邮箱验证:

步骤 1:配置邮件提供商

ProductReady 支持任何邮件提供商。以 Resend 为例:

pnpm add resend

src/lib/auth.ts 中配置:

import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

export const auth = betterAuth({
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true, // ← 启用验证
  },
  emailVerification: {
    sendVerificationEmail: async ({ user, url }) => {
      await resend.emails.send({
        from: 'noreply@yourapp.com',
        to: user.email,
        subject: '验证你的邮箱',
        html: `点击验证:<a href="${url}">${url}</a>`,
      });
    },
  },
});

步骤 2:处理验证回调

Better Auth 会自动处理 /api/auth/verify-email?token=xxx 的回调。

用户点击链接 → 邮箱已验证 → 可以登录!


密码重置

步骤 1:请求重置

'use client';

import { authClient } from '~/lib/auth.client';

export function ForgotPasswordForm() {
  const [email, setEmail] = useState('');

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    
    await authClient.forgetPassword({
      email,
      redirectTo: '/reset-password',
    });

    alert('请查看你的邮箱获取重置链接!');
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        placeholder="你的邮箱"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <button type="submit">发送重置链接</button>
    </form>
  );
}

步骤 2:重置密码页面

创建 /reset-password/page.tsx

'use client';

import { authClient } from '~/lib/auth.client';
import { useSearchParams } from 'next/navigation';

export default function ResetPasswordPage() {
  const searchParams = useSearchParams();
  const token = searchParams.get('token');
  const [password, setPassword] = useState('');

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    
    await authClient.resetPassword({
      newPassword: password,
      token: token!,
    });

    alert('密码已重置!你现在可以登录了。');
    window.location.href = '/login';
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="password"
        placeholder="新密码"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit">重置密码</button>
    </form>
  );
}

用户管理

获取当前用户(tRPC)

export const usersRouter = createTRPCRouter({
  me: protectedProcedure.query(async ({ ctx }) => {
    return ctx.session.user;
  }),
});

从前端调用:

const { data: user } = trpc.users.me.useQuery();

更新用户资料

export const usersRouter = createTRPCRouter({
  updateProfile: protectedProcedure
    .input(z.object({
      name: z.string().optional(),
      image: z.string().url().optional(),
    }))
    .mutation(async ({ ctx, input }) => {
      const userId = ctx.session.user.id;
      
      await ctx.db
        .update(users)
        .set(input)
        .where(eq(users.id, userId));
      
      return { success: true };
    }),
});

删除账户

export const usersRouter = createTRPCRouter({
  deleteAccount: protectedProcedure.mutation(async ({ ctx }) => {
    const userId = ctx.session.user.id;
    
    // 删除用户数据(级联删除相关记录)
    await ctx.db
      .delete(users)
      .where(eq(users.id, userId));
    
    // 登出
    await authClient.signOut();
    
    return { success: true };
  }),
});

最佳实践

✅ 应该做的

  • 哈希密码 - Better Auth 会自动完成
  • 在生产环境使用 HTTPS - 安全 Cookie 所必需
  • 设置安全的会话过期时间 - 默认 7 天,可配置
  • 验证邮箱格式 - 使用 Zod schema 检查格式
  • 限制认证端点速率 - 防止暴力破解攻击
// 速率限制示例(使用 Upstash 或类似服务)
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '1 m'), // 每分钟 5 次尝试
});

❌ 不应该做的

  • 不要以明文存储密码 - 始终使用哈希(Better Auth 会处理)
  • 不要暴露用户令牌 - 仅保留在服务端
  • 不要跳过邮箱验证 - 使用正确的正则表达式或库
  • 不要允许弱密码 - 强制最小长度(8+ 字符)

安全检查清单

  • BETTER_AUTH_SECRET 是随机且安全的(32+ 字符)
  • 开发环境和生产环境使用不同的密钥
  • 生产环境启用 HTTPS
  • 会话 Cookie 设置为 httpOnlysecure
  • 强制密码最小长度(8+ 字符)
  • 认证端点启用速率限制
  • 邮箱验证已启用(如果需要)
  • OAuth 回调 URL 完全匹配
  • CSRF 保护已启用(Better Auth 默认)

故障排除

受保护路由显示"未授权"

检查

  1. 你已登录了吗?使用 authClient.getSession() 测试
  2. Cookie 正在发送吗?查看浏览器开发者工具 → 应用 → Cookies
  3. BETTER_AUTH_URL 正确吗?应该与你的域名完全匹配

OAuth 无法工作

检查

  1. .env 中的 Client ID/Secret(无引号,无空格)
  2. 回调 URL 完全匹配(包括 http:// 或 https://)
  3. 更改环境变量后重新部署(Vercel)

会话过期太快

src/lib/auth.ts 中延长会话持续时间:

export const auth = betterAuth({
  session: {
    expiresIn: 60 * 60 * 24 * 30, // 30 天(以秒为单位)
  },
});

"无法读取 user 属性(undefined)"

会话尚未加载 - 添加加载状态:

const [session, setSession] = useState(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
  authClient.getSession().then((s) => {
    setSession(s);
    setLoading(false);
  });
}, []);

if (loading) return <div>加载中...</div>;
if (!session) return <div>未登录</div>;

高级:自定义认证 UI

使用 Better Auth 原语构建你自己的认证组件:

'use client';

import { authClient } from '~/lib/auth.client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';

export function CustomLoginForm() {
  const router = useRouter();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError('');
    setLoading(true);

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

      if (result.error) {
        setError(result.error.message);
        return;
      }

      router.push('/dashboard');
    } catch (err) {
      setError('登录失败。请重试。');
    } finally {
      setLoading(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      {error && <div className="error">{error}</div>}
      
      <input
        type="email"
        placeholder="邮箱"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        required
      />
      
      <input
        type="password"
        placeholder="密码"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        required
      />
      
      <button type="submit" disabled={loading}>
        {loading ? '登录中...' : '登录'}
      </button>
    </form>
  );
}

下一步

📚 更多资源

On this page