12k
All articles

如何为 Astro 站点添加身份验证

使用 adapter、middleware、Astro.locals、sessions、rewrite 和 Actions 配置 Astro 认证,涵盖登录、退出与路由保护。

OpenReplay Team
OpenReplay Team
如何为 Astro 站点添加身份验证

Astro 中的身份验证既取决于你选择哪个库,也同样取决于页面以何种方式渲染。默认情况下,Astro 会在构建时预渲染项目中的页面,你需要为单独的路由选择退出这一行为。预渲染页面是在任何访客出现之前就生成好的,因此不存在请求、不存在 cookie 头,也不存在可读取的 session。

正因如此,第一次尝试常常悄无声息地失败。你安装了一个 auth 库,把它的中间件粘贴进 src/middleware.ts,加载 /account,结果 Astro.locals 是个空对象。没有报错,没有日志,也没有可供搜索的堆栈跟踪。

本文按顺序、逐个文件地走完整条链路:adapter、每个路由的 prerender 导出、负责解析用户的中间件、读取用户的页面、使用 rewrite() 进行路由保护、以 Astro Actions 实现登录与登出,以及客户端 island 无法再看到这一切的边界。示例代码面向 Astro 7.x。Astro 6 将 Node 的最低版本提升到了 22,并放弃了对 Node 18 和 20 的支持,因此请运行 Node 22.12.0 或更高版本。

核心要点

  • Astro 默认对整个项目进行预渲染,因此任何需要读取 session 的页面都必须通过 export const prerender = false 选择退出,而按需渲染需要一个 adapter。
  • 如果你的中间件看起来毫无作用,那么它本应保护的那个路由几乎肯定仍处于预渲染状态。
  • Astro.locals 的生命周期恰好只有一次路由渲染;需要延续到下一个请求的数据应放在 Astro.session 中,而这需要一个 session driver。
  • Astro Actions 是可被公开访问的 HTTP 端点,因此每个 handler 都需要自己的 context.locals 检查;保护了渲染表单的页面,并不等于保护了其背后的 action。
  • Astro.locals 和 Astro.session 仅存在于服务端,因此带有 client:* 指令的组件只能看到页面通过 props 传给它的内容。

为什么”默认静态”会破坏 Astro 的身份验证?

除非某个路由另有要求,否则 Astro 项目会构建为静态 HTML,而静态 HTML 是在构建时一次性生成、面向所有访客的。对整个项目进行预渲染是有文档记载的默认行为,正如按需渲染指南所述。session 存在于请求头中,而当这些 HTML 被写入磁盘时,该请求头尚不存在,因此 cookie 读取、Astro.request,以及中间件放到 locals 上的任何内容都无从附着。

实际后果是:中间件看起来从未运行,这是渲染模式的症状,而不是 auth 库的 bug。

如何启用按需渲染?

按需渲染需要两样东西:一个 adapter,用于为目标运行时生成服务器;以及按路由的选择退出。Astro 的官方 adapter 有 @astrojs/cloudflare、@astrojs/netlify、@astrojs/node 和 @astrojs/vercel,此外还有社区 adapter,adapter 选项在配置参考中有说明。用 npx astro add node 安装其中一个,并查阅该 adapter 自己的文档页面,因为各自的配置项有所不同。

然后选择一种站点形态。output 选项只接受两个值:'static' 和 'server'。

站点形态astro.config.mjs页面 frontmatter
内容型站点,仅少数页面需要登录安装 adapter,保留默认的 output: 'static'在每个读取 session 的路由上写 export const prerender = false
以登录态为主的应用安装 adapter,设置 output: 'server'在营销页和内容路由上写 export const prerender = true

无论选择哪种,规则都一样:读取 session 的路由必须按需渲染,而 Node adapter 文档涵盖了此处示例所涉及的运行时细节。

中间件:src/middleware.ts

中间件在每个请求中解析一次用户,并把结果交给下游的一切。将文件放在 src/middleware.js|ts,或者如果你偏好目录结构,放在 src/middleware/index.js|ts,并提供一个名为 onRequest 的具名导出。默认导出不会被识别,中间件指南对这条规则有明确说明。

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';

export const onRequest = defineMiddleware(async (context, next) => {
  const stored = (await context.session?.get('user')) ?? null;
  context.locals.user = stored as User | null;
  return next();
});

这里读取的是 Astro 内置的 session,而不是手工解析 cookie。正如 Astro 的 sessions 指南所述,同一个 session 会以两个名字出现:页面和组件通过 Astro.session 访问它,而中间件、API 端点和 action handler 则从 context.session 获取。存储并非自动配置。有三个 adapter 会为你选定默认 driver:Node、Cloudflare 和 Netlify;使用其他 adapter 时,你需要按照 session driver 参考自行指定。在部署到 edge 运行时之前,有一个约束值得了解:session 在 edge 中间件中无法工作。

在 src/env.d.ts 中通过扩展 App 命名空间来为 locals 添加类型:

// src/env.d.ts
type User = {
  id: string;
  email: string;
};

declare namespace App {
  interface Locals {
    user: User | null;
  }
}

使用 Astro.locals 读取用户

只要页面是按需渲染的,它就能读取中间件所赋的值。Astro.locals 的生命周期恰好只有一次路由渲染,因此它适合把用户对象从中间件带到页面,却不适合存放任何需要延续到下一个请求的内容。

---
// src/pages/account.astro
/* On-demand rendering */ export const prerender = false;

const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
---
<h1>Signed in as {user.email}</h1>

删掉第一行,这个页面就会回到预渲染集合中:它会被构建成静态 HTML,Astro.locals.user 永远不会被填充,每位访客拿到的都是同一个文件。这一行,就是身份验证正常工作与”谁也过不去的登录界面”之间的分界线。

如何用 context.rewrite() 保护路由?

保护路由的做法是:把校验关口集中到中间件中,并就地渲染登录页,而不是重定向过去。context.rewrite('/login') 会在访客请求的那个 URL 上展示不同的内容,从而让受保护的路径继续留在地址栏里。

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';

const protectedPaths = ['/account', '/dashboard'];

export const onRequest = defineMiddleware(async (context, next) => {
  const stored = (await context.session?.get('user')) ?? null;
  context.locals.user = stored as User | null;

  const needsAuth = protectedPaths.some((path) =>
    context.url.pathname.startsWith(path),
  );

  if (needsAuth && !context.locals.user) {
    return context.rewrite('/login');
  }

  return next();
});

由于 rewrite 会重新开始渲染,你的中间件在这个过程中会再运行一次,因此务必把 /login 排除在 protectedPaths 之外,否则第二次执行会在同一处检查上失败并陷入循环。这类身份验证故障很少会抛出异常:对登录流程做 session replay,会看到用户在登录页和受保护路由之间反复跳转——从外部看,作用域配置错误的 session cookie,或者放错分支的校验关口,正是这副样子。

在这里应避免另一种 rewrite 形式。当你给 next() 传入一个 Request 时,Astro 会基于旧请求构造一个替代请求,此后(或此前)任何读取请求体的尝试都会在运行时抛出异常。当 Action 由 HTML 表单驱动时,这一点的杀伤力最大,这也是文档引导你改用 context.rewrite() 或 Astro.rewrite() 的原因。

用 Astro Actions 实现登录与登出

Astro Actions 于 astro@4.15 引入,是处理登录和登出的内置方式,可以取代手写的 API 路由。在 src/actions/index.ts 导出的 server 对象中定义它们,设置 accept: 'form',然后用纯 HTML 向其提交。

// src/actions/index.ts
import { ActionError, defineAction } from 'astro:actions';
import { z } from 'astro/zod';

// Replace with your own credential lookup.
async function verifyCredentials(email: string, password: string): Promise<User | null> {
  return null;
}

export const server = {
  login: defineAction({
    accept: 'form',
    input: z.object({
      email: z.email({ error: 'Enter a valid email address.' }),
      password: z.string(),
    }),
    handler: async ({ email, password }, context) => {
      const user = await verifyCredentials(email, password);
      if (!user) throw new ActionError({ code: 'UNAUTHORIZED' });

      await context.session?.regenerate();
      await context.session?.set('user', user);
      return { ok: true };
    },
  }),

  logout: defineAction({
    accept: 'form',
    handler: async (_input, context) => {
      await context.session?.destroy();
      return { ok: true };
    },
  }),

  deleteAccount: defineAction({
    accept: 'form',
    handler: async (_input, context) => {
      if (!context.locals.user) throw new ActionError({ code: 'UNAUTHORIZED' });
      return { ok: true };
    },
  }),
};

z 来自 astro/zod,它重新导出了 Zod v4,因此像 z.email() 这样的顶层校验器以及用于自定义消息的 error 键是当前的写法。登录时重新生成 session ID 可以防范 session fixation 攻击,而 destroy() 会清除 cookie 并丢弃服务端保存的数据。

注意 deleteAccount。Actions 是拥有各自 URL 的公开可访问端点,因此任何人都可以直接调用,而无需加载渲染其表单的那个页面。每个接触用户数据的 handler 都要自行检查 context.locals。

页面这一侧则是一个表单加上对结果的读取:

---
// src/pages/login.astro
/* On-demand rendering */ export const prerender = false;
import { actions } from 'astro:actions';

const result = Astro.getActionResult(actions.login);
if (result && !result.error) return Astro.redirect('/account');
---
{result?.error && <p class="error">Those details did not match.</p>}

<form method="POST" action={actions.login}>
  <input type="email" name="email" required />
  <input type="password" name="password" required />
  <button>Log in</button>
</form>

登出的形式相同:<form method="POST" action={actions.logout}>,不需要任何客户端 JavaScript。

Island 陷阱:Island 永远看不到 locals

Astro.locals 和 Astro.session 仅存在于服务端。中间件、.astro 页面与布局、API 路由和 action handler 都运行在服务端并共享它们。带有 client:* 指令的组件会在浏览器中 hydrate,位于这条链路之外,只能看到页面通过 props 传给它的内容。

---
// Renders logged-out UI forever. The island cannot reach locals.
import UserMenu from '../components/UserMenu.jsx';
---
<UserMenu client:load />
---
// Correct: the page reads locals on the server and passes the value down.
import UserMenu from '../components/UserMenu.jsx';
const user = Astro.locals.user;
---
<UserMenu client:load user={user} />

出错的那个版本不会抛出任何异常。页面渲染的是登录态内容,而它旁边的 island 却渲染出一个登录按钮——这种不一致只能在视觉上被发现。

本示例使用的是 Astro 内置的 sessions,而换成第三方库时,同样的顺序依然成立:Astro 的身份验证指南推荐了 Better Auth 和 Clerk 等 auth 库来实现邮箱登录和 OAuth,而 Better Auth 与框架无关的设计也适用于 Astro,正如我们的 BetterAuth 概览所解释的。无论你选择哪一个,它依然需要一个 adapter、一个非预渲染的路由,以及一个会写入 context.locals 的中间件。

小结

Astro 中的身份验证是一条链条,而最脆弱的一环就在最前端:没有 adapter、没有 prerender = false,就没有请求,下游的一切都会悄无声息地什么也不做。先把渲染模式弄对,然后是中间件,再然后是为每个 action handler 加上检查。打开按需渲染指南,对照你的 astro.config.mjs,安装一个 adapter,并给第一个需要用户信息的页面加上 export const prerender = false。

常见问题

Astro Actions 能在预渲染页面上工作吗?

不能。页面必须按需渲染,才能通过表单的 action 调用 action,所以要给包含该表单的页面加上 'export const prerender = false',并安装一个 adapter 以便有服务器来运行 handler。Action 的请求体还有 1 MB(1048576 字节)的默认上限;如果某个 handler 需要接收更大的内容(例如上传文件),请调高 security.actionBodySizeLimit。

预渲染的静态页面能展示登录态或未登录态的 UI 吗?

在服务端不行。预渲染页面在构建时就被写入磁盘,每位访客收到的都是完全相同的文件,因此没有可供判断分支的 cookie 头。有两种可行的解决办法:用 'export const prerender = false' 让该路由退出预渲染;或者保持页面静态,在浏览器中从一个按需渲染的端点获取用户信息,再把结果传入客户端组件。

Astro 会自动为登录表单提供 CSRF 防护吗?

部分会。在按需渲染的页面上,Astro 会将浏览器发送的 origin 头与请求所指向的 URL 进行比对,两者不一致时返回 403。这一行为自 Astro 5 起通过 security.checkOrigin 选项默认开启,且仅覆盖跨站表单提交。你仍然需要在登录时重新生成 session ID,仍然需要对每个 action handler 和 API 路由分别做授权检查。将 security.checkOrigin 设为 false 会禁用该检查。

应该用 context.rewrite 还是 Astro.redirect 把未登录用户送到登录页?

如果你希望在访客请求的那个 URL 上直接提供登录内容,就在中间件中使用 context.rewrite,因为它能让受保护的路径保留在地址栏中,并避免浏览器的第二次往返。Astro.redirect 会返回一个重定向响应,浏览器随后导航到 /login。rewrite 会触发一次全新的渲染,你的中间件会再次运行,所以要把 /login 排除在受保护路径之外,否则同一处检查会持续失败。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.