如何在 Next js 14 和应用程序路由器中实现用于外部 API 登录的 Next Auth v.5

问题描述 投票:0回答:1

Next Auth 的文档大部分都是过时的示例,据我了解,它仍在最终确定中。

实际上,很难找到有关如何执行此操作的信息。 Next Auth 的文档对我来说是真正的痛苦和混乱。我花了很多时间观看视频教程和阅读相关文章,但似乎一切都变化得太快,所有信息都已经过时了。

我实际上是在尝试根据官方网站上的教程调整身份验证系统 Next js 的,但显然我做错了。我收到如下错误:

在 VSCode 前端控制台中,我收到如下错误:

Response from API: {
  user: { id: 4, username: 'bob', email: '[email protected]' },
  refresh: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTcxMDUyNjUzMiwiaWF0IjoxNzEwNDQwMTMyLCJqdGkiOiJkMzI3MjJkNjM1MTQ0NTE5YTQ3ODEzYmE0YTYzYjc0NiIsInVzZXJfaWQiOjR9.bUEP8vq0yhbRICtXjowThZ98Z77Fh9ArhLHWnflCbaA',
  access: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzEwNDQwNDMyLCJpYXQiOjE3MTA0NDAxMzIsImp0aSI6IjBjOTE5MzM5YWNhZTQ4MmVhNzZmMTRjZTFkNWY0YzhlIiwidXNlcl9pZCI6NH0.M0F-yIQ3hitTvbom-JVeWQqlfaEEesYADFPFlDU8Mxw'
}
[auth][error] CallbackRouteError: Read more at https://errors.authjs.dev#callbackrouteerror
[auth][cause]: TypeError: "ikm"" must be an instance of Uint8Array or a string

............

[auth][details]: {
  "provider": "credentials"
}
Authentication Error: CallbackRouteError: Read more at https://errors.authjs.dev#callbackrouteerror
    at Module.callback (webpack-internal:///(action-browser)/./node_modules/@auth/core/lib/actions/callback/index.js:437:23)

....................


type: 'CallbackRouteError',
  kind: 'error',
  [cause]: {
    err: TypeError: "ikm"" must be an instance of Uint8Array or a string

如有任何帮助,我将不胜感激。

我在Django、DRF上有一个后端,也使用了JWT Simple,但这并不重要。

后端创建端点:

127.0.0.1:8000/auth/login/
127.0.0.1:8000/auth/register/
127.0.0.1:8000/auth/refresh/token

它们都工作正常。使用邮递员检查。

在 Next js 14 的前端: 在项目根目录中,我有 auth.config.ts ,代码如下:

import type { NextAuthConfig } from "next-auth";

export const authConfig = {
  pages: {
    signIn: "/login",
  },
  callbacks: {
    authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user;
      const isHomepage = nextUrl.pathname.startsWith("/");
      if (isHomepage) {
        if (isLoggedIn) return true;
        return false; // Redirect unauthenticated users to login page
      } else if (isLoggedIn) {
        return Response.redirect(new URL("/", nextUrl));
      }
      return true;
    },
  },
  providers: [], // Add providers with an empty array for now
} satisfies NextAuthConfig;

auth.ts:

import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { authConfig } from "./auth.config";
import { API_URL } from "@/config";

export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
  providers: [
    CredentialsProvider({
      name: "Credentials",
      credentials: {
        username: { label: "Username", type: "text" }, 
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials, req) {
        const res = await fetch(`http://127.0.0.1:4000/auth/login/`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            username: credentials.username,
            password: credentials.password,
          }),
        });

        const data = await res.json();
        console.log("Ответ от API:", data);

        if (res.ok) {
          return {
            id: data.user.id,
            username: data.user.username,
            email: data.user.email,
            accessToken: data.access, 
            refreshToken: data.refresh,
          } as any;
        }

        if (!res.ok) {
          console.error("API request error:", data);
          return null; 
        }
      },
    }),
  ],
});

中间件.ts:

import { getToken } from "next-auth/jwt";
import { NextResponse } from "next/server";
import { AUTH_SECRET } from "@/config";

export async function middleware(req: any) {
  const session = await getToken({ req, secret: AUTH_SECRET });
  const { pathname } = req.nextUrl;

  
  const publicPaths = [
    "/login",
    "/register",
    "/api/auth",
    "/_next/static",
    "/_next/image",
  ];

  
  const isPublicPath = publicPaths.some((path) => pathname.startsWith(path));

  if (!isPublicPath && !session) {
    const loginUrl = new URL("/login", req.url);
    loginUrl.searchParams.set("callbackUrl", req.url); 
    return NextResponse.redirect(loginUrl);
  }

 
  return NextResponse.next();
}

export const config = {
  // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
  matcher: ["/", "/terms", "/contact", "/dashboard", "/profile"], 
};

应用程序/登录/page.tsx:

import LoginForm from "@/app/ui/login-form";

export default function LoginPage() {
  return (
    <main className="flex items-center justify-center md:h-screen">
      <div className="relative mx-auto flex w-full max-w-[400px] flex-col space-y-2.5 p-4 md:-mt-32">
        <div className="flex h-20 w-full items-end rounded-lg bg-blue-500 p-3 md:h-36">
          <div className="w-32 text-white md:w-36">Login</div>
        </div>
        <LoginForm />
      </div>
    </main>
  );
}

登录表单.tsx:

"use client";

import {
  UserIcon,
  KeyIcon,
  ExclamationCircleIcon,
} from "@heroicons/react/24/outline";
import { ArrowRightIcon } from "@heroicons/react/20/solid";
import { Button } from "./button";
import { useFormState, useFormStatus } from "react-dom";
import { authenticate } from "@/app/lib/actions";

export default function LoginForm() {
  const [errorMessage, dispatch] = useFormState(authenticate, undefined);

  return (
    <form action={dispatch} className="space-y-3">
      <div className="flex-1 rounded-lg bg-gray-50 px-6 pb-4 pt-8">
        <h1 className="mb-3 text-2xl">Please log in to continue.</h1>
        <div className="w-full">
          <div>
            <label
              className="mb-3 mt-5 block text-xs font-medium text-gray-900"
              htmlFor="username" 
            >
              Username
            </label>
            <div className="relative">
              <input
                className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
                id="username" 
                type="text" 
                name="username" 
                placeholder="Enter your username" 
                required
              />
              <UserIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />{" "}
            </div>
          </div>
          <div className="mt-4">
            <label
              className="mb-3 mt-5 block text-xs font-medium text-gray-900"
              htmlFor="password"
            >
              Password
            </label>
            <div className="relative">
              <input
                className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
                id="password"
                type="password"
                name="password"
                placeholder="Enter password"
                required
                minLength={6}
              />
              <KeyIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
            </div>
          </div>
        </div>
        <LoginButton />
        <div
          className="flex h-8 items-end space-x-1"
          aria-live="polite"
          aria-atomic="true"
        >
          {errorMessage && (
            <>
              <ExclamationCircleIcon className="h-5 w-5 text-red-500" />
              <p className="text-sm text-red-500">{errorMessage}</p>
            </>
          )}
        </div>
      </div>
    </form>
  );
}

function LoginButton() {
  const { pending } = useFormStatus();

  return (
    <Button className="mt-4 w-full" aria-disabled={pending}>
      Log in <ArrowRightIcon className="ml-auto h-5 w-5 text-gray-50" />
    </Button>
  );
}

app/lib/actions.ts:

"use server";

import { signIn } from "@/auth";
import { AuthError } from "next-auth";

export async function authenticate(
  prevState: string | undefined,
  formData: FormData
) {
  try {
    await signIn("credentials", formData);
  } catch (error) {
    if (error instanceof AuthError) {
      console.error("Authentication error:", error);
      switch (error.type) {
        case "CredentialsSignin":
          return "Invalid credentials.";
        default:
          console.log(error.message);
          return "Something got wrong." + error.message;
      }
    }
    throw error;
  }
}
typescript authentication next.js django-rest-framework next-auth
1个回答
0
投票

是的,这确实很有挑战性。我花了几天时间才成功实施。我会尽力帮助你。请根据您的需要调整代码示例。这是一个真实的工作示例。

  1. 添加 auth.ts 文件 将其放在项目的根级别

DESCRIPTION:在这个文件中,我们管理一些事情,例如登录过程,将未经授权的用户重定向到登录页面,保存刷新令牌,刷新过期的访问令牌等。请仔细观察。

import { cookies, headers } from "next/headers"

import NextAuth from "next-auth"
import type { NextAuthConfig } from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"

import { privateRoutes } from "@/contains/contants" // an array like ["/", "/account"]

// @ts-ignore
async function refreshAccessToken(token) { // this is our refresh token method
    console.log("Now refreshing the expired token...")
    try {
        // change it with your own endpoint
        const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/refresh`, {
            method: "POST",
            headers: headers(),
            body: JSON.stringify({ userID: token.userId })
        })

        const { success, data } = await res.json() // change it with your own response type

        if (!success) {
            console.log("The token could not be refreshed!")
            throw data
        }

        console.log("The token has been refreshed successfully.")

        // get some data from the new access token such as exp (expiration time)
        const decodedAccessToken = JSON.parse(Buffer.from(data.accessToken.split(".")[1], "base64").toString())

        return {
            ...token,
            accessToken: data.accessToken,
            refreshToken: data.refreshToken ?? token.refreshToken,
            idToken: data.idToken,
            accessTokenExpires: decodedAccessToken["exp"] * 1000,
            error: "",
        }
    } catch (error) {
        console.log(error)

        // return an error if somethings goes wrong
        return {
            ...token,
            error: "RefreshAccessTokenError",
        }
    }
}

export const config = {
    trustHost: true,
    theme: {
        logo: "https://next-auth.js.org/img/logo/logo-sm.png",
    },
    providers: [
        // we use credentials provider here
        CredentialsProvider({
            credentials: {
                email: {
                    label: "email",
                    type: "email",
                    placeholder: "[email protected]",
                },
                password: {
                    label: "password",
                    type: "password",
                },
            },
            async authorize(credentials, req) {
                const payload = {
                    email: credentials.email,
                    password: credentials.password,
                }

                // external api for users to log in, change it with your own endpoint
                const res = await fetch(`${process.env.API_BASE_URL}/auth/login`, {
                    method: "POST",
                    headers: {
                        "Content-Type": "application/json",
                        "Access-Control-Allow-Origin": "*",
                    },
                    body: JSON.stringify(payload)
                })

                const user = await res.json()

                if (!res.ok) {
                    throw new Error(user.message)
                }

                if (res.ok && user) {
                    const prefix = process.env.NODE_ENV === "development" ? "__Dev-" : ""

                    // we set http only cookie here to store refresh token information as we will not append it to our session to avoid maximum size warning for the session cookie (4096 bytes)
                    cookies().set({
                        name: `${prefix}xxx.refresh-token`,
                        value: user.refreshToken,
                        httpOnly: true,
                        sameSite: "strict",
                        secure: true,
                    } as any)

                    return user
                }

                return null
            }
        })
    ],
    // this is required
    secret: process.env.AUTH_SECRET,
    // our custom login page
    pages: {
        signIn: "/login",
    },
    callbacks: {
        async jwt({ token, user, account }) {
            if (account && user) {
                token.id = user.id
                token.accessToken = user.accessToken
                token.refreshToken = user.refreshToken
                token.role = "Unknown" // the user role

                const decodedAccessToken = JSON.parse(Buffer.from(user.accessToken.split(".")[1], "base64").toString())

                if (decodedAccessToken) {
                    token.userId = decodedAccessToken["sub"] as string
                    token.accessTokenExpires = decodedAccessToken["exp"] * 1000
                }

                // get some info about user from the id token
                const decodedIdToken = JSON.parse(Buffer.from(user.idToken.split(".")[1], "base64").toString())

                if (decodedIdToken) {
                    token.email = decodedIdToken["email"]
                    token.cognitoGroups = decodedIdToken["cognito:groups"]
                    token.role = decodedIdToken["cognito:groups"].length ? decodedIdToken["cognito:groups"][0] : "Unknown"
                }
            }

            // if our access token has not expired yet, return all information except the refresh token
            if (token.accessTokenExpires && (Date.now() < Number(token.accessTokenExpires))) {
                const { refreshToken, ...rest } = token

                return rest
            }

            // if our access token has expired, refresh it and return the result
            return await refreshAccessToken(token)
        },

        async session({ session, token }) {
            console.log("session => ", session)

            return {
                ...session,
                user: {
                    ...session.user,
                    id: token.id as string,
                    email: token.email as string,
                    cognitoGroups: token.cognitoGroups as string[],
                    accessToken: token.accessToken as string,
                    accessTokenExpires: token.accessTokenExpires as number,
                    role: token.role as string
                },
                error: token.error,
            }
        },
        authorized({ request, auth }) {
            const { pathname } = request.nextUrl

            // get route name such as "/about"
            const searchTerm = request.nextUrl.pathname.split("/").slice(0, 2).join("/")

            // if the private routes array includes the search term, we ask authorization here and forward any unauthorized users to the login page
            if (privateRoutes.includes(searchTerm)) {
                console.log(`${!!auth ? "Can" : "Cannot"} access private route ${searchTerm}`)
                return !!auth
            // if the pathname starts with one of the routes below and the user is already logged in, forward the user to the home page
            } else if (pathname.startsWith("/login") || pathname.startsWith("/forgot-password") || pathname.startsWith("/signup")) {
                const isLoggedIn = !!auth

                if (isLoggedIn) {
                    return Response.redirect(new URL("/", request.nextUrl))
                }

                return true
            }

            return true
        },
    },
    debug: process.env.NODE_ENV === "development",
} satisfies NextAuthConfig

export const { auth, handlers } = NextAuth(config)

我们的路由处理程序用于刷新我们在上面的文件中调用的访问令牌(路径:app/api/auth/refresh)

import { cookies } from "next/headers"

export async function POST(request: Request) {
    const body = await request.json()

    const prefix = process.env.NODE_ENV === "development" ? "__Dev-" : ""

    const payload = {
        refreshToken: cookies().get(`${prefix}xxx.refresh-token` as any)?.value,
        userID: body.userID,
    }

    // change it with your own endpoint
    const res = await fetch(`${process.env.API_BASE_URL}/auth/refresh`, {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
            "Access-Control-Allow-Origin": "*",
        },
        body: JSON.stringify(payload)
    })

    const data = await res.json()

    return Response.json({
        success: res.ok,
        status: res.status,
        data,
    })
}
  1. 添加自定义类型

为了能够使用我们自己的会话道具,我们需要在项目的根级别添加 type 文件夹并将 next-auth.d.ts 文件放入其中

这是我们的 next-auth.d.ts 文件,如果没有它,打字稿会抱怨

import { DefaultSession } from "next-auth"

declare module "next-auth" {
    interface User {
        id: string
        email: string
        cognitoGroups: string[]
        accessToken: string
        refreshToken: string
        idToken: string
        exp: number
        role: string
    }

    interface Session {
        user: User & DefaultSession["user"]
        expires: string
        error: string
    }
}
  1. 添加 [...nextauth] 文件夹 现在,我们在app/api/auth路径下添加[...nextauth]文件夹,并在其中添加route.ts文件,内容如下
import { handlers } from "auth"
export const { GET, POST } = handlers

不要忘记在 tsconfig.json 文件中添加身份验证路径

{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"],
      "auth": ["./auth"]
    }
  },
  "include": ["process.d.ts", "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}
  1. 添加您的环境变量 在再次位于根级别的 .env.local 文件中(假设您在 http://localhost:3000 上运行此应用程序),您应该添加这些变量
API_BASE_URL=https://api.yourdomain.com // external api for login, remember?
AUTH_SECRET=ac7uc0598805655a2b015a789ba889e0 // generate your secret here
AUTH_URL=http://localhost:3000/api/auth // important, you will have problems such as wrong redirect url issue without it
NEXT_PUBLIC_BASE_URL=http://localhost:3000

想知道制作过程吗?

API_BASE_URL=https://api.yourdomain.com // external api for login, remember?
AUTH_SECRET=bx7fg0593375655a2b015a789cc089x9 // just another secret
AUTH_URL=https://www.yourdomain.com/api/auth
NEXT_PUBLIC_BASE_URL=https://www.yourdomain.com

不要忘记在 Dockerfile 中添加这些变量(如果您使用的话)

  1. 登录

与许多示例一样,您可以将页面的某些部分转换为客户端组件,如下所示,并从服务器组件页面调用它。我添加了客户端组件部分作为示例。

"use client"

import { useSearchParams } from "next/navigation"
import type { SignInResponse } from "next-auth/lib/client"
import { signIn } from "next-auth/react"

import { Route } from "@/routers/types"

export default function LoginForm() {
    const searchParams = useSearchParams()

    async function login({ email, password }: any) { // whatever your type
        const callbackUrl = searchParams.get("callbackUrl")
        signIn("credentials", {
            email: email,
            password: password,
            redirect: false,
        })
            .then((res: SignInResponse | undefined) => {
                if (!res) {
                    alert("No response!")
                    return
                }

                if (!res.ok)
                    alert("Something went wrong!")
                else if (res.error) {
                    console.log(res.error)

                    if (res.error == "CallbackRouteError")
                        alert("Could not login! Please check your credentials.")
                    else
                        alert(`Internal Server Error: ${res.error}`)
                } else {
                    if (callbackUrl)
                        router.push(callbackUrl as Route)
                    else
                        router.push("/")
                }
            })
    }

    return (
        <>
            // ...
        <>
    )
}

就是这样。

如果您有任何问题请告诉我。

奖金

我在这里给你一个 HOC 来实现基于角色的访问

/****** withAuth HOC ******
Use this HOC if your page needs authentication to be accessed
- If the page to be accessed requires only authentication (a logged in user),
pass an empty array as allowedRoles
- If the page to be accessed requires both authentication and authorization (a logged in user with a specific role),
pass a string array as allowedRoles
 ******/
import React from "react"

import { redirect } from "next/navigation"

import { auth } from "auth"

import PageFourOhThree from "@/app/(unauthorized)/four-oh-three/page" // you design it

/* eslint-disable react/display-name */
export default function withAuth(allowedRoles: string[]) {
    return function <P extends object>(Component: React.FC<P>) {
        return async function (props: P) {
            const sess = await auth()

            // redirect the user to the login page if not logged in
            if (!sess)
                redirect("/login")

            // show 403 page if the user has not one of the required roles to access the page
            if (allowedRoles.length)
                if (!allowedRoles.includes(sess.user?.role)) {
                    return (
                        <PageFourOhThree />
                    )
                }

            return <Component {...(props as P)} />
        }
    }
}

这就是你如何使用它

import withAuth from "@/hoc/withAuth"
//..

export interface PageAboutProps {}

const PageAbout: FC<PageAboutProps> = () => {
    //..
}

export default withAuth(["Administrator"])(PageAbout) // which means only the users with Administrator role can access to this page
© www.soinside.com 2019 - 2024. All rights reserved.