Next-js 應用實現權限管理

前言

在前面的文章中《使用 NextAuth.js 給 Next.js 應用添加鑑權與認證》[1],我們使用了 Github OAuth 和郵箱認證登錄,我們的視頻網站就有了用戶系統,和用戶系統離不開的,便是權限系統,今天我們就聊一聊權限系統的設計與實現,要在網站中實現複雜的權限管理對應新手來說,這可能會是比較困難的,但權限系統是軟件中不可或缺的部分,我們只要掌握一個套路,就會變得非常簡單,一起來看看吧!

權限區分

因爲有了權限,我們可以在一個系統中實現各種各樣的功能,系統也會變得龐大而複雜。一般可以將權限分爲 “功能權限”、“數據權限” 和“字段權限”。

功能權限:用戶具有哪些權利,例如特定數據的增、刪、改、查等;比如在一個視頻網站中,超級管理員擁有對所有視頻的審覈權限,而普通用戶只能擁有對着自己視頻的編輯和刪除權限。功能權限需要前後端共同實現;

數據權限:用戶可以看到哪些範圍的主數據。比如視頻網站中,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[2] 中,關聯表一般使用 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,

  });

}

上面的代碼中,查詢步驟爲:

以上代碼便是多對多查詢過程,訪問接口,就可以獲得當前用戶的權限信息了。

權限接口查詢

那麼前端就可以通過該接口來判斷功能權限了,至此後端權限部分就完成了。

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 不需要權限判斷,我們應該儘早返回,這樣可以避免多餘的查詢。

小結

本文以視頻網站爲例,講解了權限系統的設計與實現,主要涉及到的知識點有:

好了,以上就是本文的全部內容,你學會了嗎?接下來我將繼續分享 Next.js 相關的實戰文章,歡迎各位關注我的《Next.js 全棧開發實戰》 專欄,感謝您的閱讀。

[1] 使用 NextAuth.js 給 Next.js 應用添加鑑權與認證: https://juejin.cn/post/7155514465591984136

[2]NodeJS ORM: https://www.prisma.io/

[3]NextAuth.js Auth Middleware for Next.js 12: https://gist.github.com/balazsorban44/30e2267fe1105529f217acbe3763b468

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/DRc8YO7-SfNxs2SgkNO8gA