ISSUE / 016
纯前端的 Next.js 项目如何实现 Authentication
本文将介绍基于 Next.js(非 Next.js FullStack 项目,以下简称 Web应用/项目) 的Web 项目中是如何集成自有后端完成 Authentication 功能的设计和实现。 名词解释 在开始深入讨论我们设计之前,让我们先明确几个概念: Next.js 在项目中的定位 Next.j…
本文将介绍基于 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 为例,它的工作流程如下:
而我们自有后端的授权流程如下图所示:
显而易见,因为 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,也请配合使用 queryClient 和 HydrationBoundary,因为实现依赖了 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 项目,那开源社区的方案会更加适用。
