Next.js 应用实现权限管理

12,539 阅读8分钟

文章为稀土掘金技术社区首发签约文章,14 天内禁止转载,14 天后未获授权禁止转载,侵权必究!

前言

在前面的文章中《使用 NextAuth.js 给 Next.js 应用添加鉴权与认证》,我们使用了 Github OAuth 和邮箱认证登录,我们的视频网站就有了用户系统,和用户系统离不开的,便是权限系统,今天我们就聊一聊权限系统的设计与实现,要在网站中实现复杂的权限管理对应新手来说,这可能会是比较困难的,但权限系统是软件中不可或缺的部分,我们只要掌握一个套路,就会变得非常简单,一起来看看吧!

权限区分

因为有了权限,我们可以在一个系统中实现各种各样的功能,系统也会变得庞大而复杂。一般可以将权限分为“功能权限”、“数据权限”和“字段权限”。

功能权限:用户具有哪些权利,例如特定数据的增、删、改、查等;比如在一个视频网站中,超级管理员拥有对所有视频的审核权限,而普通用户只能拥有对着自己视频的编辑和删除权限。功能权限需要前后端共同实现;

数据权限:用户可以看到哪些范围的主数据。比如视频网站中,VIP 用户可以看到 VIP 视频,而非 VIP 用户只能看普通视频。数据权限主要是后端实现;

字段权限:在特定的数据表中,可以看到哪些字段;比如普通用户能够看到其他用户的基本信息,但是看不到其它人的账户信息。字段权限也主要是由后端实现;

权限系统设计

我们可以将网站中的功能按角色划分,根据不同的角色来指定不同的权限,这便是大部分网站的实现方式。 比如在视频网站中我们可以将角色划分为:

前台角色

  • 普通用户
  • VIP 用户

我们可以在数据库中加入 2 个字段区分,User 表加入 isVip, Video 表加入 vip,prisma Schema 定义如下:

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String?   @unique
  isVip         Boolean
  emailVerified DateTime?
  Video         Video[]
}

model Video {
  id    Int    @id @default(autoincrement())
  title String @unique
  vip        Boolean
  desc       String?
}

那么我们通过接口就可以查出 Vip 视频。

import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { unstable_getServerSession } from "next-auth/next";

export default async function handler( req: NextApiRequest, res: NextApiResponse ) {
  const session = await unstable_getServerSession(req, res, authOptions);

  if (!session) {
    res.status(401).json({ message: "You must be logged in." });
    return;
  }

  const user = await prisma.user.findFirst({
    where: {
      id: session.id as string,
    },
  });

  if (!user.isVip) {
    res.status(401).json({ message: "You are not vip user" });
    return;
  }
  const videos = await prisma.video.findMany({
    where: {
      vip: true,
    },
  });
}

上面代码中,先通过 session 获得用户 id,再查询用户是否为 vip,若为 vip 则查询出 vip 视频,不是则返回 401。

后台角色

  • 视频管理员:用于视频审核,对不合法的视频进行下架。
  • 超级管理员:拥有所有权限,用户管理、视频管理等

备注:由于系统比较简单,我们将前台后台的用户系统使用同一个,只要在数据库中设置一个字段isAdmin ,然后在页面上根据这个字段显示后台管理入口,就可以实现管理视频啦。

数据表关系图

一般情况下,我们需要给网站添加 2 张表:一张是角色表(Rule)、一张是权限表(Permission),角色和权限的关系是多对多的关系,一个角色可以有多个权限,一个权限也可以赋给多个角色, 因此需要加入第三章表关联表,在 Prisma 中,关联表一般使用 TablesOnTables的形式设计,使用 @relation关联表中的附键,下面代码就是 prisma Schema 代码

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String?   @unique
  roleId        String?
  role          Role?     @relation(fields: [roleId], references: [id])
}

model Role {
  id                 String               @id @default(cuid())
  name               String
  PermissionsOnRoles PermissionsOnRoles[]
  User               User[]
}

model Permission {
  id                 String               @id @default(cuid())
  pid                String?
  name               String
  code               String
  PermissionsOnRoles PermissionsOnRoles[]
}

model PermissionsOnRoles {
  role         Role       @relation(fields: [roleId], references: [id])
  roleId       String
  permission   Permission @relation(fields: [permissionId], references: [id])
  permissionId String
  assignedAt   DateTime   @default(now())

  @@id([roleId, permissionId])
}

prisma/schema.prisma文件中修改 schema, 修改完 prisma schema 后,执行以下命令,我们便可以往数据库迁移,生成真实的表。

npx prisma migrate dev

执行后,会在 prisma/migrations/*/migration.sql 文件下生成 Sql 语句,效果如下。

生成的sql语句

系统中,一般权限数据都数据库内置的,因此需要新建一个seed.ts文件,可以方便我们往默认数据库插入数据

import { PrismaClient } from "@prisma/client";

// initialize Prisma Client
const prisma = new PrismaClient();

async function main() {
  await prisma.permission.createMany({
    data: [
      {
        name: "用户管理",
        code: "user_management",
      },
      {
        name: "视频管理",
        code: "video_management",
      },
    ],
  });

  await prisma.role.create({
    data: {
      name: "超级管理员",
      permissions: {
        create: [
          {
            assignedAt: new Date(),
            permission: {
              connect: {
                code: "video_management",
              },
            },
          },
          {
            assignedAt: new Date(),
            permission: {
              connect: {
                code: "user_management",
              },
            },
          },
        ],
      },
    },
  });

  await prisma.role.create({
    data: {
      name: "视频管理员",
      permissions: {
        create: [
          {
            assignedAt: new Date(),
            permission: {
              connect: {
                code: "video_management",
              },
            },
          },
        ],
      },
    },
  });
}

main()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(async () => {
    // close Prisma Client at the end
    await prisma.$disconnect();
  });

执行npx ts-node prisma/seed.ts,就可以初始化角色和权限数据了,上述代码中我们设置了 2 个角色,分别为超级管理员和视频管理员,添加了 2 个权限分别为视频管理和用户管理并且设置了唯一键 code,code 可以拥有前端权限的判断。

后端接口设计

有了数据库和数据我们便可以实现一个接口,“用户信息接口”,我们将它定义为/api/user/me,用于返回权限信息

新建一个pages/api/me.ts 文件, 代码如下

import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { unstable_getServerSession } from "next-auth/next";
import prisma from "@/lib/prisma";
import { makeSerializable } from "@/lib/util";
import { NextApiRequest, NextApiResponse } from "next";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const session = await unstable_getServerSession(req, res, authOptions);

  if (!session) {
    res.status(401).json({ message: "You must be logged in." });
    return;
  }

  const user = await prisma.user.findFirst({
    where: {
      id: session.id as string,
    },
  });

  const permissionsOnRoles = await prisma.permissionsOnRoles.findMany({
    where: {
      roleId: user.roleId,
    },
  });
  const ids = permissionsOnRoles.map((item) => item.permissionId);

  const permissions = await prisma.permission.findMany({
    where: {
      id: {
        in: ids,
      },
    },
  });

  return res.json({
    user,
    permissions,
  });
}

上面的代码中,查询步骤为:

  • 先通过 session 获得用户 id
  • 通过用户 id 查询用户信息,获得角色 id
  • 通过角色 id 查询关联表,获得权限 id
  • 再通过权限 id 查询权限信息。

以上代码便是多对多查询过程,访问接口,就可以获得当前用户的权限信息了。

权限接口查询

那么前端就可以通过该接口来判断功能权限了,至此后端权限部分就完成了。

React 中实现权限管理

在 React 中实现状态管理,我们可以在整个 App 组件(跟组件)渲染前选请求me接口,然后通过 React Context 将 permission 信息进行全局状态管理,这样我们就可以在任意组件获取权限信息,进行权限判断了。

import React, { useContext, useEffect } from 'react';
import axios from 'axios';
import {
  createBrowserRouter,
  RouterProvider,
  Route,
  Outlet,
} from 'react-router-dom';

const PermissionContext = React.createContext([]);

const usePermission = function () {
  return useContext(PermissionContext);
};

function Permission({ code, children }) {
  const permissions = usePermission();
  if (permissions.includes(code)) {
    return children;
  }
  return null;
}

export default function App() {
  const [permissions, setPermissions] = React.useState([]);

  useEffect(() => {
    axios.get('/api/user/me').then((res)=>{
      const permissions=res.data.map(item=>item.code)
      setPermissions(permissions);
    })
  }, []);
  
  return (
    <div>
      <PermissionContext.Provider value={permissions}>
        <RouterProvider router={router} />
      </PermissionContext.Provider>
    </div>
  );
}

上面代码中,我们创建了一个我们定义了一个Permission组件,那么在项目中,只要在要判断权限的地方使用该组件,若没有权限,则会不显示。我们还定义了一个usePermission 自定义 hooks,这样在后续开发中,若要使用到权限,我们就可以直接使用这个 Hooks,界面 UI 也可以重新定义了。

比如在 React-router 路由(V6)中,直接嵌套一个Permission 组件便可以实现权限控制了。

const router = createBrowserRouter([
  {
    path: '/',
    element: <div>首页</div>,
  },
  {
    path: '/login',
    element: <div>登录</div>,
  },
  {
    path: '/admin',
    element: (
      <div>
        后台管理 <Outlet />
      </div>
    ),
    children: [
      {
        path: 'user',
        element: (
          <Permission code="user_management">
            <div>用户管理</div>
          </Permission>
        ),
      },
      {
        path: 'video',
        element: (
          <Permission code="user_management">
            <div>视频管理</div>
          </Permission>
        ),
      },
    ],
  },
]);

Next.js 实现权限管理

在 Next.js 中,我们可以同 React 一样的方式来实现权限控制。若是服务端渲染的页面,我们也可以在服务端控制。

import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { unstable_getServerSession } from "next-auth/next";
import prisma from "@/lib/prisma";
import { makeSerializable } from "@/lib/util";

export async function getServerSideProps(context) {
  const session = await unstable_getServerSession( context.req, context.res, authOptions );

  if (!session) {
    return {
      redirect: {
        destination: "/403",
        permanent: false,
      },
    };
  }

  //const permissions =  prisma query

  if(!permissions.includes('video_management')){
    return {
      props: {
        errorCode:403,
      },
    };
  }

  const data = await prisma.video.findMany({
    include: { author: true },
  });

  return {
    props: {
      session,
      data: makeSerializable(data),
    },
  };
}

同接口的方式一致,我们也可以在 getServerSideProps 通过获得 session,然后获得用户权限,再通过权限判断,是否让页面显示403。

那如果有多个页面有需要权限判断,该怎么办呢?我们可以在根目录下建立一个middleware.js, 中间件会在每个请求的时候执行。

import { getToken } from "next-auth/jwt"
import { NextResponse } from "next/server"

export async function middleware(req) {
  // 如果url不应该受到保护,请尽早返回
  if (!req.url.includes("/protected-url")) {
    return NextResponse.next()
  }

  const session = await getToken({ req, secret: process.env.SECRET })
  if (!session) return NextResponse.redirect("/api/auth/signin")
  ...

  // 如果授权通过则继续。
  return NextResponse.next()
}

在中间件中,我们也可以获得session,那么与 api 接口一样,就可以在中间件中判断权限信息了,有一点需要注意的是,Url 不需要权限判断,我们应该尽早返回,这样可以避免多余的查询。

小结

本文以视频网站为例,讲解了权限系统的设计与实现,主要涉及到的知识点有:

  • 后端基于角色表和权限表,多对多表结构设计
  • Prisma 中实现多对多关系查询
  • 前端使用 React Context 和 自定义 hooks 实现全局状态管理
  • 利用 Next.js 的 middleware 也可以获得 session,并且用于权限判断。

好了,以上就是本文的全部内容,你学会了吗?接下来我将继续分享 Next.js 相关的实战文章,欢迎各位关注我的《Next.js 全栈开发实战》 专栏,感谢您的阅读。