【翻译】使用 React Router 实现 OpenAuth-基于 React Router 的身份验证指南

0 阅读8分钟

原文链接:seanpaulcampbell.com/blog/openau…

作者:Sean Campbell's Blog

在本指南中,我们将逐步演示如何在 React Router 中实现 OpenAuth。之所以撰写此指南,是因为我曾花了不少时间才理清实现思路,希望这份指南能对您有所帮助!请注意,这仅是我个人的实现方式,实际存在多种实现方案。

该方案的高级概述如下:

我们采用OpenAuth作为用户认证方案。当通过邮件验证码或GitHub等社交账户验证用户身份后,即可获取身份凭证(邮箱或社交账户),并基于此信息在数据库中执行授权操作。这样既能验证是否存在对应身份的用户,若不存在则引导用户完成注册流程,并可能进行计费操作。

我们将涵盖的内容

  • 为我们的Web和身份验证服务器设置SST
  • 使用提供商选项配置身份验证签发者
  • 在React Router中实现身份验证流程
  • 管理经过身份验证的会话
  • 处理回调和重定向
  • 创建受保护的路由
  • 如何通过创建用户来处理用户引导流程

前提条件

开始之前,请确保您已具备以下条件:

  • 安装了 Node.js 及 npm/yarn/bun
  • 对 React 和 React Router 有基础了解
  • 掌握 SST 基础知识
  • 拥有 AWS 账户(用于 SST 部署)

项目设置

首先创建一个使用 React Router 的新 SST 项目:

# Create a new React Router project
mkdir openauth-react-router
cd openauth-react-router
bunx create-react-router@latest

# Setup SST
bunx sst init

# Install openauth
bun add @openauth/openauth

设置 SST 身份验证

我们的第一步是使用 SST 设置身份验证服务器。打开您的 sst.config.ts 文件,并添加以下配置:

/// <reference path="./.sst/platform/config.d.ts" />

export default $config({
  app(input) {
    return {
      name: "openauth-react-router",
      removal: input?.stage === "production" ? "retain" : "remove",
      protect: ["production"].includes(input?.stage),
      home: "aws",
      providers: {
        aws: {
          region: "us-east-1",
          // Replace with your AWS profile
          profile: "your-aws-profile",
        },
      },
    };
  },
  async run() {
    const auth = new sst.aws.Auth("AuthServer", {
      issuer: {
        handler: "./packages/functions/src/auth/issuer.handler",
      },
    });

    const web = new sst.aws.React("Web", {
      environment: {
        VITE_AUTH_URL: auth.url,
        VITE_SITE_URL: "http://localhost:5173",
      },
    });
  },
});

此配置:

  1. 创建名为“AuthServer”的SST认证构造体
  2. 指向我们的签发者处理程序函数
  3. 为认证URL和站点URL设置环境变量的React应用程序

创建认证处理程序

接下来,我们需要创建认证处理程序。创建目录结构:

packages/
  functions/
    src/
      auth/
        issuer.ts

现在,让我们实现基本的发行者处理程序:

import { issuer } from "@openauthjs/openauth";
import { CodeProvider } from "@openauthjs/openauth/provider/code";
import { CodeUI } from "@openauthjs/openauth/ui/code";
import { handle } from "hono/aws-lambda";
import { authSubjects } from "./subjects";

const app = issuer({
  subjects: authSubjects,
  allow: async () => true,
  providers: {
    email: CodeProvider(
      CodeUI({
        sendCode: async (email, code) => {
          console.log("send code: ", email, code);
        },
      })
    ),
  },
  success: async (ctx, value) => {
    if (value.provider === "email") {
      const email = value.claims.email;
      if (!email) {
        throw new Error("No email found");
      }

      return ctx.subject(
        "account",
        { type: "email", email },
        { subject: email }
      );
    }

    throw new Error("Invalid provider");
  },
});

export const handler = handle(app);

授权主题

import { createSubjects } from "@openauthjs/openauth/subject";
import { z } from "zod";

const EmailAccount = z.object({
  type: z.literal("email"),
  email: z.string(),
});

export type EmailAccount = z.infer<typeof EmailAccount>;
export type Account = EmailAccount;

const AccountSchema = z.discriminatedUnion("type", [EmailAccount]);

export const authSubjects = createSubjects({
  account: AccountSchema,
});

在 React Router 中设置身份验证模块

现在,让我们在 React 应用程序中设置身份验证模块。我们将创建几个关键文件:

1. 身份验证会话存储

接下来,我们将创建一个服务器端身份验证存储来管理会话。部分代码灵感来源于The Epic Stack。感谢Kent C. Dodds的杰出工作!该会话存储将用于保存从OpenAuth服务器获取的JWTtoken

import type { Tokens } from "@openauthjs/openauth/client";
import { type SessionStorage, createCookieSessionStorage } from "react-router";

interface SessionData {
  tokens: Tokens;
  expires?: Date;
}

type SessionFlashData = {
  error: string;
};

const AUTH_SESSION_KEY = "en_session";

export const authSessionStorage = createCookieSessionStorage<
  SessionData,
  SessionFlashData
>({
  cookie: {
    name: AUTH_SESSION_KEY,
    sameSite: "lax",
    path: "/",
    httpOnly: true,
    secrets: ["secret"],
    secure: process.env.NODE_ENV === "production",
  },
});

export function getSessionDefaultExpiration() {
  return new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); // 30 days
}

// we have to do this because every time you commit the session you overwrite it
// so we store the expiration time in the cookie and reset it every time we commit
const originalCommitSession = authSessionStorage.commitSession;

Object.defineProperty(authSessionStorage, "commitSession", {
  value: async function commitSession(
    ...args: Parameters<typeof originalCommitSession>
  ) {
    const [session, options] = args;
    if (options?.expires) {
      session.set("expires", options.expires);
    }
    if (options?.maxAge) {
      session.set("expires", new Date(Date.now() + options.maxAge * 1000));
    }
    const expires = session.has("expires")
      ? new Date(session.get("expires") as Date)
      : undefined;
    const setCookieHeader = await originalCommitSession(session, {
      ...options,
      expires,
    });
    return setCookieHeader;
  },
});

export class AuthSessionController {
  #sessionStorage: SessionStorage<SessionData, SessionFlashData>;

  constructor(sessionStorage: SessionStorage<SessionData, SessionFlashData>) {
    this.#sessionStorage = sessionStorage;
  }

  get sessionStorage() {
    return this.#sessionStorage;
  }

  async getSession(request: Request) {
    return this.#sessionStorage.getSession(request.headers.get("Cookie"));
  }

  async getSessionData(request: Request): Promise<Partial<SessionData>> {
    const session = await this.getSession(request);
    const tokens = session.get("tokens");
    const expires = session.get("expires");

    return {
      tokens,
      expires,
    };
  }

  async setSessionData(data: SessionData) {
    const session = await this.#sessionStorage.getSession();
    session.set("tokens", data.tokens);
    if (data.expires) {
      session.set("expires", data.expires);
    }
    const headers = new Headers();
    headers.append(
      "Set-Cookie",
      await this.#sessionStorage.commitSession(session)
    );
    return headers;
  }

  async destroySession(request: Request) {
    const session = await this.getSession(request);
    const headers = new Headers();
    headers.append(
      "Set-Cookie",
      await this.#sessionStorage.destroySession(session)
    );
    return headers;
  }
}

2. 身份验证器

接下来是一个围绕 OpenAuth client构建的小型辅助类,用于协助 React Router 的身份验证流程。该实现深受 Remix Auth OpenAuth 的启发。感谢 Sergio Xalambrí 的出色工作!

import type { SetCookieInit } from "@mjackson/headers";
import { type Tokens, createClient } from "@openauthjs/openauth/client";
import * as OpenAuthError from "@openauthjs/openauth/error";
import type { SubjectSchema } from "@openauthjs/openauth/subject";
import { redirect } from "react-router";
import { combineHeaders } from "../../utils/misc.server";
import { StateStore } from "./store.server";

type FetchLike = NonNullable<Parameters<typeof createClient>["0"]["fetch"]>;

export interface AuthenticatorOption<T extends SubjectSchema = SubjectSchema> {
  /**
   * The redirect URI of the application you registered in the OpenAuth
   * server.
   *
   * This is where the user will be redirected after they authenticate.
   *
   * @example
   * "https://example.com/auth/callback"
   */
  redirectUri: string;

  /**
   * The client ID of the application you registered in the OpenAuth server.
   * @example
   * "my-client-id"
   */
  clientId: string;

  /**
   * The issuer of the OpenAuth server you want to use.
   * This is where your OpenAuth server is hosted.
   * @example
   * "https://openauth.example.com"
   */
  issuer: string;

  /**
   * The name of the cookie used to keep state and code verifier around.
   *
   * The OAuth2 flow requires generating a random state and code verifier, and
   * then checking that the state matches when the user is redirected back to
   * the application. This is done to prevent CSRF attacks.
   *
   * The state and code verifier are stored in a cookie, and this option
   * allows you to customize the name of that cookie if needed.
   * @default "oauth2"
   */
  cookie?: string | (Omit<SetCookieInit, "value"> & { name: string });

  /**
   * A custom fetch implementation to use when making requests to the OAuth2
   * server. This can be useful when you need to replace the default fetch
   * to use a proxy, for example.
   */
  fetch?: FetchLike;
  subjects: T;
}

export class Authenticator<T extends SubjectSchema> {
  name = "openauth";
  #client: ReturnType<typeof createClient>;
  #options: AuthenticatorOption<T>;
  #subjects: T;

  constructor(options: AuthenticatorOption<T>) {
    this.#options = options;
    this.#client = createClient({
      clientID: options.clientId,
      issuer: options.issuer,
      fetch: options.fetch,
    });
    this.#subjects = options.subjects;
  }

  get #cookieName() {
    if (typeof this.#options.cookie === "string") {
      return this.#options.cookie || "oauth2";
    }
    return this.#options.cookie?.name ?? "oauth2";
  }

  get #cookieOptions() {
    if (typeof this.#options.cookie !== "object") return {};
    return this.#options.cookie ?? {};
  }

  /**
   * Throws a redirect to the authorization endpoint.
   */
  async authorize(
    _request: Request,
    options?: {
      provider?: string;
      redirectUri?: string;
      type?: "login" | "email-verify";
      headers?: Headers;
    }
  ): Promise<void> {
    const { state, verifier, url, redirectUri } =
      await this.#createAuthorizationURL(options);

    // Create a cookie prefix based on type
    const cookiePrefix = options?.type
      ? `${this.#cookieName}-${options.type}`
      : this.#cookieName;

    const store = new StateStore();
    store.set(state, verifier, redirectUri);

    const setCookie = store.toSetCookie(cookiePrefix, this.#cookieOptions);

    const headers = new Headers();
    headers.append("Set-Cookie", setCookie.toString());

    throw redirect(url.toString(), {
      headers: combineHeaders(headers, options?.headers),
    });
  }

  async exchange(
    request: Request,
    options?: {
      type?: "login" | "email-verify";
    }
  ) {
    const url = new URL(request.url);

    const code = url.searchParams.get("code");
    const stateUrl = url.searchParams.get("state");

    // Create a cookie prefix based on type
    const cookiePrefix = options?.type
      ? `${this.#cookieName}-${options.type}`
      : this.#cookieName;

    const store = StateStore.fromRequest(request, cookiePrefix);

    if (!code) throw new ReferenceError("Missing authorization code.");
    if (!stateUrl) throw new ReferenceError("Missing state in URL.");
    if (!store.state) throw new ReferenceError("Missing state in cookie.");
    if (store.state !== stateUrl) {
      throw new RangeError(
        `State mismatch. Cookie: ${store.state}, URL: ${stateUrl}`
      );
    }
    if (!store.codeVerifier) {
      throw new ReferenceError("Missing code verifier in cookie.");
    }

    // Get the redirect URI that was saved during authorization
    const redirectUri = store.redirectUri ?? this.#options.redirectUri;

    const result = await this.#client.exchange(
      code,
      redirectUri,
      store.codeVerifier
    );

    if (result.err) throw result.err;

    const cleanCookie = StateStore.cleanCookie(cookiePrefix);
    const headers = new Headers();
    headers.append("Set-Cookie", cleanCookie.toString());

    return {
      tokens: result.tokens,
      headers,
    };
  }

  /**
   * Refreshes the access token using the provided refresh token.
   *
   * @param refresh - The refresh token to use for obtaining a new access token.
   * @param access - An optional access token to validate if it needs to be refreshed.
   * @returns The new tokens obtained after refreshing.
   */
  async refreshToken(
    refresh: string,
    access?: string
  ): Promise<Tokens | undefined> {
    const result = await this.#client.refresh(refresh, { access });
    if (result.err) throw result.err;
    if (!result.tokens && access) return { access, refresh, expiresIn: 0 };
    if (!access && !result.tokens) throw new Error("No tokens returned");
    return result.tokens;
  }

  async verifyToken(
    token: string,
    options?: { refresh: string; audience?: string }
  ) {
    const result = await this.#client.verify(this.#subjects, token, {
      ...options,
      issuer: this.#options.issuer,
      fetch: this.#options.fetch as typeof fetch,
    });
    const clone = structuredClone(result);
    return clone;
  }

  async #createAuthorizationURL(options?: {
    provider?: string;
    redirectUri?: string;
  }) {
    const redirectUri = options?.redirectUri ?? this.#options.redirectUri;
    const provider = options?.provider;
    const result = await this.#client.authorize(redirectUri, "code", {
      pkce: true,
      provider: provider,
    });

    const url = new URL(result.url);
    url.searchParams.set("state", result.challenge.state);

    return { ...result.challenge, url, redirectUri };
  }
}

export class OAuth2RequestError extends Error {
  code: string;
  description: string | null;
  uri: string | null;
  state: string | null;

  constructor(
    code: string,
    description: string | null,
    uri: string | null,
    state: string | null
  ) {
    super(`OAuth request error: ${code}`);
    this.code = code;
    this.description = description;
    this.uri = uri;
    this.state = state;
  }
}

export { OpenAuthError };

状态存储

状态存储用于保存OAuth流程的状态和代码验证器。它将负责存储代码验证器和状态的Cookie。

import { Cookie, SetCookie, type SetCookieInit } from "@mjackson/headers";

/**
 * This class stores all necessary information for the OAuth flow.
 * It follows the same pattern as the OpenAuth SPA implementation.
 */
export class StateStore {
  state: string | undefined;
  codeVerifier: string | undefined;
  redirectUri: string | undefined;

  constructor(state?: string, codeVerifier?: string, redirectUri?: string) {
    this.state = state;
    this.codeVerifier = codeVerifier;
    this.redirectUri = redirectUri;
  }

  /**
   * Set the state, code verifier, and redirect URI
   */
  set(state: string, verifier?: string, redirectUri?: string) {
    this.state = state;
    this.codeVerifier = verifier;
    this.redirectUri = redirectUri;
  }

  /**
   * Check if the store has a specific state
   */
  has(checkState?: string) {
    if (!this.state) return false;
    return checkState ? this.state === checkState : true;
  }

  /**
   * Get the code verifier for the current state
   */
  get(checkState: string) {
    if (checkState === this.state) {
      return this.codeVerifier;
    }
    return undefined;
  }

  /**
   * Get the redirect URI that was used for this auth flow
   */
  getRedirectUri() {
    return this.redirectUri;
  }

  toString() {
    if (!this.state) return "";
    if (!this.codeVerifier) return "";

    const params = new URLSearchParams();

    params.set("state", this.state);
    params.set("codeVerifier", this.codeVerifier);
    if (this.redirectUri) {
      params.set("redirectUri", this.redirectUri);
    }

    return params.toString();
  }

  /**
   * Convert the store to cookie for storage
   */
  toSetCookie(
    cookieName = "oauth2",
    options: Omit<SetCookieInit, "value"> = {}
  ) {
    return new SetCookie({
      value: this.toString(),
      httpOnly: true, // Prevents JavaScript from accessing the cookie
      maxAge: 60 * 5, // 5 minutes
      path: "/",
      sameSite: "Lax",
      ...options,
      name: cookieName,
    });
  }

  /**
   * Create a new instance from a Request object
   */
  static fromRequest(request: Request, cookieName = "oauth2") {
    const cookie = new Cookie(request.headers.get("cookie") ?? "");
    const cookieValue = cookie.get(cookieName);

    if (!cookieValue) {
      return new StateStore();
    }

    const params = new URLSearchParams(cookieValue);
    const state = params.get("state") || undefined;
    const verifier = params.get("codeVerifier") || undefined;
    const redirectUri = params.get("redirectUri") || undefined;

    return new StateStore(state, verifier, redirectUri);
  }

  static cleanCookie(cookieName = "oauth2") {
    return new SetCookie({
      value: "",
      maxAge: 0,
      httpOnly: true,
      expires: new Date(0),
      path: "/",
      sameSite: "Lax",
      name: cookieName,
    });
  }
}

3. 身份验证与授权功能

此处将处理身份验证与授权流程。我们将通过辅助函数中的身份验证器authenticator来管理路由的身份验证与授权流程。

我们提供以下辅助函数:

  • getSessionDataWithUser - 获取会话数据及已认证用户的身份信息
  • requireSessionData - 强制要求会话数据,若未认证则重定向至登录页面
  • requireSessionWithUser - 强制要求会话数据及已认证用户的身份信息
  • requireAnonymous - 强制要求会话数据,若未认证则重定向至首页

此外还提供 sessionController 组件,用于处理会话数据及用户身份验证相关操作。

import type { Tokens } from "@openauthjs/openauth/client";
import { redirect } from "react-router";
import {
  type EmailAccount,
  type OAuthAccount,
  authSubjects,
} from "../../../packages/functions/src/auth/subjects";
import { combineHeaders } from "../../utils/misc.server";
import type { User } from "../users/service.server";
import * as UserService from "../users/service.server";
import {
  AuthSessionController,
  authSessionStorage,
  getSessionDefaultExpiration,
} from "./auth-session-storage.server";
import { Authenticator } from "./authenticator.server";

export const authenticator = new Authenticator<typeof authSubjects>({
  clientId: "web",
  redirectUri: `${import.meta.env.VITE_SITE_URL}/auth/callback`,
  issuer: import.meta.env.VITE_AUTH_URL,
  subjects: authSubjects,
});

export async function handleAuthCallback(request: Request) {
  try {
    const { tokens, headers: exchangeHeaders } = await authenticator.exchange(
      request
    );
    const verified = await authenticator.verifyToken(tokens.access, {
      refresh: tokens.refresh,
    });

    if (verified.err) {
      throw redirect("/", {
        headers: combineHeaders(
          await sessionController.destroySession(request),
          exchangeHeaders
        ),
      });
    }

    if (verified.subject.type !== "account") {
      throw new Error("Invalid subject type");
    }

    if (verified.subject.properties.type === "email") {
      return handleEmailFlow({
        tokens,
        verified: verified.subject.properties,
        exchangeHeaders,
      });
    }

    throw new Error("Invalid subject type");
  } catch (error) {
    if (error instanceof Response) {
      throw error;
    }
    console.error("Error handling callback:", error);
    throw redirect("/logout");
  }
}

async function handleEmailFlow({
  tokens,
  verified,
  exchangeHeaders,
}: {
  tokens: Tokens;
  verified: EmailAccount;
  exchangeHeaders: Headers;
}) {
  const user = await UserService.userByEmail(verified.email);

  if (!user) {
    const headers = await sessionController.setSessionData({
      tokens: tokens,
      expires: getSessionDefaultExpiration(),
    });

    throw redirect("/onboarding", {
      headers: combineHeaders(headers, exchangeHeaders),
    });
  }

  const headers = await sessionController.setSessionData({
    tokens: tokens,
    expires: getSessionDefaultExpiration(),
  });

  return {
    user,
    headers: combineHeaders(headers, exchangeHeaders),
  };
}

const sessionController = new AuthSessionController(authSessionStorage);

interface SessionData {
  tokens: Tokens;
  properties: EmailAccount | OAuthAccount;
  headers: Headers;
}

export async function getSessionData(request: Request) {
  const sessionData = await sessionController.getSessionData(request);
  if (!sessionData.tokens) {
    return undefined;
  }

  let headers = new Headers();
  const verified = await authenticator.verifyToken(sessionData.tokens.access, {
    refresh: sessionData.tokens.refresh,
  });
  // if the token is invalid, destroy the session
  // and redirect to the home page where we can login
  if (verified.err || verified.subject.type !== "account") {
    throw redirect("/", {
      headers: await sessionController.destroySession(request),
    });
  }
  // if there are new tokens from the refreshing, update the session
  if (verified.tokens) {
    sessionData.tokens = verified.tokens;
    headers = await sessionController.setSessionData({
      tokens: verified.tokens,
      expires: getSessionDefaultExpiration(),
    });
  }

  return {
    tokens: sessionData.tokens,
    properties: verified.subject.properties,
    headers,
  };
}

export async function requireSessionData(
  request: Request,
  { redirectTo }: { redirectTo?: string | null } = {}
) {
  const sessionData = await getSessionData(request);
  if (!sessionData) {
    throw redirect(getLoginRedirectUrl(request, redirectTo));
  }
  return sessionData;
}

/**
 * Get the session data from the request
 * @param request - The request object
 * @throws redirect to the onboarding page if we have a valid session but no user
 * @returns The session data
 */
export async function getSessionWithUser(request: Request): Promise<
  | {
      sessionData: SessionData;
      user: User;
    }
  | undefined
> {
  const sessionData = await getSessionData(request);
  if (!sessionData) {
    return undefined;
  }
  const user = await UserService.userByEmail(sessionData.properties.email);
  if (!user) {
    throw redirect("/onboarding");
  }

  return {
    sessionData,
    user,
  };
}

export async function requireSessionWithUser(
  request: Request,
  { redirectTo }: { redirectTo?: string | null } = {}
) {
  const sessionData = await requireSessionData(request, { redirectTo });
  const user = await UserService.userByEmail(sessionData.properties.email);
  if (!user) {
    throw redirect("/onboarding");
  }

  return {
    sessionData,
    user,
  };
}

export async function requireAnonymous(request: Request) {
  const sessionData = await getSessionData(request);
  if (sessionData) {
    throw redirect("/");
  }
}

function getLoginRedirectUrl(
  request: Request,
  redirectTo?: string | null
): string {
  const requestUrl = new URL(request.url);
  const to =
    redirectTo === null
      ? null
      : redirectTo ?? `${requestUrl.pathname}${requestUrl.search}`;
  const params = to ? new URLSearchParams({ redirectTo: to }) : null;
  const loginRedirect = ["/login", params?.toString()]
    .filter(Boolean)
    .join("?");
  return loginRedirect;
}

export async function handleLogout(
  request: Request,
  {
    redirectTo = "/",
    responseInit,
  }: {
    redirectTo?: string;
    responseInit?: ResponseInit;
  } = {}
) {
  const headers = await sessionController.destroySession(request);

  throw redirect(redirectTo, {
    ...responseInit,
    headers: combineHeaders(headers, responseInit?.headers),
  });
}

export async function handleSignup({
  email,
  name,
  tokens,
}: {
  email: string;
  name: string;
  tokens: Tokens;
}) {
  const user = await UserService.signup({ email, name });
  const headers = await sessionController.setSessionData({
    tokens,
    expires: getSessionDefaultExpiration(),
  });
  return { user, headers };
}

在 React Router 中实现身份验证路由

既然我们已经拥有了身份验证模块,接下来就来实现必要的路由:

1. 登录路由

登录路由的职责是将用户重定向至 OpenAuth 服务器以启动 OAuth 流程。该流程将在 Cookie 中设置状态和代码验证器,随后将用户重定向至 OpenAuth 服务器。

import {
  authenticator,
  requireAnonymous,
} from "../../modules/auth/auth.server";
import type { Route } from "./+types/login";

export async function loader({ request }: Route.LoaderArgs) {
  await requireAnonymous(request);

  throw await authenticator.authorize(request);
}

2. 回调路由

在回调路由中,我们将处理来自OpenAuth服务器的响应。我们将验证令牌,然后设置会话数据。

import type { LoaderFunctionArgs } from "react-router";
import { redirect } from "react-router";
import { handleAuthCallback } from "../../modules/auth/auth.server";

export async function loader({ request }: LoaderFunctionArgs) {
  const { headers } = await handleAuthCallback(request);

  return redirect("/protected", {
    // this could be any route you want
    headers: headers,
  });
}

3. 注销路径

注销路径将销毁会话并重定向用户至主页。

import { handleLogout } from "../../modules/auth/auth.server";
import type { Route } from "./+types/logout";

export async function loader({ request }: Route.LoaderArgs) {
  return handleLogout(request);
}

export async function action({ request }: Route.ActionArgs) {
  return handleLogout(request);
}

创建注册引导组件

handleAuthCallback 函数中,我们会检查用户是否已在我们的"数据库"中存在。若不存在,则将用户重定向至注册引导页面。无论用户是否为新用户,我们都会使用令牌设置会话数据,并根据情况进行相应重定向。

在引导页面上,我们将通过 requireSessionData 函数确保存在有效会话。该函数将返回用户的邮箱地址,我们可据此填写表单并验证用户是否已存在于我们的“数据库”中。

对于新用户,我们将创建引导组件:

import {
  type SubmissionResult,
  getFormProps,
  getInputProps,
  useForm,
} from "@conform-to/react";
import { getZodConstraint, parseWithZod } from "@conform-to/zod";
import {
  Form,
  data,
  redirect,
  useActionData,
  useLoaderData,
  useSearchParams,
} from "react-router";
import { HoneypotInputs } from "remix-utils/honeypot/react";
import { safeRedirect } from "remix-utils/safe-redirect";
import { z } from "zod";
import { ErrorList, Field } from "../../components/ui/forms";
import { StatusButton } from "../../components/ui/status-button";
import { authSessionStorage } from "../../modules/auth/auth-session-storage.server";
import {
  handleSignup,
  requireSessionData,
} from "../../modules/auth/auth.server";
import * as UserService from "../../modules/users/service.server";
import { useIsPending } from "../../utils/misc";
import type { Route } from "./+types/onboarding";

export const OnboardingSchema = z.object({
  email: z.string().email(),
  name: z.string(),
  redirectTo: z.string().optional(),
});

export async function loader({ request }: Route.LoaderArgs) {
  const { email } = await requireOnboardingData(request);
  const authSession = await authSessionStorage.getSession(
    request.headers.get("cookie")
  );

  const formError = authSession.get("error");
  const hasError = typeof formError === "string";

  return data({
    email: email,
    status: "idle",
    submission: {
      status: hasError ? "error" : undefined,
      initialValue: {
        email: email,
      },
      error: { "": hasError ? [formError] : [] },
    } as SubmissionResult,
  });
}

export async function action({ request }: Route.ActionArgs) {
  const onboardingData = await requireOnboardingData(request);
  const formData = await request.formData();

  const submission = await parseWithZod(formData, {
    async: true,
    schema: (intent) =>
      OnboardingSchema.superRefine(async (data, ctx) => {
        try {
          const existingUser = await UserService.userByEmail(data.email);
          if (existingUser) {
            ctx.addIssue({
              code: z.ZodIssueCode.custom,
              message: "A user with this email already exists.",
              path: ["email"],
            });
            return;
          }
          if (data.email !== onboardingData.email) {
            ctx.addIssue({
              code: z.ZodIssueCode.custom,
              message: "Email does not match the email provided.",
              path: ["email"],
            });
            return;
          }
        } catch (error) {
          console.error("Error verifying token:", error);
          ctx.addIssue({
            code: z.ZodIssueCode.custom,
            message: "An error occurred while verifying the token.",
            path: ["email"],
          });
        }
      }).transform(async (data) => {
        if (intent !== null) return { ...data, headers: null };

        const { headers } = await handleSignup({
          email: data.email,
          name: data.name,
          tokens: onboardingData.tokens,
        });
        return { ...data, headers };
      }),
  });

  if (submission.status !== "success" || !submission.value.headers) {
    return data(
      { result: submission.reply() },
      { status: submission.status === "error" ? 400 : 200 }
    );
  }

  const { redirectTo, headers } = submission.value;

  return redirect(safeRedirect(redirectTo, "/protected"), { headers });
}

export default function OnboardingProviderRoute() {
  const loaderData = useLoaderData<typeof loader>();
  const actionData = useActionData<typeof action>();

  const [searchParams] = useSearchParams();
  const redirectTo = searchParams.get("redirectTo");

  const isPending = useIsPending();

  const [form, fields] = useForm({
    id: "onboarding-form",
    constraint: getZodConstraint(OnboardingSchema),
    lastResult: actionData?.result ?? loaderData.submission,
    onValidate({ formData }) {
      return parseWithZod(formData, { schema: OnboardingSchema });
    },
    shouldRevalidate: "onBlur",
  });

  return (
    <div className="min-h-screen flex items-center justify-center bg-background">
      <div className=" flex p-8 flex-col items-center justify-center gap-6 rounded-lg shadow-md w-full max-w-xl bg-card">
        <header className="mb-2 flex flex-col gap-2">
          <h1 className="font-display text-center text-5xl font-semibold text-foreground">
            Create your account
          </h1>
          <p className="text-center text-base font-normal text-muted-foreground">
            Join thousands of users today
          </p>
        </header>

        <div className="space-y-8 w-full">
          <Form
            method="POST"
            autoComplete="off"
            className="flex w-full flex-col items-start gap-1"
            {...getFormProps(form)}
          >
            <HoneypotInputs />
            {redirectTo ? (
              <input
                {...getInputProps(fields.redirectTo, { type: "hidden" })}
                value={redirectTo}
              />
            ) : null}
            <div className="grid w-full grid-cols-1 gap-6 md:grid-cols-6">
              <div className="col-span-full md:col-span-full md:col-start-1">
                <Field
                  labelProps={{
                    children: "Name",
                  }}
                  inputProps={{
                    ...getInputProps(fields.name, {
                      type: "text",
                    }),
                    autoFocus: true,
                  }}
                  errors={fields.name.errors}
                />
              </div>

              <div className="col-span-full md:col-span-full">
                <Field
                  labelProps={{
                    children: "Email",
                  }}
                  inputProps={{
                    ...getInputProps(fields.email, { type: "email" }),
                    className: "lowercase",
                    autoComplete: "email",
                    readOnly: true,
                  }}
                  errors={fields.email.errors}
                />
              </div>
            </div>
            <div>
              <ErrorList errors={form.errors} id={form.errorId} />
            </div>
            <div className="mt-8 w-full">
              <StatusButton
                type="submit"
                status={isPending ? "pending" : form.status ?? "idle"}
                className="w-full"
              >
                Create Account
              </StatusButton>
            </div>
          </Form>

          <Form method="POST" action="/logout">
            <p className="text-body-sm text-muted-foreground">
              Want to use a different email?{" "}
              <button
                type="submit"
                className="text-body-sm text-muted-foreground hover:underline"
              >
                Sign out
              </button>
            </p>
          </Form>
        </div>
      </div>
    </div>
  );
}

async function requireOnboardingData(request: Request) {
  const sessionData = await requireSessionData(request);
  if (!sessionData.tokens) {
    throw new Error("No tokens found");
  }
  const result = z
    .object({
      email: z.string().email(),
      tokens: z.object({
        access: z.string(),
        refresh: z.string(),
        expiresIn: z.number(),
      }),
    })
    .safeParse({
      email: sessionData.properties.email,
      tokens: sessionData.tokens,
    });
  if (!result.success) {
    console.log("requireOnboardingData: result", result);
    throw redirect("/");
  }
  return result.data;
}

保护路由

既然认证系统已就绪,现在我们来创建一个用于保护路由的实用程序。若用户未通过认证,系统将重定向至登录页面以启动认证流程。

import { requireSessionWithUser } from "../modules/auth/auth.server";
import type { Route } from "./+types/protected";

export async function loader({ request }: Route.LoaderArgs) {
  const { user } = await requireSessionWithUser(request);

  return { user };
}

export default function Protected({ loaderData }: Route.ComponentProps) {
  return (
    <div>
      <h1>Protected</h1>
      <pre>{JSON.stringify(loaderData, null, 2)}</pre>
    </div>
  );
}

试一试

现在我们已完成所有配置,接下来部署我们的应用程序:

bunx sst dev

部署完成后,您可以访问应用程序网址并尝试认证流程:

  1. 点击“登录”开始认证流程
  2. 输入您的邮箱地址
  3. 完成验证码验证步骤
  4. 返回应用程序页面
  5. 完成注册流程(新用户)
  6. 访问受保护的路由!

总结

本指南通过结合OpenAuth、React Router和SST构建了完整的认证系统。该方案具备以下优势:

  • 基于JWT令牌的安全认证
  • 支持多认证提供商
  • 极简代码实现受保护路由
  • 无服务器认证基础设施

您可通过添加更多提供商、优化用户体验或集成其他服务来扩展此方案。核心认证流程保持不变,使系统兼具稳健性与灵活性。

下篇指南将新增GitHub提供商,演示其流程的回调处理方案,并为OpenAuth OAuth流程创建自定义UI页面。

完整代码示例详见GitHub仓库