Better

Ethan的博客,欢迎访问交流

Authentication and Authorization

登录鉴权和权限管理几乎是每个系统必备的功能,随着 React 版本更新和 NextJS SSR 模式的流行,在习惯 SPA 模式下,突然要在 SSR 模式下实现,出现了一种不适感。本文分别就 SPA、NextJS 同构、 NextJS 异构模式实现具备 refresh token 逻辑登录鉴权演示,同时还对开源方案 better-auth 进行尝试。

价值

权限控制的关键在于后端,前端权限控制的价值是

  • 提升突破权限的门槛
  • 过滤越权请求,减轻服务端压力
  • 提升用户体验

Refresh Token

通过 refresh token 机制实现仅连续 7 天未使用系统登录态才失效功能

  • 登录流程:用户登录成功后,服务端同时生成 access token 和 refresh token,设置 access token 用于接口鉴权,通常有效期比较短,如 2h,设计 refresh token 用于刷新 token,可存储在 redis 中,设置有效期为 7d
  • 业务请求通过 cookie/header 携带 access token,如 access token 过期,则接口返回 401
  • 客户端通过调用 refresh 接口,得到新的 access token 和 refresh token,旧的 refresh token 立刻过期,这样一来 refresh token 就近似 7d 未使用登录态才失效的效果(不考虑静态浏览)
  • 如果 refresh token 也失效了,此时也返回 401,同时客户端引导用户登录

该机制的优劣势

  • 更安全:设置较短时间的 accessToken 用于数据访问,如 2h,设置较长时间的 refreshToken 用于刷新 accessToken,如 7d
  • 客户端体验更好,除非用户连续超过 7d 未使用,否则登录态可以一直保持
  • 但实现变复杂了,尤其是客户端,需要在 accessToken 过期时自动发起刷新,因为请求可能是并发的,因此需要避免多次刷新 token,且当 token 刷新成功时,需要将之前积攒的请求队列进行重试清空,否则业务逻辑会出错。

为了增加安全性,建议 access token 采用 in-memory state,refresh token 采用 HTTP-only cookie 机制。

常见场景

关于登录认证的需求场景

  • 对于受保护的资源,当用户没有登录时则直接跳转到登录页要求用户登录,通常还会带上跳转前路径,方便登录成功后直接跳转到指定页面。
  • 如果用户已登录,用户依旧访问登录页,部分应用会选择自动跳转到首页

关于权限认证的需求场景,因为即使都是登录用户,但角色不同,权限也就不同,能看到和操作的东西就会有所差别。

  • 不同的用户对页面的访问权限不同
    • 路由菜单等入口不显示
    • 通过 URL 直接访问,渲染 404 或 403 组件
  • 不同用户在页面中可以看到的元素和操作不同
    • 元素直接消失不可见
    • 元素保留,但处于禁用态,同时引导用户购买 Pro 版等

在产品层面,建议将页面分为三类,首页(根路由)、公开页(如登录页)、保护页(如面板页)

  • 首页一定是可以正常访问的,只不过有些产品会针对是否登录有一些小的变化,如已登录,则显示去面板页,否则显示去登录
  • 公开页也一定可以正常访问的,只不过有些产品会对登录页在已登录的情况下设施默认跳转,我觉得这个可有可无吧
  • 保护页需要登录信息和权限信息,如未登录则自动跳转到登录页,一登录但无权限,则显示 403 页面等

上面指的是页面层级针对登录鉴权的逻辑,实际还有组件层级、接口层级

  • 组件级别:不同于页面级别处理逻辑是跳转或渲染另一个页面,组件层级是小范围的元素控制,因此我们需要一种方式能在组件里轻松得到当前用户信息
  • 接口级别:当用户已经进入系统,正常使用过程中,此时用户信息已过期,接口该如何处理的问题

登录鉴权的处理逻辑针对用户启动应用和持续使用中,也应该分开讨论

  • 应用启动时,如果登录信息已过期,用户通常希望自动引导登录,通常我们会通过路由守卫实现
  • 正常使用中,由于路由守卫只会加载执行一次?(发送请求/切换页面)后续使用由于不再发送检查请求,如果中途用户信息过期,此时应该自动跳转还是友好提示用户呢?

多种常见 Authorization 权限策略

  • RBAC - Role-Based Access Control
    • 将权限分配给角色,再将角色分配给用户来实现权限管理,关键部分是:User-Role-Permission
    • 优点是管理简单,适合组织结构清晰的场景
    • 缺点是不够灵活,难以处理复杂条件,以及角色爆炸问题
  • ABAC - Attribute-Based Access Control
    • 通过评估用户、资源、环境和操作的各种属性来决定访问权限,如实现【允许部门经理在工作时间(9:00-18:00)从公司网络访问其部门标记为'内部'的文档】
    • 优点是非常灵活,支持复杂条件,可以实现动态权限控制
    • 缺点是实现复杂,性能开销较大
  • ACL - Access Control List
    • 直接附加在资源上的权限列表,明确指定哪些用户/主体可以对该资源执行什么操作,每个资源维护一个列表,记录谁可以访问这个资源,可以进行什么操作
    • 优点是简单直观,细粒度控制,适合资源数量有限的场景
    • 缺点是难以大规模管理(每个资源单独维护),容易出现权限冗余

现代系统常组合使用这些策略,如使用RBAC作为基础,对特殊场景使用ABAC补充,对关键资源使用ACL进行精确控制。

其他场景:资源数量显示/账号有效期时长限制

下面根据 RBAC 策略和 React 技术栈简单谈下设计。

设计分析

根据上述 RBAC 提到的三个关键部分 User, Role, Permission,大概简单定义一下 User 数据模型如下。

interface User {
  // ……
  role: 'admin' | 'user';
  permission: { [key: string]: boolean }
}

主要 API 支持:login/logout/register/profile,分别用于登录、登出、注册、查询当前用户。

在 SPA 模式中,主要逻辑都发生在客户端,如在应用启动后,将用户信息从服务端同步到客户端,然后客户端根据用户信息来判断是跳转登录页还是正常渲染,这里面涉及到服务端的多次往返,而 SSR 模式下组件本身运行在服务端,可以更早查询用户信息,并且可以在服务端就触发重定向,理论上用户体验会更好。

主要围绕在如下两方面去设计功能

  • 我们需要地方初始化应用状态(当前用户信息等):在 SPA 模式下,我们通常在根组件下用 effect 同步信息,在 SSR 模式,可以在 rsc 阶段直接从服务端获取。
  • 为了开发方便,我们需要一种方式可以轻松获取到该状态(主要用 Context/Atom)去进一步实现条件渲染(权限保护)和重定向逻辑(路由保护)

如果仅在根组件做同步和保护逻辑,子路由之间的跳转可能有问题,如你在 dashboard/page.tsx 做检查,访问 dashboard/a,然后跳转 dashboard/b 此时由于 dashboard/page.tsx 并不会重新执行,可能就会有问题,所以你可能还需要处理一个类似 onPageChange 事件。

知识储备

实践该 demo 时需要了解的前置知识。

New React 知识

  • useActionState + form action
  • server actions + useTransition + event trigger
  • use server 用于声明该函数或该模块导出的函数是 Server Function,此时会自动创建该 Server Function 的引用,且传递给客户端组件
  • use client 框架默认所有组件是服务端组件,除非你手动通过该指令声明为服务端组件

Server Actions 优缺点

  • API 不会暴露在服务端
  • 使用 form action 提交时,即使 js 禁用也能工作
  • 类型更安全,因为通过 api 获取的数据是没有类型的,你并不知道服务端会返回什么
  • 本质还是 HTTP,但更简单了,不需要理解 HTTP,如 req/res/method 等
  • 相比编写 API 更简单,不需要写路由层、请求层,直接调用普通函数调用或编写即可
  • 缺点就是:不够独立了,因为比没有可公开的 HTTP API 入口,如果将来还有移动端,服务端逻辑就没法复用,同时也没办法对 HTTP 进行精细控制。说到底 API Routes 和 Servers Actions 只是对外暴露的方式不同,前者通过 HTTP 路由的形式,后者通过普通函数,但只能在 Web 端使用,编写后端实现时,遵循好的分层设计,也就是将 services 层写好,这样可以根据选择使用 API Routes 还是 Servers Actions。

针对场景分析了一下,我还是更倾向于开发一个独立后端,但借助 NextJS 的 SSR 能力,因此设计目标以独立后端+可服用的 service 层为中心,具体如下:

  • 独立的后端服务,Next 这边通过 axios/fetch 封装统一的 services 层
  • 当需要用到 SSR 能力时,直接在组件内调用 service
  • 如果需要用到 form action,则定义 server action,内部调用 services 层
  • API Routes 则仅扮演 bff 的角色,甚至可能不需要,只在需要进一步聚合数据时使用,如内部封装多个 service 的调用
  • Next proxy 中不推荐做很重的逻辑检查,仅推荐放轻逻辑,重逻辑要放在每个 page/route 中

NextJS 项目文件结构选择

  • 方案一:所有项目代码都放在 app 目录下
  • 方案二:把 app 仅做路由定义,其他可分享文件夹(如 components/utils)都放在根目录下(和 app 平级),如果还是习惯用 src 进行目录收敛,也是可以的

NextJS 文件规则

  • ../route 用于定义 API
  • ../(page|layout|loading|error) 用于定义路由相关
  • 并行路由:通过 @slot 定义,实现在同一个布局里并行渲染多个插槽,如邮箱/聊天类 UI:左侧列表、右侧详情、下方信息等各自独立导航,在目录下创建并行路由文件夹 @inbox, @detail 等,然后在父 layout 中接收同名 prop。
  • 拦截路由:拦截路由基于并行路由实现,拦截原本会导航到其他路径的请求,将其作为“浮层/模态”在当前页面上渲染,同时保留真实路由可直接访问(如刷新或直接访问 URL 时回退到完整页面)。通过 (.)folder, (..)folder, (..)(..)folder, (...)folder 实现拦截。这里实现有个小坑,每次重命名拦截路由目录时,总是会有个 .next 下文件提示被修改,我总是选择撤销修改不保存从而导致总是无效,实在不放心,可选择重启服务。

当我们因为 SSR 特性去使用 NextJS 时,我们很快就会陷入困惑,我应该使用 NextJS 的 API Routes/Server Actions 完成整个后端的编写吗?那我已有的后端服务怎么办(这其实很常见,因为现在更多的是前后端分离)?如果全用 NextJS Action 完成,那我如果还有移动端要开发,那我岂不是炸了?如果只是用 NextJS BFF 层,后端依旧保持独立,在 NextJS 中直接调用独立后端服务,而不是直接去查询数据库,所有功能都能正常工作。但一旦网站需要认证,就会出现问题,此时需要小心处理 cookies 传递问题。

实践过程中用到的 docker 命令

  • docker compose -f up; -d 表示是否 detach; --build 表示是否需要 build image,多次 up 不会有问题,有变更才会重启,否则保持
  • docker compose restart 重启容器,单纯 restart 不会导致数据丢失(即使没有 volume),但 down 会
  • docker compose down 关闭所有容器,-v 会删除关联卷
  • docker compose build 重新构建镜像,如果你改了 dockerfile/源代码
  • docker volume ls 查看所有卷
  • docker volume inspect 审查所有指定卷
  • docker volume rm 删除卷
  • docker exec -it sh|bash 进入指定容器
  • docker logs 查看指定容器日志
  • docker ps 等同于 docker container ls 查看运行中容器
  • docker rm 删除容器
  • docker image ls 查看所有镜像
  • docker image prune 删除所有游离镜像

SPA 模式

根据应用场景,模块应该具备的部分有

  • 考虑到登录鉴权模块是很通用的功能,最好能抽象出稳定的东西,将其解耦出来,方便在不同的系统中进行复用
  • 需要一个时机对关键信息 User 进行获取,考虑到路由层面和元素层面可能需要对是否登录和是否有权限做出反应,为方便后续组件设计,通过 Context 机制将关键信息注入到全局
  • 需要考虑到这部分数据的更改,如退出登录需要清除、登录成功需要手动注入
  • 需要考虑客户端不可感知的异常情况,如本来已登录有权限,但登录失效或权限被更改,从而导致的接口异常情况
  • 实现 component 当通过 URL 访问受保护资源时,未登录的自动跳登录页逻辑,无权限时渲染 404/403 组件
  • 实现一个 useAccess hook 和 Access component 供组件进行元素级别权限控制

简单模块设计图如下 auth-spa.png

关于登录部分的解耦,可以参考开源项目 Refine 的设计

export type AuthActionResponse = {
  success: boolean;
  redirectTo?: string;
  error?: RefineError | Error;
  [key: string]: unknown;
  successNotification?: SuccessNotificationResponse;
};
export type CheckResponse = {
  authenticated: boolean;
  redirectTo?: string;
  logout?: boolean;
  error?: RefineError | Error;
};
export type OnErrorResponse = {
  redirectTo?: string;
  logout?: boolean;
  error?: RefineError | Error;
};

export type AuthProvider = {
  login: (params: any) => Promise<AuthActionResponse>;
  logout: (params: any) => Promise<AuthActionResponse>;
  check: (params?: any) => Promise<CheckResponse>;
  onError: (error: any) => Promise<OnErrorResponse>;
  register?: (params: any) => Promise<AuthActionResponse>;
  forgotPassword?: (params: any) => Promise<AuthActionResponse>;
  updatePassword?: (params: any) => Promise<AuthActionResponse>;
  // 获取权限信息
  getPermissions?: (
    params?: Record<string, any>,
  ) => Promise<PermissionResponse>;
  // 获取用户信息
  getIdentity?: (params?: any) => Promise<IdentityResponse>;
};

关于 Authenticated 组件应用举例,实现如果未登录访问需授权信息,则跳转登录页,实现已登录访问登录页,则自动跳转内容页

<Route
  element={
    <Authenticated
      key="authenticated-inner"
      // CatchAllNavigate 的作用是跳转到指定路径,带上当前 location 信息作为 query params
      fallback={<CatchAllNavigate to="/login" />}
    >
      <Outlet />
    </Authenticated>
  }
>
  Private Content
</Route>
<Route
  element={
    <Authenticated
      key="authenticated-outer"
      fallback={<Outlet />}
    >
      {/* NavigateToResource 跳转到内容页面 */}
      <NavigateToResource />
    </Authenticated>
  }
>
  Login Page/Register Page/…
</Route>

SSR 模式

SSR 更多只是思维方式上的改变,习惯了之后比客户端应该是要更简单的,在 NextJS 中强调服务端优先的授权策略,在服务端进行授权检查,也就是 Layout、Page、Server Actions 或 Proxy 中验证权限。

  • 在 Proxy 中进行乐观检查
  • 在 Data Access Layer 集中处理授权逻辑,如 verifySession
  • 在 Data Transfer Objects 只返回必要信息

在 SPA 中的一种常见模式是,如果用户未获得授权,则在布局或顶级组件中返回 null。不推荐使用这种模式,因为 NextJS 应用程序有多个入口点,这不会阻止嵌套的路由段和 Server Action 被访问。

在 NextJS 同构模式中,如果你仅完成自身逻辑,你几乎可以通过 Server Component(Layout/Page……) + Server Actions 完成整个项目的编写,你甚至不需要用到 API Routes 特性,也不要了解 HTTP 是什么,因为它被 Server Actions 屏蔽了。这样完成登录鉴权的逻辑其实很自然,因为不存在客户端鉴权了,而服务端鉴权本来就是要写的。具体看底部仓库源码。

接口级别

请求层面的登录失效逻辑怎么处理呢?如果再扩展一下,因为我们通常预期接口都是成功的,只有网络异常或者权限等问题时才会出现请求失败,因此我们系统异常情况只需要在一个地方统一处理即可。

在 SPA 模式下,根组件通常都会处理未登录自动跳转逻辑,于是我在想,有没有可能将接口级别的 401 和全局状态联动起来,当接口 401 时修改 store 状态,此时根组件发现状态变了,则跳转登录页,这样一来将根组件和组件级别的跳转都统一了。

由于需要修改组件逻辑,则使用需要使用 useEffect 进行注入,举例如下

export default function useSetupFetch(instance: AxiosInstance) {
  const router = useRouter();
  const [, setIsLoginIn] = useAtom(isLoginAtom);

  useEffect(() => {
    instance.interceptors.response.use(undefined, (error) => {
      const { response } = error;
      // 处理 client 组件发起请求自动跳转登录页逻辑
      if (response && response.status === 401) {
        setIsLoginIn(false);
      }
      return Promise.reject(error);
    });
  }, [instance, router]);

  return null;
}

在 NextJS 应用中,如果用户手动触发请求,如点击加载更多,有三种请求

  • 使用 fetch/axios 请求独立后端服务
  • 使用 fetch/axios 请求 API Routes
  • 调用 Server Actions

以上请求是无法直接响应服务端跳转的,跳转都需要有客户端发起(请求 Server Actions 时,NextJS 会捕获对应错误,自动发起跳转),因此我们同样需要处理请求 401 的情况。举例如下

import { redirect } from 'next/navigation';

let router: RouterInstance | null = null;
export function setRouter(instance: RouterInstance) {
  router = instance;
}

import { useRouter } from 'next/navigation';
import { useEffect } from 'react';

// because of it can't use useSetupFetch in server component directly, need to wrap it in a client component
export default function SetupRequest() {
  const router = useRouter();

  useEffect(() => {
    setRouter(router);
  }, [router]);

  return null;
}

instance.interceptors.response.use(undefined, (error: any) => {
  const { response } = error;
  if (response && response.status === 401) {
    if (isServer) {
      // 当在 rsc Page 发起时,感觉各自处理好就行,不要在这里做统一拦截
      redirect('/login');
    } else if (router) {
      router.push('/login');
    }
  }
  return Promise.reject(error);
});

想学习下其他优秀站点是怎么处理中途 token 失效交互的,经调研发现,连 Figma 都没有处理 ajax 请求 401 时的异常情况。

Better Auth 设计

Better Auth - NextJS

  • create API Route /api/auth/[...all] 拦截所有 api/auth 请求
  • createAuthClient 处理客户端 signup/signin/signout/useSession/getSession/……
  • auth.api 处理服务端 signin/signup/getSession
  • auth protection proxy/middleware 检查 session cookie 是否存在
  • 默认过期策略:会话在 7 天后到期。但每当会话被使用且达到 updateAge 时,会话过期时间会更新为当前时间加上 expiresIn 值。

Prisma + Better Auth 小试牛刀 - 简单整理下使用过程

  • 安装开发依赖 prisma tsx @types/pg --save-dev,此处的 prisma 提供 CLI 和迁移工具,并不是运行时必须
  • 安装生产依赖 @prisma/client @prisma/adapter-pg dotenv pg,分别是 prisma 主要 api,针对 postgresql 的适配器和 postgresql 客户端本身
  • 初始化 prisma 环境,npx prisma init --db --output ../src/generated/prism,会生成 schema.prisma 配置和写入 DATABASE_URL。需要表结构时,更新 schema.prisma 即可,然后通过 npx prisma migrate dev --name <file_name> 生成迁移脚本
  • 生成 prisma 客户端,npx prisma generate,会在你 prisma init 指定的 output 位置自动生成 prisma 客户端代码,注意路径是相对 schema.prisma 而言的
  • 初始化 better-auth 需要的配置,npx @better-auth/cli generate,需要指定你的 auth.ts(因为需要相关信息生成不同的配置),会更新你的 scheme.prisma 文件
  • 由于更新了 schema 文件,需要迁移,通过 npx prisma migrate dev --name <file_name> 生成迁移脚本,他会读取 scheme 变更,生成一份迁移文件
  • 运行 npx prisma generate 更新 prisma client,这样你才能访问到更新后的 Data Model
  • 添加 "postinstall": "prisma generate" 确保 Prisma Client 已经生成

开发环境主要用到 prisma migrate dev,主要工作流如下

  • 更新 prisma scheme
  • 通过 prisma migrate dev 生成 migration.sql,更新 Database schema,重新生成 prisma client
  • 将 prisma scheme 和 migration.sql 提交到 repo

在 CI/CD 组要用到 prisma migrate deploy

  • 理想情况下,migrate deploy 应该成为自动 CI/CD 流水线的一部分
  • 只有当 prisma/migrations 目录发生变化时,这个操作才会执行,所以 prisma migrate deploy 只会在迁移更新时运行

Prisma 常用命令

  • prisma db push:把 schema.prisma 推到数据库(修改数据库结构),不生产迁移文件,适合原型开发
  • prisma db pull:把 数据库结构反向拉回 schema.prisma(生成/更新模型),不会改数据库,适合已有数据库反向建模。
  • prisma migrate dev:生成迁移文件并应用到数据库,有迁移历史,适合正式开发流程。

checklist

登录鉴权功能是否完整的 check 清单

  • 没有登录信息,正常登录,是否跳转私有页
  • 退出登录后,应该回到首页,重新登录,应成功跳转默认私有页
  • 有登录信息,直接访问私有页,应该成功访问
  • 有登录信息,但 access token 过期,直接访问私有页,应该无感刷新 token
  • 没有登录信息,直接访问私有页,应重定向到登录页,同时保留原始访问地址,此时成功登录后,应成功回到之前页面
  • 有登录信息,但 access token 和 refresh token 均过期,直接访问私有页,应等同上一条
  • 并发请求时,应防止多个请求重复刷新 token,后续请求应等待刷新完成
  • 页面长时间停留,access token 过期,点击发送业务请求,应无感刷新,并继续原有逻辑
  • 页面长时间停留,access token 和 refresh token 均过期,点击发送业务请求,应该友好提示或模态框引导登录(直接跳转登录页会很突兀)
  • 刷新 refresh token 时网络问题导致失败的处理

源码



留言