本篇介紹Vue Nuxt 3的側邊功能選單(Sidebar)的權限控管實作。
專案目前只有一個使用者[admin],其擁有側邊選單所有權限的角色[admin],換句話說就是可見和可點擊側邊選單的所有功能項目。接下來會加入一個一般使用者與角色[user],只允許使用[出口業務],但無法使用[會員管理]。
這種根據使用者擁有的角色的權限控管方式簡稱為RBAC(Role-Based Access Control,基於角色的存取控制)。
範例只有兩個角色且僅控制頁面是否隱藏,所以僅使用[角色]來做權限控制,但實務上比較複雜的權限控制會帶入[群組(Group)]和[權限(Permission)]的概念。
事前要求
參考「Vue Nuxt 3 加入側邊選單」,為側邊選單來源。
參考「Vue Nuxt 3 登入驗證」,了解登入驗證方式。
參考「Vue Nuxt 3 登入狀態管理」,了解前端狀態同步。
建立User介面
在專案根目錄新增shared/types/user.ts。Nuxt專案的shared目錄中的程式碼可讓前端Vue和server公享(參照)。
User介面如下,另外定義去除敏感資訊password屬性的PublicUser介面。
shared/types/user.ts
export interface User {
username: string;
password: string;
name: string;
role: "admin" | "user";
}
export type PublicUser = Omit<User, 'password'>
建立模擬資料
新增server/data/users.ts,用來模擬後端的使用者資料及所持的角色,可假想為資料表中的資料。引入User介面作為使用者的資料格式。
定義兩個使用者,角色(role)分別為管理員[admin]和一般使用者[user]。
server/data/users.ts
import type { User } from '~/shared/types/user'
export const users: User[] = [
{
username: "admin",
password: "1234",
name: "admin",
role: "admin",
},
{
username: "wang1",
password: "1234",
name: "王一",
role: "user",
},
];
修改登入API
修改p登入API]檔案server/api/auth/login.post.ts,把檢驗帳號密碼的部分改為從server/data/users.ts取得使用者資料;修改token為帶username的格式fake-token-${username};把使用者資料放入回應物件回傳給前端。
server/api/auth/login.post.ts
import { users } from "~/server/data/users";
export default defineEventHandler(async (event) => {
const body = await readBody(event);
const { username, password } = body;
// 依帳號密碼尋找使用者
const user = users.find(
(u) => u.username === username && u.password === password,
);
if (!user) {
return {
success: false,
};
}
// 模擬token
const token = `fake-token-${username}`;
setCookie(event, "token", token, {
httpOnly: true,
maxAge: 60 * 60 * 24, // 1 天
});
return {
success: true,
token,
user: {
username: user.username,
name: user.name,
role: user.role,
},
};
});
修改LoginResponse介面
修改types.auth.ts中的LoginResponse,新增PublicUser欄位來接收[登入API]回傳的使用者資料。
types/auth.ts
import type { PublicUser } from "~/shared/types/user";
export interface LoginResponse {
success: boolean;
token?: string;
user?: PublicUser; // <--
}
新增使用者狀態
新增composables/useUser.ts,定義組合函式useUser建立[使用者狀態]user來存放使用者資料供前後端執行時存取。
composables/useUser.ts
import type { PublicUser } from "~/shared/types/user";
export const useUser = () => useState<PublicUser | null>("user", () => null);
修改登入頁面
修改pages/login.vue即登入頁面中接收[登入API]回應資料的部分,要接收PublicUser資料並放入狀態userState,也就是說登入後前端SPA路由都可從[使用者狀態]中取得使用者資料。
pages/login.vue
<template>
<h1>登入</h1>
<!--..._-->
</template>
<script setup lang="ts">
definePageMeta({
layout: 'auth'
})
import type { LoginResponse } from '~/types/auth'
const isLogin = useAuth()
const userState = useUser()
const username = ref('')
const password = ref('')
const login = async () => {
try {
const { success, token, user } = await $fetch<LoginResponse>('/api/auth/login', {
method: 'POST',
body: { username: username.value, password: password.value }
})
if (success && token && user) {
isLogin.value = true
userState.value = user // <--
navigateTo('/home')
} else {
alert('帳號或密碼錯誤')
}
} catch (err) {
alert('登入失敗,請稍後再試')
}
}
</script>
建立SidebarItem介面
建立types/sidebar.ts,定義SidebarItem為側邊選單元素的資料格式。
types/sidebar.ts
export interface SidebarItem {
label: string;
path: string;
roles: string[];
}
新增使用者資料API
新增server/api/users.get.ts,即建立模擬API [GET] /api/users,從前端傳入的請求頭Bearer token中的username來獲取對應的使用者資料。
server/api/users.get.ts
import { users } from "~/server/data/users";
import type { PublicUser } from "~/shared/types/user";
export default defineEventHandler((event): PublicUser => {
const authHeader = getHeader(event, "authorization");
if (!authHeader) {
throw createError({
statusCode: 401,
statusMessage: "Missing Authorization header",
});
}
// Bearer fake-token-username
const [type, token] = authHeader.split(" ");
if (type !== "Bearer" || !token) {
throw createError({
statusCode: 401,
statusMessage: "Invalid Authorization format",
});
}
const username = token.replace("fake-token-", "");
const user = users.find((u) => u.username === username);
if (!user) {
throw createError({
statusCode: 401,
statusMessage: "User not found",
});
}
return {
username: user.username,
name: user.name,
role: user.role,
};
});
新增驗證使用者函式
新增server/utils/auth.ts,建立verifyUser函式,帶入token來呼叫[使用者資料API]來取得使用者資料。Middleware驗證SSR路由時需要利用此函式來同步[使用者狀態]。
server/utils/auth.ts
import type { PublicUser } from "~/shared/types/user";
export async function verifyUser(token: string): Promise<PublicUser | null> {
try {
// 呼叫後端使用者資料API,帶入token取得使用者資料
const user = await $fetch<PublicUser>("/api/users", {
headers: {
Authorization: `Bearer ${token}`,
},
});
return user;
} catch (err) {
return null;
}
}
修改路由驗證Middleware
修改middleware/auth.global.ts,在SSR路由驗證部分要呼叫verifyUser函式並帶入從Cookie取得的token(在一開始登入成功後token就由後端放入Cookie)來驗證並取得使用者資料,然後放入userState中來同步前端與SSR的[使用者狀態]。
加入驗證非[admin]角色不可前往/home/member頁面的邏輯。
middleware/auth.global.ts
import { verifyUser } from "~/server/utils/auth";
export default defineNuxtRouteMiddleware(async (to) => {
if (to.path === "/login") return; // 登入頁面不用驗證
const isLogin = useAuth();
const userState = useUser();
// 檢查SSR路由登入驗證
if (import.meta.server) {
const token = useCookie("token").value;
if (!token) {
return navigateTo("/login");
}
// 同步前端登入狀態
isLogin.value = true;
userState.value = await verifyUser(token); // <--
}
// 檢查SPA路由登入驗證
if (import.meta.client) {
if (!isLogin.value) {
return navigateTo("/login");
}
}
if (to.path === "/home/member" && userState.value?.role !== "admin") {
return navigateTo("/home");
}
});
修改側邊選單組件
修改側邊選單組件components/Sidebar.vue,使用SidebarItem陣列定義選單項目可使用的角色;選單項目改用v-for從SidebarItem[]陣列取得,並從[使用者狀態]取得使用者的角色來過濾目前使用者可使用的選單項目。注意光靠前端隱藏功能並不安全,功能權限最終仍需要在後端API進行把控。
components/Sidebar.vue
<template>
<aside class="sidebar">
<nav>
<ul>
<li v-for="item in visibleItems">
<NuxtLink :key="item.path" :to="item.path" active-class="active">
{{ item.label }}
</NuxtLink>
</li>
</ul>
</nav>
</aside>
</template>
<script setup lang="ts">
import type { SidebarItem } from '~/types/sidebar'
// Sidebar 功能清單
const sidebarItems: SidebarItem[] = [
{ label: '會員設定', path: '/home/member', roles: ['admin'] },
{ label: '出口業務', path: '/home/export', roles: ['admin', 'user'] },
]
const userState = useUser()
// 依角色過濾可見項目
const visibleItems = computed(() => {
if (!userState.value) return []
return sidebarItems.filter(item =>
userState.value &&
item.roles.includes(userState.value.role))
})
</script>
修改導覽列組件
在導覽列組件components/Header.vue加入使用者名稱和角色的顯示,和調整CSS。
components/Header.vue
<template>
<header class="header">
<nav class="nav-left">
<ul>
<li>
<NuxtLink to="/">Home</NuxtLink>
</li>
<li>
<NuxtLink to="/about">About</NuxtLink>
</li>
</ul>
</nav>
<!-- 右上角使用者資訊 -->
<div class="nav-right" v-if="user">
{{ user.name }} ({{ user.role }})
</div>
</header>
</template>
<script setup lang="ts">
const user = useUser()
</script>
<style scoped>
.header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #ccc;
padding: 8px 16px;
}
.nav-left ul {
display: flex;
gap: 8px;
list-style: none;
margin: 0;
padding: 0;
}
.nav-right {
font-weight: bold;
}
</style>
測試
使用一般使用者帳號登入後,側邊選單看不到[會員設定]項目,右上角也顯示使用者名稱與角色。直接在瀏覽器輸入http://localhost:3000/home/member會被導向至http://localhost:3000/home。
目前尚未實作登出功能,可開啟瀏覽器開發者模式刪除此網站的token Cookie即可登出。
接著參考「Vue Nuxt 3 使用Page Meta管理頁面角色存取」把改用Page Meta設定頁面的存取規則。
沒有留言:
張貼留言