Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

身份验证 (Authentication)

本指南涵盖了身份验证模式,并展示了如何使用 TanStack Start 实现你自己的身份验证系统。

📋 开始之前: 请查看我们的 身份验证概览,了解所有可用选项,包括合作伙伴解决方案和托管服务。

身份验证方案

在 TanStack Start 应用程序中,你有多种身份验证选项:

托管解决方案:

  1. Clerk - 带有 UI 组件的完整身份验证平台

  2. WorkOS - 专注于企业级,具备 SSO 和合规特性

  3. Better Auth - 开源 TypeScript 库

  4. Auth.js - 支持 80 多个 OAuth 提供商的开源库

自定义实现 (DIY) 的优势:

身份验证涉及许多考量因素,包括密码安全、会话管理、速率限制、CSRF 保护以及各种攻击向量。

核心概念

身份验证 vs 授权

TanStack Start 通过服务器函数 (Server Functions)、会话 (Sessions) 和路由保护为这两者提供了工具。

核心构建块

1. 用于身份验证的服务器函数

服务器函数在服务器上安全地处理敏感的身份验证逻辑:

import { createServerFn } from "@tanstack/react-start";
import { redirect } from "@tanstack/react-router";

// 登录服务器函数
export const loginFn = createServerFn({ method: "POST" })
  .inputValidator((data: { email: string; password: string }) => data)
  .handler(async ({ data }) => {
    // 验证凭据(在此替换为你的身份验证逻辑)
    const user = await authenticateUser(data.email, data.password);

    if (!user) {
      return { error: "凭据无效" };
    }

    // 创建会话
    const session = await useAppSession();
    await session.update({
      userId: user.id,
      email: user.email,
    });

    // 重定向到受保护区域
    throw redirect({ to: "/dashboard" });
  });

// 注销服务器函数
export const logoutFn = createServerFn({ method: "POST" }).handler(async () => {
  const session = await useAppSession();
  await session.clear();
  throw redirect({ to: "/" });
});

// 获取当前用户
export const getCurrentUserFn = createServerFn({ method: "GET" }).handler(
  async () => {
    const session = await useAppSession();
    const userId = session.data.userId;

    if (!userId) {
      return null;
    }

    return await getUserById(userId);
  },
);

2. 会话管理

TanStack Start 提供了安全的 HTTP-only cookie 会话:

// utils/session.ts
import { useSession } from "@tanstack/react-start/server";

type SessionData = {
  userId?: string;
  email?: string;
  role?: string;
};

export function useAppSession() {
  return useSession<SessionData>({
    // 会话配置
    name: "app-session",
    password: process.env.SESSION_SECRET!, // 至少 32 个字符
    // 可选:自定义 cookie 设置
    cookie: {
      secure: process.env.NODE_ENV === "production",
      sameSite: "lax",
      httpOnly: true,
    },
  });
}

3. 身份验证上下文

在整个应用程序中共享身份验证状态:

// contexts/auth.tsx
import { createContext, useContext, ReactNode } from "react";
import { useServerFn } from "@tanstack/react-start";
import { getCurrentUserFn } from "../server/auth";

type User = {
  id: string;
  email: string;
  role: string;
};

type AuthContextType = {
  user: User | null;
  isLoading: boolean;
  refetch: () => void;
};

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }: { children: ReactNode }) {
  const { data: user, isLoading, refetch } = useServerFn(getCurrentUserFn);

  return (
    <AuthContext.Provider value={{ user, isLoading, refetch }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error("useAuth 必须在 AuthProvider 中使用");
  }
  return context;
}

4. 路由保护

使用 beforeLoad 保护路由:

// routes/_authed.tsx - 受保护页面的布局路由
import { createFileRoute, redirect } from "@tanstack/react-router";
import { getCurrentUserFn } from "../server/auth";

export const Route = createFileRoute("/_authed")({
  beforeLoad: async ({ location }) => {
    const user = await getCurrentUserFn();

    if (!user) {
      throw redirect({
        to: "/login",
        search: { redirect: location.href },
      });
    }

    // 将用户数据传递给子路由
    return { user };
  },
});
// routes/_authed/dashboard.tsx - 受保护路由
import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/_authed/dashboard")({
  component: DashboardComponent,
});

function DashboardComponent() {
  const { user } = Route.useRouteContext();

  return (
    <div>
      <h1>欢迎回来,{user.email}!</h1>
      {/* 仪表板内容 */}
    </div>
  );
}

实现模式

基础 邮箱/密码 身份验证

// server/auth.ts
import bcrypt from "bcryptjs";
import { createServerFn } from "@tanstack/react-start";

// 用户注册
export const registerFn = createServerFn({ method: "POST" })
  .inputValidator(
    (data: { email: string; password: string; name: string }) => data,
  )
  .handler(async ({ data }) => {
    // 检查用户是否已存在
    const existingUser = await getUserByEmail(data.email);
    if (existingUser) {
      return { error: "用户已存在" };
    }

    // 哈希处理密码
    const hashedPassword = await bcrypt.hash(data.password, 12);

    // 创建用户
    const user = await createUser({
      email: data.email,
      password: hashedPassword,
      name: data.name,
    });

    // 创建会话
    const session = await useAppSession();
    await session.update({ userId: user.id });

    return { success: true, user: { id: user.id, email: user.email } };
  });

async function authenticateUser(email: string, password: string) {
  const user = await getUserByEmail(email);
  if (!user) return null;

  const isValid = await bcrypt.compare(password, user.password);
  return isValid ? user : null;
}

基于角色的访问控制 (RBAC)

// utils/auth.ts
export const roles = {
  USER: "user",
  ADMIN: "admin",
  MODERATOR: "moderator",
} as const;

type Role = (typeof roles)[keyof typeof roles];

export function hasPermission(userRole: Role, requiredRole: Role): boolean {
  const hierarchy = {
    [roles.USER]: 0,
    [roles.MODERATOR]: 1,
    [roles.ADMIN]: 2,
  };

  return hierarchy[userRole] >= hierarchy[requiredRole];
}

// 带有角色检查的受保护路由
export const Route = createFileRoute("/_authed/admin/")({
  beforeLoad: async ({ context }) => {
    if (!hasPermission(context.user.role, roles.ADMIN)) {
      throw redirect({ to: "/unauthorized" });
    }
  },
});

社交平台身份验证集成

// OAuth 提供商示例
export const authProviders = {
  google: {
    clientId: process.env.GOOGLE_CLIENT_ID!,
    redirectUri: `${process.env.APP_URL}/auth/google/callback`,
  },
  github: {
    clientId: process.env.GITHUB_CLIENT_ID!,
    redirectUri: `${process.env.APP_URL}/auth/github/callback`,
  },
};

export const initiateOAuthFn = createServerFn({ method: "POST" })
  .inputValidator((data: { provider: "google" | "github" }) => data)
  .handler(async ({ data }) => {
    const provider = authProviders[data.provider];
    const state = generateRandomState();

    // 将 state 存入会话以进行 CSRF 保护
    const session = await useAppSession();
    await session.update({ oauthState: state });

    // 生成 OAuth URL
    const authUrl = generateOAuthUrl(provider, state);

    throw redirect({ href: authUrl });
  });

密码重置流程

// 密码重置请求
export const requestPasswordResetFn = createServerFn({ method: "POST" })
  .inputValidator((data: { email: string }) => data)
  .handler(async ({ data }) => {
    const user = await getUserByEmail(data.email);
    if (!user) {
      // 不要泄露邮箱是否存在
      return { success: true };
    }

    const token = generateSecureToken();
    const expires = new Date(Date.now() + 60 * 60 * 1000); // 1 小时后过期

    await savePasswordResetToken(user.id, token, expires);
    await sendPasswordResetEmail(user.email, token);

    return { success: true };
  });

// 密码重置确认
export const resetPasswordFn = createServerFn({ method: "POST" })
  .inputValidator((data: { token: string; newPassword: string }) => data)
  .handler(async ({ data }) => {
    const resetToken = await getPasswordResetToken(data.token);

    if (!resetToken || resetToken.expires < new Date()) {
      return { error: "令牌无效或已过期" };
    }

    const hashedPassword = await bcrypt.hash(data.newPassword, 12);
    await updateUserPassword(resetToken.userId, hashedPassword);
    await deletePasswordResetToken(data.token);

    return { success: true };
  });

安全最佳实践

1. 密码安全

// 使用强哈希算法 (bcrypt, scrypt, 或 argon2)
import bcrypt from "bcryptjs";

const saltRounds = 12; // 根据你的安全需求进行调整
const hashedPassword = await bcrypt.hash(password, saltRounds);

2. 会话安全

// 使用安全的会话配置
export function useAppSession() {
  return useSession({
    name: "app-session",
    password: process.env.SESSION_SECRET!, // 32 位以上字符
    cookie: {
      secure: process.env.NODE_ENV === "production", // 仅在生产环境中使用 HTTPS
      sameSite: "lax", // CSRF 保护
      httpOnly: true, // XSS 保护
      maxAge: 7 * 24 * 60 * 60, // 7 天
    },
  });
}

3. 速率限制 (Rate Limiting)

// 简单的内存速率限制(生产环境请使用 Redis)
const loginAttempts = new Map<string, { count: number; resetTime: number }>();

export const rateLimitLogin = (ip: string): boolean => {
  const now = Date.now();
  const attempts = loginAttempts.get(ip);

  if (!attempts || now > attempts.resetTime) {
    loginAttempts.set(ip, { count: 1, resetTime: now + 15 * 60 * 1000 }); // 15 分钟
    return true;
  }

  if (attempts.count >= 5) {
    return false; // 尝试次数过多
  }

  attempts.count++;
  return true;
};

4. 输入验证

import { z } from "zod";

const loginSchema = z.object({
  email: z.string().email().max(255),
  password: z.string().min(8).max(100),
});

export const loginFn = createServerFn({ method: "POST" })
  .inputValidator((data) => loginSchema.parse(data))
  .handler(async ({ data }) => {
    // 此时 data 已经过验证
  });

测试身份验证 (Testing Authentication)

服务器函数单元测试

// __tests__/auth.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { loginFn } from "../server/auth";

describe("身份验证", () => {
  beforeEach(async () => {
    // 设置测试数据库
    await setupTestDatabase();
  });

  it("使用有效的凭据应当登录成功", async () => {
    const result = await loginFn({
      data: { email: "test@example.com", password: "password123" },
    });

    expect(result.error).toBeUndefined();
    expect(result.user).toBeDefined();
  });

  it("使用无效的凭据应当拒绝登录", async () => {
    const result = await loginFn({
      data: { email: "test@example.com", password: "wrongpassword" },
    });

    expect(result.error).toBe("凭据无效");
  });
});

集成测试

// __tests__/auth-flow.test.tsx
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { router } from "../router";

describe("身份验证流程", () => {
  it("访问受保护路由时应当重定向到登录页面", async () => {
    const history = createMemoryHistory();
    history.push("/dashboard"); // 受保护路由

    render(<RouterProvider router={router} history={history} />);

    await waitFor(() => {
      expect(screen.getByText("登录")).toBeInTheDocument();
    });
  });
});

常见模式

加载状态 (Loading States)

function LoginForm() {
  const [isLoading, setIsLoading] = useState(false);
  const loginMutation = useServerFn(loginFn);

  const handleSubmit = async (data: LoginData) => {
    setIsLoading(true);
    try {
      await loginMutation.mutate(data);
    } catch (error) {
      // 处理错误
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* 表单字段 */}
      <button disabled={isLoading}>{isLoading ? "正在登录..." : "登录"}</button>
    </form>
  );
}

“记住我”功能 (Remember Me)

export const loginFn = createServerFn({ method: "POST" })
  .inputValidator(
    (data: { email: string; password: string; rememberMe?: boolean }) => data,
  )
  .handler(async ({ data }) => {
    const user = await authenticateUser(data.email, data.password);
    if (!user) return { error: "凭据无效" };

    const session = await useAppSession();
    await session.update(
      { userId: user.id },
      {
        // 如果勾选了“记住我”,则延长会话有效期
        maxAge: data.rememberMe ? 30 * 24 * 60 * 60 : undefined, // 30 天 vs 仅限会话期间
      },
    );

    return { success: true };
  });

运行示例

通过学习这些实现来理解不同的身份验证模式:

从其他方案迁移

从客户端验证迁移

如果你正在从客户端身份验证(仅使用 localStorage 或 Context)迁移:

  1. 将身份验证逻辑移至服务器函数 (Server Functions)

  2. 使用服务器会话 (Sessions) 替换 localStorage

  3. 更新路由保护逻辑,使用 beforeLoad

  4. 添加适当的安全响应头和 CSRF 保护

从其他框架迁移

生产环境考量

在选择身份验证方法时,请考虑以下因素:

托管方案 vs 自定义实现对比

托管解决方案 (Clerk, WorkOS, Better Auth):

自定义实现 (DIY):

安全注意事项

身份验证系统需要处理各种安全层面:

下一步

在实现身份验证时,请考虑:

有关其他身份验证方法,请查看 身份验证概览。如需具体的集成帮助,请探索我们的 运行示例