Chapter 06

Session 与认证

基于 Web 标准 Cookie 实现完整的用户认证,掌握 Remix 的 Session 管理机制

6.1 Cookie Session 存储

Remix 提供 createCookieSessionStorage 工具函数,将 Session 数据加密存储在 Cookie 中(无需数据库)。这是最简单的 Session 存储方式,适合大多数应用场景。

// app/session.server.ts — Session 配置模块
import { createCookieSessionStorage, redirect } from "@remix-run/node";

// 创建 Session 存储
const { getSession, commitSession, destroySession } =
  createCookieSessionStorage({
    cookie: {
      name: "__session",
      httpOnly: true,            // 防 XSS:JS 无法读取
      maxAge: 60 * 60 * 24 * 30, // 30 天(秒)
      path: "/",
      sameSite: "lax",            // 防 CSRF
      secrets: [process.env.SESSION_SECRET!], // 签名密钥
      secure: process.env.NODE_ENV === "production", // HTTPS only
    },
  });

export { getSession, commitSession, destroySession };

// 从 Request 获取当前用户(路由守卫辅助函数)
export async function getUserId(request: Request) {
  const session = await getSession(request.headers.get("Cookie"));
  return session.get("userId") as string | undefined;
}

// 强制要求登录(未登录则重定向到 /login)
export async function requireUser(request: Request) {
  const userId = await getUserId(request);
  if (!userId) {
    const url = new URL(request.url);
    throw redirect(`/login?redirectTo=${url.pathname}`);
  }
  const user = await getUserById(userId);
  if (!user) throw redirect("/login");
  return user;
}
TS

6.2 登录 Action

登录流程:验证凭证 → 写入 Session → 重定向到目标页面。

// app/routes/login.tsx
import { json, redirect, type ActionFunctionArgs } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { getSession, commitSession } from "~/session.server";
import { verifyLogin } from "~/models/user.server";

export async function action({ request }: ActionFunctionArgs) {
  const form       = await request.formData();
  const email      = form.get("email") as string;
  const password   = form.get("password") as string;
  const redirectTo = form.get("redirectTo") as string || "/";

  // 验证凭证(bcrypt 比对)
  const user = await verifyLogin(email, password);
  if (!user) {
    return json({ error: "邮箱或密码错误" }, { status: 401 });
  }

  // 创建 Session,写入 userId
  const session = await getSession(request.headers.get("Cookie"));
  session.set("userId", user.id);

  // 重定向并在响应头中设置 Cookie
  return redirect(redirectTo, {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  });
}

export default function LoginPage() {
  const actionData = useActionData<typeof action>();
  return (
    <Form method="post">
      <input type="hidden" name="redirectTo"
        value={new URLSearchParams(location.search).get("redirectTo") ?? "/"} />
      <input name="email" type="email" placeholder="邮箱" />
      <input name="password" type="password" placeholder="密码" />
      {actionData?.error && <p style={{ color: "red" }}>{actionData.error}</p>}
      <button type="submit">登录</button>
    </Form>
  );
}
TSX

6.3 退出登录

// app/routes/logout.tsx
import { redirect, type ActionFunctionArgs } from "@remix-run/node";
import { Form } from "@remix-run/react";
import { getSession, destroySession } from "~/session.server";

export async function action({ request }: ActionFunctionArgs) {
  const session = await getSession(request.headers.get("Cookie"));
  return redirect("/login", {
    headers: { "Set-Cookie": await destroySession(session) },
  });
}

// 退出按钮(POST 提交,防 CSRF)
export function LogoutButton() {
  return (
    <Form action="/logout" method="post">
      <button type="submit">退出登录</button>
    </Form>
  );
}
TSX

6.4 Flash Message — 一次性消息

Flash Message 是只读取一次的 Session 数据,用于在重定向后显示成功/错误通知(如"文章已发布")。Remix 内建支持 session.flash()

// 在 action 中写入 flash 消息
const session = await getSession(request.headers.get("Cookie"));
session.flash("success", "文章已成功发布!");
return redirect("/blog", {
  headers: { "Set-Cookie": await commitSession(session) },
});

// 在 loader 中读取并清除 flash 消息
export async function loader({ request }: LoaderFunctionArgs) {
  const session = await getSession(request.headers.get("Cookie"));
  const success = session.get("success"); // 读取后自动从 session 删除
  return json({ success }, {
    headers: { "Set-Cookie": await commitSession(session) }, // 必须提交以清除
  });
}
TSX

6.5 remix-auth — OAuth2 第三方登录

remix-auth 是 Remix 生态最流行的认证库,提供 Strategy 模式支持 GitHub、Google、Discord 等 OAuth2 提供商。

npm install remix-auth remix-auth-github
SHELL
// app/auth.server.ts
import { Authenticator } from "remix-auth";
import { GitHubStrategy } from "remix-auth-github";
import { sessionStorage } from "~/session.server";

export const authenticator = new Authenticator<User>(sessionStorage);

authenticator.use(
  new GitHubStrategy(
    {
      clientID: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
      callbackURL: "http://localhost:5173/auth/github/callback",
    },
    async ({ profile }) => {
      // 查找或创建用户
      return findOrCreateUser({
        githubId: profile.id,
        name: profile.displayName,
        email: profile.emails?.[0].value,
      });
    }
  )
);
TS
// app/routes/auth.github.tsx — 触发 OAuth 跳转
export async function loader({ request }: LoaderFunctionArgs) {
  return authenticator.authenticate("github", request);
}

// app/routes/auth.github.callback.tsx — OAuth 回调
export async function loader({ request }: LoaderFunctionArgs) {
  return authenticator.authenticate("github", request, {
    successRedirect: "/",
    failureRedirect: "/login",
  });
}
TSX
ℹ️

本章小结Remix 的认证基于 Web 标准 Cookie API:用 createCookieSessionStorage 创建会话存储;登录 Action 验证凭证后写入 Session 并 redirect;requireUser 函数在 Loader 中做路由守卫;session.flash() 实现一次性通知消息;remix-auth 提供开箱即用的 OAuth2 集成。这套方案无需额外数据库,安全可靠。