NextJS - 使用 next-auth 配置 JWT token

发布时间 2023-08-11 12:59:02作者: 炎黄子孙,龙的传人

Nextjs 中有很多身份验证选项,例如 Supabase、Firebase、Userbase 等等。 我们将重点关注 NextAuth.js 以及通过凭证提供程序在现有 Django 后端和 Next.js 之间实现 JWT 会话的打字稿。 我们将尽力专注于我们的用例以节省时间,因此我们将省略所有未使用的选项和功能。

为什么选择 NextAuth.js

这个全栈库可以帮助您与每个主要的 OAuth 提供商集成,也可以仅与电子邮件身份验证集成(推荐这样做,因为安全性更高)。 如果需要,您还可以创建自定义 OAuth 或凭据提供程序。 这就是我们喜欢这个库的原因,它为您提供了通用流程和默认处理程序,但您可以轻松重写每个步骤以满足您的需求。

最简配置

通过命令 yarn add next-auth 安装软件包后,或者如果您更喜欢 npm install next-auth,则必须创建配置 [...nextauth].ts 文件,该文件将位于 API 路由 /api/auth/[... nextauth].ts
这意味着所有到达 /api/auth/* 的请求都将由 NextAuth.js 处理。 在此文件中,我们将导出处理程序函数,其中将包含我们的配置。 您可以在此处找到有关配置的更多详细信息。


// pages/api/auth/[...nextauth].ts

export default async function auth(
      req: NextApiRequest, 
      res: NextApiResponse
) {​
    return await NextAuth(req, res, {​
        providers: [ ... ],​
        session: {​
            strategy: "jwt",​
        },​
        cookies: cookies,​
        callbacks: { ... },​
    });​
}

一般情况下,当您想要使用 JWT 会话时,必须将 session.strategy 设置为 jwt 并指定用于加密令牌的密钥。
我们建议您通过环境变量 NEXTAUTH_SECRET 设置秘密。 此外,如果您不部署到 Vercel,则需要将站点的规范 URL 作为 NEXTAUTH_URL。

在我们的示例中,我们将使用 session-tokencallback-urlcsrf-token。 它们分别用于存储 JWT 令牌、登录/退出后将重定向的默认回调以及最后的 CSRF 令牌。
要使我们的令牌在所有子域中可用,您必须将 cookie 的域选项设置为有效域,例如 如果您的域是 account.example.com 和 example.com,则必须将域选项设置为 example.com。


// pages/api/auth/[...nextauth].ts

const cookies: Partial<CookiesOptions> = {​
    sessionToken: {​
        name: `next-auth.session-token`,​
        options: {​
            httpOnly: true,​
            sameSite: "none",​
            path: "/",​
            domain: process.env.NEXT_PUBLIC_DOMAIN,​
            secure: true,​
        },​
    },​
    callbackUrl: {​
        name: `next-auth.callback-url`,​
        options: {​
            ...​
        },
    },​
    csrfToken: {​
        name: "next-auth.csrf-token",​
        options: {​
        ...​
        },​
    },​
};

类型安全

NextAuth.js 提供内置类型,但由于我们需要在会话对象中存储更多信息,因此我们必须重写 SessionUserJWT 对象的类型。
在文档中阅读有关 cookie 及其选项的更多信息。
https://next-auth.js.org/configuration/options#cookies

// types/next-auth.d.ts

declare module "next-auth" {
  /**
   * Returned by `useSession`, `getSession` and received as
   * a prop on the `SessionProvider` React Context
   */
  interface Session {
    refreshTokenExpires?: number;
    accessTokenExpires?: string;
    refreshToken?: string;
    token?: string;
    error?: string;
    user?: User;
  }

  interface User {
    firstName?: string;
    lastName?: string;
    email?: string | null;
    id?: string;
    contactAddress?: {
      id?: string;
    };
  }
}

declare module "next-auth/jwt" {
  /** Returned by the `jwt` callback and `getToken`, when using JWT sessions */
  interface JWT {
    refreshTokenExpires?: number;
    accessTokenExpires?: number;
    refreshToken?: string;
    token: string;
    exp?: number;
    iat?: number;
    jti?: string;
  }
}

认证流程

凭证提供者(Credentials provider)

我们终于可以将凭据提供程序添加到我们的配置中。 需要提供者的nameid来区分不同的提供者。 credentials对象表示登录表单的字段,该对象结构将作为authorize函数的第一个参数传递。

// pages/api/auth/[...nextauth].ts

providers: [​
    Providers.Credentials({​
        name: 'credentials',​
        id: 'credentials',​
        credentials: {​
            username: { },​
            password: { }​
        },​
        async authorize(credentials, req) {​
            // ...​
        }​
    })​
],

authorize函数负责从自定义后端实现中获取用户,该实现应该为我们提供 JWT 令牌。

// pages/api/auth/[...nextauth].ts

async authorize(credentials) {​
    const response = await fetch('...', {​
        ...​
        variables: {​
            email: credentials?.email,​
            password: credentials?.password,​
        },​
    });​
    const data = await response.json();​
    if (response.ok && data?.token) {​
        return  data;
    }​
    return Promise.reject(new Error(data?.errors));​​
};

回调 (Callbacks)

我们流程中的第一个回调是 jwt。 每当客户端创建或访问 JSON Web Token 时都会调用此回调。 这是实施代币轮换的正确位置。 freshAccessToken 函数的目的是使用存储在 token 对象中的刷新令牌,并使用它来获取具有更新的过期时间的新访问令牌。 请注意,我们的后端为我们提供了以秒为单位的过期时间,而 Date.now() 的输出以毫秒为单位,这就是为什么我们需要将其除以 1000。

// pages/api/auth/[...nextauth].ts

export const jwt = async ({ token, user }: { token: JWT; user?: User }) => {
  // first call of jwt function just user object is provided
  if (user?.email) {
    return { ...token, ...user };
  }

  // on subsequent calls, token is provided and we need to check if it's expired
  if (token?.accessTokenExpires) {
    if (Date.now() / 1000 < token?.accessTokenExpires) return { ...token, ...user };
  } else if (token?.refreshToken) return refreshAccessToken(token);

  return { ...token, ...user };
};

jwt 的输出作为 token 传递到 session 回调中。 这是将附加数据传递到 session 对象的好地方。
在我们的例子中,我们将解析后端在 token 内向我们提供的所有数据,并将其作为 Web 客户端的 user 对象传递。 此外,我们还可以检查访问令牌和刷新令牌是否已过期并引发错误。

// pages/api/auth/[...nextauth].ts

export const session = ({ session, token }: { session: Session; token: JWT })
 : Promise<Session> => {

  if (Date.now() / 1000 > token?.accessTokenExpires && 
      token?.refreshTokenExpires && Date.now() / 1000 > token?.refreshTokenExpires) {
    return Promise.reject({
      error: new Error("Refresh token has expired. Please log in again to get a new refresh token."),
    });
  }

  const accessTokenData = JSON.parse(atob(token.token.split(".")?.at(1)));
  session.user = accessTokenData;
  token.accessTokenExpires = accessTokenData.exp;

  session.token = token?.token;

  return Promise.resolve(session);
};

这是我们的身份验证流程的可视化。

客户端使用

正如我们之前提到的,我们使用自定义登录页面,以便在客户端验证后我们可以调用 signIn。 将重定向设置为 false 后,我们可以手动处理错误和成功。

// pages/login.tsx

signIn("credentials", {
  username: data?.username,
  password: data?.password,
  redirect: false,
}).then((response) => {
  if (response?.error) {
    // show notification for user
  } else {
    // redirect to destination page
  }
});

如果我们不需要验证响应,我们可以将重定向设置为 true 并提供回调 URL,以便在用户注销后他将被重定向到指定页面。

// pages/logout.tsx

signOut({
  redirect: true,
  callbackUrl: `${process?.env.NEXT_PUBLIC_LOGIN_PAGE}/login`,
});

当您需要访问 session 数据或访问客户端中的 token 令牌时,可以使用 useSession() 钩子。 在我们的例子中,我们将使用自定义属性获取session类型。

中间件 (Middleware)

如果您使用 Next.js 12 或更高版本,则可以在中间件 middleware 中使用 NextAuth.js。 在基本用法中,我们只需导出一个 matcher 对象,其中包含我们想要保护的路径名数组。

// middleware.ts

export { default } from "next-auth/middleware"

export const config = { matcher: [ ... ] }

如果您需要一些高级逻辑,您可以使用自定义 middleware 中间件实现。 我们可以通过 getToken() 访问和解码中间件中的令牌数据。
例如,如果用户没有管理员访问权限,我们可以重定向用户。


// middleware.ts

export async function middleware(request: NextRequest) {
  const token = await getToken({
    req: request,
    secret: process?.env?.NEXTAUTH_SECRET,
    cookieName: ACCESS_TOKEN, // next-auth.session-token
  });

  // redirect user without access to login
  if (token?.token && Date.now() / 1000 < token?.accessTokenExpires) {
    return NextResponse.redirect("/login");
  }

  // redirect user without admin access to login
  if (!token?.isAdmin) {
    return NextResponse.redirect("/login");
  }

  return NextResponse.next();
}

子域名设置

当您只有一个域名时,到目前为止的所有内容都对您有效。 恭喜你,你已经完成了。 其余的已成功完成负责身份验证的域的实施。 在撰写本文时,NextAuth.js 文档尚未提供官方解决方案。 我们可以发现这里只是我们需要设置一个自定义的cookie策略。

我们需要在所有子域之间共享除 authorize 授权功能之外的所有内容。 这是有道理的,因为我们永远不会从子域调用 signIn

// pages/api/auth/[...nextauth].ts

export default NextAuth({
  providers: [
    CredentialsProvider({
      name: "id",
      name: "credentials",
      credentials: {},
      async authorize(_credentials, _req) {
        return null;
      },
    }),
  ],
  session: {
    strategy: "jwt",
  },
  cookies: cookies,
  callbacks: {
    session,
    jwt,
  },
});

因此,我们需要共享 cookiesessionjwt 回调的设置,以同样地访问和刷新令牌。 这就是为什么我们建议您将这些功能移至共享模块中,以便您可以从两个站点访问它们。 不要忘记使用相同的 secret,因为 token 令牌的解密将会失败。

ref: https://remaster.com/blog/next-auth-jwt-session