返回博客归档

ISSUE / 016

纯前端的 Next.js 项目如何实现 Authentication

本文将介绍基于 Next.js(非 Next.js FullStack 项目,以下简称 Web应用/项目) 的Web 项目中是如何集成自有后端完成 Authentication 功能的设计和实现。 名词解释 在开始深入讨论我们设计之前,让我们先明确几个概念: Next.js 在项目中的定位 Next.j…

纯前端的 Next.js 项目如何实现 Authentication的文章题图

本文将介绍基于 Next.js(非 Next.js FullStack 项目,以下简称 Web应用/项目) 的Web 项目中是如何集成自有后端完成 Authentication 功能的设计和实现。

名词解释

在开始深入讨论我们设计之前,让我们先明确几个概念:

Next.js 在项目中的定位

Next.js 提供了前端渲染的能力和服务端的 Route Handlers 的能力。在 Next.js 渲染能力中,也包含了一些服务端的概念,比如服务端渲染/服务器组件,请注意,这仍然属于前端渲染能力。在本文介绍的 Web 项目中,我们并没有用到 Next.js 的 Route Handlers 能力,所以这类项目仍然是一个纯粹的前端应用,而不是一个全栈应用。这种架构在多数项目中可能更常见,因为大部分的时候会有专业的后端人员实现后端服务,而不是 Next.js 提供。

Authentication

认证是验证用户身份的过程,回答的是"你是谁?"的问题。认证通常包括以下步骤:

  • 用户提供证明其身份的凭据(如邮箱/密码、手机验证码、社交账号令牌等)
  • 系统验证这些凭据的真实性
  • 成功后,系统生成会话标识符(SessionID)或令牌(Token)。

这边简单说明下 SessionID 和 Token 的区别:

特性 SessionID 令牌(Token)
存储位置 主要信息存储在服务器端,客户端只保存标识符 信息直接编码在令牌中,可完全存储在客户端
状态管理 有状态(服务器需要维护会话状态) 无状态(服务器不需要存储会话信息)
工作原理 1. 服务器生成标识符
2. 客户端每次请求发送该标识符
3. 服务器查找对应会话数据
1. 服务器生成自包含令牌
2. 客户端每次请求发送令牌
3. 服务器验证令牌有效性
安全特性 标识符本身无含义,需要服务器解释 通常包含加密的用户信息和权限
可扩展性 较差,需要在多服务器间共享会话存储 较好,服务器无需存储会话状态
服务器负载 较高,需要存储和查询会话数据 较低,只需验证令牌
过期处理 由服务器控制,可随时使会话失效 令牌自身包含过期时间,一旦发出难以撤销
典型传输方式 Cookie Authorization 头、Cookie

这个差异是指服务端实现的差异,对于前端来说,SessionID 和 Token 并没有太大的差异,前端只需要按照服务端要求传递凭证即可。

Session Management

会话管理是指在用户认证后,维护用户状态的机制。会话管理需要解决的问题包括:

  • 会话创建:用户成功认证后,服务器创建会话记录
  • 会话持久化:通过cookies或其他存储机制保存会话标识符
  • 会话验证:每次请求时验证会话的有效性
  • 会话刷新:延长会话有效期
  • 会话终止:用户登出或会话超时时销毁会话

Authorization

授权是确定用户有权执行什么操作的过程,回答的是"你能做什么?"的问题。授权在认证之后进行,基于用户的身份和角色决定其访问权限。授权包括:

  • 访问控制(用户是否可以访问特定页面或资源)
  • 操作权限(用户是否可以执行特定操作)
  • 数据权限(用户是否可以查看或修改特定数据)

为什么不是 Next-Auth

开源社区有很多基于 Next.js 的身份验证库,比较流行的有:

  • Next-Auth(Auth.js)
  • Clerk
  • Supabase

这类的身份验证库,无一例外的全部依赖 Next.js 的 Route Handlers 能力,比 Next-Auth 为例,它的工作流程如下:

Image

而我们自有后端的授权流程如下图所示:

Image

显而易见,因为 Next.js 在这类 Web 应用中的定位,我们无法开箱即用社区提供的方案,所以才有了这篇文章,需要自己去实现 Authentication 的功能。

Authentication 实现

基于上面的讨论,要完成 Web 应用 的 Authentication 的设计,需要完成这三方面的工作:

  • Authentication
  • Session Management
  • Authorization

以下代码示例为伪代码

Authentication

在 Web 应用中,Authentication 的方式包含了 Email + Password/Phone Number/OAuth等。认证成功之后,后端会返回 Session ID并存储在客户端的 Cookie 中。

import { signIn, signOut } from '@/lib/auth/client';

function LoginForm() {
  const { refreshSession } = useSession();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleEmailLogin = async () => {
    try {
      await signIn('email', { email, password });
      refreshSession();
    } catch (error) {
      console.error('signIn error:', error);
    }
  };

  const handleLogout = async () => {
    try {
      await signOut();
    } catch (error) {
      console.error('signOut error:', error);
    }
  };

  return (
    <form>
      {/* Form content */}
    </form>
  );
}

因为认证的方式包含了 Email + Password/Phone Number/OAuth,所以这边借鉴了 Next-Auth 的 Provider 设计,每一种认证方式对应一个 Provider,每个 Provider 的实现如下:

export function EmailProvider(): EmailProvider {
    return {
        id: "email-password",
        name: "email-password",
        type: "credentials",

        async signIn(credentials: EmailPasswordCredentials) {
            try {
                const response = await UserAJAXWebService.login(credentials);
                return response;
            } catch (error) {
                console.error("signIn error:", error);
                throw error;
            }
        },

        async signUp(credentials: RegisterCredentials) {
            try {
                const response = await UserAJAXWebService.register(credentials);
                return response;
            } catch (error) {
                console.error("signUp error:", error);
                throw error;
            }
        },
    };
}

登录成功之后后端已经更新了 SessionID 的内容,需要调用 refreshToken 方法,或者 router.refresh 或者 window.location.reload 来确保页面可以重新从 WebServices 获取最新的 Session。

此处根据后端的实现做相应调整

Session Management

Session Management 分别实现了 Server Side 和 Client Side 的 APIs

For Server Side

getServerSession

在 Server Side,仅提供了一个可访问的 API: getServerSession:

interface UserSession {
    profile: ProfileAJAXView | null;
    sessionId: string | null;
    isAuthenticated: boolean;
}

type getServerSession = (queryClient?: QueryClient) => UserSession;

然后在 app/layout.impl.ts 使用它:

// app/layout.impl.ts

export default async function LayoutImpl({ children }: { children: React.ReactNode }) {
    const queryClient = new QueryClient();

    // ...

    await getServerSession(queryClient);

    return (
        <HydrationBoundary state={dehydrate(queryClient)}>
            {/** Layout Content */}
        </HydrationBoundary>
    );
}

这边因为需要依赖 ReactQueryProvider 提供的上下文能力(无须再实现一个 SessionProvider 了),所以又拆分了一个 layout.impl.ts 出来:

export default async function RootLayout({
    children,
}: Readonly<{
    children: React.ReactNode;
}>) {
    return (
        <html lang="en">
            <body className={`${themeClassname}`}>
                <ReactQueryProvider>
                    <LayoutImpl>{children}</LayoutImpl>
                </ReactQueryProvider>
            </body>
        </html>
    );
}

这边为了演示直接在 layout 中使用了 getServerSession,实际场景你可以调整一下,例如只在需要的页面设置 getServerSession。

如果服务端页面/组件需要访问 getServerSession,也请配合使用 queryClientHydrationBoundary,因为实现依赖了 React-Query,这样可以避免在客户端渲染的时候重复请求。

For Client Side

useSession

提供一个 React Hooks 在客户端组件中使用:

interface UserSession {
    profile: ProfileAJAXView | null;
    sessionId: string | null;
    isAuthenticated: boolean;
}

interface SessionProps {
    session: UserSession | null;
    isLoading: boolean;
    isError: boolean;
    error: Error;
    refreshSession: () => void;
    isAuthenticated: boolean;
}

type useSession = () => SessionProps;

在需要访问 Session 的组件中使用它:

import { useSession } from '@/lib/auth/client';

function ProfileComponent() {
  const {
    session,
    isLoading,
    isError,
    error,
    isAuthenticated,
    refreshSession
  } = useSession();

  if (isLoading) return <div>Loading...</div>;
  if (isError) return <div>Error: {error.message}</div>;

  if (isAuthenticated) {
    return (
      <div>
        <h1>Welcome, {session.profile.name}</h1>
        <p>Email: {session.profile.email}</p>
        <button onClick={refreshSession}>refresh</button>
      </div>
    );
  }

  return <div>Please sign in.</div>;
}

getSession

当需要在非 React 世界中访问 Session 的时候,可以调用 getSession

type getSession = () => UserSession;

signIn

type signIn = (providerId: string; providerOptions: any) => Promise<UserSession>

signOut

type signOut = () => Promise<void>;

Authorization

对于需要授权访问的页面/接口,需要进行授权检测,通过以下几种方式配合工作。

middleware.ts

为了方便管理多个 middleware 的执行顺序,我们先创建一个工具函数:

import { NextResponse } from "next/server";

function chain(functions, index = 0) {
    const current = functions[index];
    if (current) {
        const next = chain(functions, index + 1);
        return current(next);
    }
    return () => NextResponse.next();
}

然后在创建我们的 ProtectedMiddleware.ts

function withProtectedMiddleware(middleware) {
    return async (request) => {
        const sessionCookie = request.cookies.get("sessionId");

        if (!sessionCookie) {
            return middleware(request);
        }

        const isAuthenticated = sessionCookie.value.includes("user_id");

        const { pathname } = request.nextUrl;

        if (!isAuthenticated && isProtected(pathname)) {
            return NextResponse.redirect("/");
        }

        return middleware(request);
    };
}

src/middleware.ts 文件中使用它:

export default chain([withCheckSession, withProtectedMiddleware]);

Page

import { useSession } from '@/lib/auth/@client';

function ProfileComponent() {
  const {
    session,
    isLoading,
    isError,
    error,
    isAuthenticated,
    refreshSession
  } = useSession();

  if (isLoading) return <div>Loading...</div>;
  if (isError) return <div>Error: {error.message}</div>;

  if (isAuthenticated) {
    return (
      <div>
        <h1>Welcome, {session.profile.name}</h1>
        <p>Email: {session.profile.email}</p>
        <button onClick={refreshSession}>refresh</button>
      </div>
    );
  }

  return <div>Please sign in.</div>;
}

Interceptor

额外的,我们还需要在请求工具中设置拦截器,拦截器中处理后端接口返回的 HTTP Code 401 和其他 ERROR_CODE:

const handleUnauthorizedException = async (error: APIException): Promise<boolean> => {
    // ...
};

具体的实现就看前后端的约定,以及一些交互行为在产品中是怎么设计的,调整一下就行了。

总结

当你尝试自己对接后端服务实现 Authentication 功能的时候,可以参考一下上面的实现。如果你的 Next.js 项目是一个 FullStack 项目,那开源社区的方案会更加适用。