好了,各位同学,把手机收一收,把正在看的《甄嬛传》关掉。把你们的脑袋凑过来,就像我们在 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 传给服务器:
- URL 参数:最不安全,就像把家门钥匙挂在门把手上。
- Header:像特工那样,把钥匙藏在袖子里。
- 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 机制。
通常的做法是:
- 登录时,发给客户端两个 Token:
access_token: 短期有效(比如 15 分钟)。refresh_token: 长期有效(比如 7 天)。
- 当
access_token过期时,不要让用户重新登录。 - 客户端拦截那个失败的请求,悄悄用
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 失效的方法只有两种:
- 删除 Cookie:这是最常用的。用户下次再发请求,没有 Cookie,服务器返回 401,用户被迫重新登录。
- 撤销 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。
- 信任服务器,怀疑客户端:永远不要相信
formData.get('userId')是当前登录用户的 ID。 - Cookie 是好朋友,LocalStorage 是危险品:利用 Cookie 的自动携带特性,配合 HttpOnly 和 Secure 属性。
- Action 是堡垒:把所有的业务逻辑和权限验证都放在 Action 里,利用 React 19 的同步错误处理机制来拦截非法请求。
- Token 会过期:准备好 Refresh Token 的方案,不要让用户尴尬地看到“未授权”的弹窗。
- CSRF 是鬼:利用 React 的表单机制,或者 CSRF Token,保护你的数据。
React 19 的 Action 让我们离“全栈开发”更近了一步。以前我们要写两套逻辑:一套在服务端,一套在客户端。现在,Action 把它们统一了。
但是,Power comes with responsibility(力量伴随着责任)。
当你拥有了在服务端直接操作数据库、直接读写 Cookie 的能力时,你也拥有了破坏整个系统的能力。一个错误的 redirect,一个忘记检查权限的 delete 操作,或者一个被 XSS 注入的 Cookie,都可能让你的应用一夜之间变成黑客的玩具。
所以,请记住:无状态不是“没有状态”,而是“状态由 Token 定义”。而 React 19 的 Action,就是那个守护 Token 流转的忠诚卫士。
现在,去写代码吧。但别忘了,写完记得测试一下“Token 过期”的情况。因为那才是程序员的成人礼。
下课!