React 19 Actions 中的安全令牌处理:在无状态后端环境下通过 Action 实现安全的认证状态流转

好了,各位同学,把手机收一收,把正在看的《甄嬛传》关掉。把你们的脑袋凑过来,就像我们在 1999 年那个没有 React 的日子里,围在 CRT 显示器前听那位满嘴跑火车的老师傅讲硬件一样。

今天我们要聊的话题,比“如何在不发际线后移的情况下写代码”更重要,也比“为什么我女朋友生气了”更难搞。

我们要聊聊 React 19 Actions 中的安全令牌处理

为什么是 React 19?因为以前我们处理认证,那是“见招拆招”。用户点一下,我们发个请求,带上 Token,拿回数据,搞定。但现在,React 19 把服务器逻辑搬到了客户端,把异步操作变成了原生的 API。这就像是你把家里的金库密码直接贴在了大门上(当然夸张了点),但至少,现在我们有了“指纹锁”的便利,同时还得防止有人试图用橡皮泥来复制指纹。

我们今天的主题是:在无状态后端环境下通过 Action 实现安全的认证状态流转

听起来很高大上?别怕,其实就是教你如何像特工一样处理你的登录态。


第一部分:无状态,听起来很酷,实际上像个健忘症

首先,我们要搞清楚“无状态后端”是个什么鬼。现代 Web 架构,尤其是微服务那帮人推崇的,基本都是无状态的。这意味着服务器不记性。它不记得你昨天喝了咖啡,也不记得你今天想买咖啡。它只在乎你给它一张票,这张票上写着“你可以进来”。

这张票,就是 Token

在 React 19 出来之前,我们用 fetch。现在,我们用 Action

React 19 的 Action 是什么?它不仅仅是一个异步函数,它是服务器和浏览器之间的一条秘密隧道。浏览器发起一个提交(比如登录按钮被点击),React 自动把这个请求通过这条隧道送到了服务器。服务器执行逻辑,然后……嘿!服务器可以直接修改你的状态,不需要你再发一个请求去刷新页面。这就是所谓的“同步状态更新”。

但是,这里有个巨大的坑。

坑在哪里?

如果你的后端是无状态的,你的服务器压根不知道你是谁。它只看 Token。而 Token 在哪里?在客户端。

如果你在浏览器里写代码:

// 这是一个非常糟糕的示例,不要这样做
const formData = new FormData();
formData.append('username', 'evil_hacker');
formData.append('password', '123456');

// 恶意调用,服务器不知道这是谁发起的(除非有 CSRF 保护)
await myAction(formData);

如果服务器没有做 CSRF(跨站请求伪造)防护,这个请求可能看起来就像是一个正常的用户发出来的。这就是为什么我们要小心翼翼地处理 Token。


第二部分:Token 的“生死轮回”

在 React 19 中,处理 Token 最核心的挑战在于:服务器不知道请求是从哪里来的,除非我们告诉它。

我们有几种选择把 Token 传给服务器:

  1. URL 参数:最不安全,就像把家门钥匙挂在门把手上。
  2. Header:像特工那样,把钥匙藏在袖子里。
  3. Cookie:最标准,浏览器自动帮你管理,但要注意 CSRF。

在无状态环境下,我们通常使用 JWT (JSON Web Token) 或者某种加密的 Session Token

让我们看看怎么在 React 19 的 Action 里把这个流程跑通。

1. 登录:把钥匙拿回来

假设我们的登录 Action 叫 login

// 这是一个典型的 Server Action
// 注意:这通常运行在服务端(Server Component 或 Server Action)
export async function login(formData: FormData) {
  const username = formData.get('username') as string;
  const password = formData.get('password') as string;

  // 1. 验证凭证
  const user = await verifyCredentials(username, password);

  if (!user) {
    // 拒绝访问!
    throw new Error('Invalid credentials');
  }

  // 2. 生成 Token
  // 这里的 token 包含了用户的 ID、过期时间等信息
  const token = await generateToken({
    id: user.id,
    role: user.role
  });

  // 3. 返回数据
  // React 19 Action 返回的数据,会自动处理为 JSON 响应
  return {
    token: token, // 给客户端
    user: user // 给客户端(仅包含非敏感信息)
  };
}

看懂了吗?服务器在这里执行验证。如果验证失败,Action 直接抛出 Error。React 会捕获这个错误,并显示在表单上,而不会刷新页面。这就是“客户端无感知”的魔力。


第三部分:如何把 Token 安全地传给服务器?

现在问题来了。login Action 已经生成了 Token。我们怎么把这个 Token 存到浏览器里,并且确保后续的请求能带上它?

方案 A:直接存储在 LocalStorage 或 Cookie

这是最常见的。但是,React 19 的 Action 不仅仅在客户端运行,它也在服务器运行。

如果你在 React 19 的 Server Component 或者 Server Action 里想获取当前的 Token,你不能直接去 document.cookie(因为服务端没有 DOM)。

React 19 提供了一些机制(比如 TanStack Start 的 createServerActionClient,或者 Next.js 的 cookies())。

我们来看看登录成功后,React 客户端组件应该怎么处理返回的 Token。

// 客户端组件
'use client';

import { useForm } from 'react-dom';
import { login } from './actions'; // 引用上面的 Server Action

export default function LoginForm() {
  const { data, formAction, error } = useForm(login);

  return (
    <form action={formAction}>
      <input name="username" type="text" placeholder="Username" />
      <input name="password" type="password" placeholder="Password" />
      <button type="submit">Log In</button>
      {error && <p className="error">{error.message}</p>}
      {data && (
        <div>
          <p>Welcome back, {data.user.name}</p>
          <p>Your Token: {data.token}</p>
          {/* 这里处理 Token 存储 */}
        </div>
      )}
    </form>
  );
}

注意! 这里的 data.token 是原始字符串。绝对不要把原始 Token 存在 localStorage 里!为什么?因为 XSS(跨站脚本攻击)。

如果有一个黑客写的脚本在你的网站上运行,它可以轻松地读取 localStorage 里的所有数据,然后偷偷把你的 Token 发回给黑客的服务器。

正确的姿势:Cookie。

不要手动把 Token 写进 Cookie。使用一个库,比如 js-cookie 或者 React 19 推荐的自动处理机制。

import Cookies from 'js-cookie';

// 登录成功后的处理逻辑
if (data.token) {
  // 将 Token 设置为 HttpOnly Cookie(服务器端可读,前端不可读)
  // Secure: 仅在 HTTPS 下传输
  // SameSite: 防止 CSRF
  Cookies.set('auth_token', data.token, {
    expires: 1 / 24, // 1天
    path: '/',
    secure: true,
    sameSite: 'strict'
  });

  // 跳转到首页
  window.location.href = '/';
}

现在,Token 存在了浏览器的 Cookie 里。下次你调用任何需要认证的 Action 时,浏览器会自动把 Cookie 加到请求头里。你甚至不需要在代码里显式地手动添加头信息。


第四部分:Token 的轮转与刷新 —— 程序员的噩梦

Token 是有有效期的。就像你的学生证一样,过期了就得补办。

假设你的 Token 有效期是 1 小时。用户登录了,开始工作。1 小时后,Token 过期了。用户点击“保存”按钮,服务器说:“嘿,这票过期了,滚蛋。”

如果此时页面刷新,用户就被踢出去了。用户体验极差。

我们需要 Refresh Token 机制

通常的做法是:

  1. 登录时,发给客户端两个 Token:
    • access_token: 短期有效(比如 15 分钟)。
    • refresh_token: 长期有效(比如 7 天)。
  2. access_token 过期时,不要让用户重新登录。
  3. 客户端拦截那个失败的请求,悄悄用 refresh_token 换一个新的 access_token,然后重试原来的请求。

在 React 19 中,我们可以利用 redirect 和错误处理来优雅地处理这个流程。

// 服务器端的 Action
export async function saveUserData(formData: FormData) {
  // 1. 检查 Cookie 里的 access_token 是否有效
  const token = await getAccessTokenFromCookie(); // 假设这个函数读取 Cookie

  if (!token) {
    // 没有 Token?可能是过期了
    // 2. 尝试用 refresh_token 刷新
    const refreshToken = await getRefreshTokenFromCookie();

    if (!refreshToken) {
      // 都没有,去登录吧
      throw redirect('/login');
    }

    try {
      const newToken = await refreshAccessToken(refreshToken);
      // 更新 Cookie
      await setAccessTokenCookie(newToken);
      // 继续执行保存逻辑(这里需要把 Token 重新获取一下,因为上面可能改变了状态)
      const tokenAgain = await getAccessTokenFromCookie();
      return await executeSaveLogic(tokenAgain);
    } catch (e) {
      // 刷新也失败了,说明 refresh_token 也过期了,彻底凉凉
      throw redirect('/login?error=expired');
    }
  }

  // 3. 正常执行
  return await executeSaveLogic(token);
}

这代码看起来有点乱,对吧?这就是为什么“认证状态流转”这么复杂。因为你在处理“成功”和“失败”两种截然不同的路径,还要处理“失败后自我修复”的路径。

React 19 的简化:

React 19 允许你在客户端拦截表单提交。你可以写一个 Client Action,先尝试刷新 Token,然后再提交表单。

'use client';

import { useActionState } from 'react';
import { login } from './actions';
import { refreshTokens } from './auth-actions';

export default function AuthForm() {
  // 使用 useActionState 包装你的逻辑
  const [state, formAction, isPending] = useActionState(async (prevState, formData) => {
    // 1. 先尝试刷新 Token(如果需要)
    await ensureValidToken();

    // 2. 执行实际的登录逻辑
    return await login(prevState, formData);
  }, null);

  return (
    <form action={formAction}>
      {/* ... */}
    </form>
  );
}

虽然这种写法让逻辑有点“粘稠”,但它保证了在服务器端执行任何逻辑之前,Token 一定是最新的。这就是 防御性编程 的精神。


第五部分:Action 里的安全守卫

在 React 19 中,最强大的功能之一是:你可以在服务器端直接进行验证,而无需担心客户端篡改。

传统的方式是:前端发请求 -> 后端验证 -> 返回数据 -> 前端判断。

现在,React 19 Action 是同步执行逻辑的。如果你在 Action 里抛出一个错误,React 就能捕获到。

所以,不要依赖客户端传来的数据来判断权限!

// 这是一个很典型的 Server Action
export async function deleteAccount(userId: string) {
  // 危险!这里千万不要相信 userId 是当前登录用户
  // 因为用户可以在浏览器控制台把 userId 改成 "admin"

  // 正确的做法:
  const currentUser = await getCurrentUserFromSession(); // 从 Cookie 或 Session 中获取当前真实身份

  if (currentUser.id !== userId) {
    throw new Error("Unauthorized: You can only delete your own account.");
  }

  // 执行删除
  await db.users.delete(userId);
  return { success: true };
}

这就是 React 19 带来的最大红利。安全逻辑现在运行在服务端,就像堡垒一样坚固。 客户端传什么都不重要,重要的是服务器内部发生了什么。


第六部分:CSRF 攻击 —— 那个拿着饼干的小偷

即使有了 Cookie 和 Token,我们还有一个敌人:CSRF(跨站请求伪造)。

想象一下,你在一个银行网站(比如 bank.com)登录了,浏览器自动保存了你的 auth_token Cookie。然后你顺便点了一个钓鱼邮件里的链接(evil.com)。

如果 evil.com 上的图片标签是这样的:

<img src="https://bank.com/api/transfer?amount=1000&to=hacker">

由于浏览器认为你在浏览 bank.com,它默认会把 auth_token Cookie 发送过去。于是,黑客的脚本就悄悄转移了你的 1000 块钱。

在 React 19 中怎么防?

React 19 的表单提交是事件驱动的,不是直接构造 URL 发送请求的。这天然地防止了简单的 <img> 标签攻击。

但是,如果你使用了 JavaScript 的 fetch 或者直接构造 form action,你仍然需要防范 CSRF。

最佳实践:双重提交 Cookie 模式。

服务器在生成 Token 时,同时也生成一个 CSRF Token
服务器把这个 CSRF Token 放在 Cookie 里。
前端提交表单时,必须把这个 Token 放在请求头里。

由于 React 19 的 Action 是服务器端的,你可以在 Action 开头验证这个 Token。

import { cookies } from 'next/headers'; // 或者类似的环境

export async function riskyAction(formData: FormData) {
  const cookieStore = cookies();

  // 1. 从请求头获取客户端提交的 CSRF Token
  const clientToken = formData.get('csrf_token');

  // 2. 从 Cookie 获取服务端存储的 CSRF Token
  const serverToken = cookieStore.get('csrf_token')?.value;

  if (clientToken !== serverToken) {
    throw new Error('CSRF Validation Failed');
  }

  // ... 执行逻辑
}

当然,很多框架(如 Next.js App Router)已经内置了 CSRF 保护,你只需要在配置里开启即可。


第七部分:实战演练 —— 一个完整的认证系统架构

让我们把这些碎碎念整合起来。假设我们使用 Next.js + TanStack Start 或者 Next.js 15 的架构。

1. 配置 Cookie 策略

首先,我们需要定义如何管理 Cookie。

// auth.ts
import { cookies } from 'next/headers';

export async function getAccessToken(): Promise<string | undefined> {
  const cookieStore = await cookies();
  return cookieStore.get('auth_token')?.value;
}

export async function setAccessToken(token: string) {
  const cookieStore = await cookies();
  cookieStore.set({
    name: 'auth_token',
    value: token,
    httpOnly: true, // 防 XSS
    secure: process.env.NODE_ENV === 'production', // HTTPS
    sameSite: 'lax', // 防 CSRF
    maxAge: 60 * 15, // 15分钟
    path: '/',
  });
}

export async function clearTokens() {
  const cookieStore = await cookies();
  cookieStore.delete('auth_token');
  cookieStore.delete('refresh_token');
}

2. 登录 Action

// actions.ts
import { setAccessToken, clearTokens } from './auth';

export async function login(formData: FormData) {
  const username = formData.get('username');
  const password = formData.get('password');

  const res = await fetch('https://api.example.com/login', {
    method: 'POST',
    body: JSON.stringify({ username, password }),
  });

  if (!res.ok) throw new Error('Login failed');

  const { accessToken, refreshToken } = await res.json();

  // 将 Token 写入 Cookie
  await setAccessToken(accessToken);

  // 这里通常还会把 refreshToken 也存起来,用于后续刷新

  return { success: true };
}

3. 需要认证的 Action

这是核心。我们要确保只有登录了的人能做这件事。

export async function createPost(content: string) {
  // 获取当前 Token
  const token = await getAccessToken();

  if (!token) {
    // 没有登录?那就重定向去登录,并带上返回的 URL
    // 这种重定向是 React 19 特有的魔力,它会中断 Action 的执行流程
    throw redirect('/login?callbackUrl=' + encodeURIComponent('/dashboard'));
  }

  // 发送请求
  const res = await fetch('https://api.example.com/posts', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${token}`, // 手动添加 Header
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ content }),
  });

  if (res.status === 401) {
    // Token 过期了
    // 这里可以做自动刷新逻辑,或者直接报错
    throw new Error('Session expired');
  }

  if (!res.ok) throw new Error('Failed to create post');

  return await res.json();
}

第八部分:登出 —— 如何物理消除存在感

当用户点击登出时,我们通常不会真的去后端“注销”用户(因为无状态服务器不记事,注销了也无所谓)。我们只需要让 Token 失效

在无状态环境下,让 Token 失效的方法只有两种:

  1. 删除 Cookie:这是最常用的。用户下次再发请求,没有 Cookie,服务器返回 401,用户被迫重新登录。
  2. 撤销 Token:如果服务器使用了黑名单机制。

让我们看看 React 19 的登出 Action:

import { redirect } from 'next/navigation'; // 或者 redirect from react-router-dom

export async function logout() {
  // 1. 清除本地 Cookie
  await clearTokens();

  // 2. 可选:如果后端支持,发送请求通知服务器撤销这个 Token
  // await fetch('https://api.example.com/logout', { method: 'POST' });

  // 3. 重定向
  redirect('/login');
}

注意: 在 React 19 中,redirect 是一个在服务端执行的函数。如果你在 Client Component 里调用 redirect,React 19 会自动帮你处理。

'use client';
import { logout } from './actions';

export function LogoutButton() {
  return (
    <button onClick={() => logout()}>
      Log Out
    </button>
  );
}

第九部分:React 19 的“热力图”与调试

写代码是快乐的,调试 Token 失效是痛苦的。React 19 的 Action 带来了一个很棒的工具:状态热力图

当你提交一个表单时,useFormStatus 可以告诉你 Action 正在运行。这让你知道你的逻辑是否真的执行了,还是卡在了某个地方。

import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Saving...' : 'Save Changes'}
    </button>
  );
}

虽然这不能直接帮你调试 Token 问题,但它能让你确信你的请求真的发到了服务器。


第十部分:终极总结与哲学思考

好了,同学们,时间差不多了。

我们今天学了什么?
我们学了如何在 React 19 的 Action 体系下,像个老练的黑客一样处理 Token。

  1. 信任服务器,怀疑客户端:永远不要相信 formData.get('userId') 是当前登录用户的 ID。
  2. Cookie 是好朋友,LocalStorage 是危险品:利用 Cookie 的自动携带特性,配合 HttpOnly 和 Secure 属性。
  3. Action 是堡垒:把所有的业务逻辑和权限验证都放在 Action 里,利用 React 19 的同步错误处理机制来拦截非法请求。
  4. Token 会过期:准备好 Refresh Token 的方案,不要让用户尴尬地看到“未授权”的弹窗。
  5. CSRF 是鬼:利用 React 的表单机制,或者 CSRF Token,保护你的数据。

React 19 的 Action 让我们离“全栈开发”更近了一步。以前我们要写两套逻辑:一套在服务端,一套在客户端。现在,Action 把它们统一了。

但是,Power comes with responsibility(力量伴随着责任)

当你拥有了在服务端直接操作数据库、直接读写 Cookie 的能力时,你也拥有了破坏整个系统的能力。一个错误的 redirect,一个忘记检查权限的 delete 操作,或者一个被 XSS 注入的 Cookie,都可能让你的应用一夜之间变成黑客的玩具。

所以,请记住:无状态不是“没有状态”,而是“状态由 Token 定义”。而 React 19 的 Action,就是那个守护 Token 流转的忠诚卫士。

现在,去写代码吧。但别忘了,写完记得测试一下“Token 过期”的情况。因为那才是程序员的成人礼。

下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注