此範例,將介紹如何在 Vue.js 中實現基於角色的授權/訪問控制(role based authorization/access control)的示例。
將從伺服器端開始,單頁應用程式 SPA 的伺服器端在客戶端下載 index.html 檔案後,似乎就沒甚麼事情了,如果不是很複雜的系統,我們可以用它兼作 API 伺服器。這個範例我們就將它兼作為伺服器端認證的 API,我們將在此伺服器上實作身份驗證服務 API。正式應用上可以使用 GraphQL 將認證與一般的 API 整合,只需將此範例認證 API 改為指向 GraphQL 即可,除此,客戶端不需甚麼變動。
原始碼
這個範例可以當成 Web Application 的起始架構,程式碼可以使用 git 或複製壓縮檔取得:
- 如果你有安裝 git,可以用 git clone 取得程式碼,我的 Z: 虛擬硬碟是指向 XXX111。
1 2 3 4 5 6 7
| git clone Z:\DBA\Git\Depot\vue-demo-sample-auth.git
cd vue-demo-sample-auth
npm install
npm start
|
- 如果沒有安裝 git,可從 XXX111:\DBA\Oracle Training\Xyang\Source\vue-demo-sample-auth.zip 取得壓縮檔,解壓縮後:
如果要用在正試環境時,記得將 package.json 的屬性 start 改為用 Node.js 啟動:
package.json1 2 3
| "scripts": { "start": "node ./bin/www" },
|
如果你想跟著稍後的實作細節自己建立程式碼,可以複製Fullstack Vue 實作範例 (1)作為起始架構,跟著實作。
實作細節
以下則是詳細的過程及說明。
伺服器端
HTML5 歷史模式
以 Vue.js 建立的 SPA App 通常只使用一個瀏覽器可以訪問的索引檔案,例如 index.html,而網址導航(網頁切換),通常透過客戶端的 Vue Router 來達成。預設的情況下, Vue Router 使用 URL 雜湊來儲存路徑,要在你的網站中存取 /about,你要輸入 http://<你的網站>.com/#/about。不過,幾乎所有現行的瀏覽器都支援 HTML5 history API 。有了它,開發者無須重載新網頁就可以更新 URL,網址也會更好看。
public/router.js1 2 3 4 5 6 7
| const router = new VueRouter({ mode: history, routes: [ { path: '/', component: PageHome}, { path: '/about', component: PageAbout } ] });
|
在第 2 行我們可以把 Vue Router 設為 HTML5 history API。 現在,若你直接用瀏覽器連到 http://<你的網站>.com/about,你會看到 …404 網頁:
在 SPA 中使用 HTML 5 history API 除了讓客戶端的程式碼去抓完整的路徑之外,你還需要告訴伺服器在回應每一次它無法識別的要求時,要回傳那一個 HTML 網頁,在 express 上,我們需要安裝一個套件。
1
| npm install --save connect-history-api-fallback
|
app.js1 2 3 4 5
| ... const history = require("connect-history-api-fallback"); ... app.use(history()); ...
|
但是因為我們想讓此 express 兼作 API 伺服器,這樣的設定將會把 express 的伺服器端路由全部攔截,因此必須加入一些條件,讓它跳過一些路由:
app.js1 2 3 4 5 6 7 8
| app.use(history({ rewrites: [ { from: /^\/api\/.*$/, to: (context) => context.parsedUrl.pathname } ] }));
|
這個設定可以讓它通過以 /api/* 開頭的所有路由,我們就可設定如下的認證 API。
1
| app.use('/api/v1/users', usersRouter);
|
以下會是伺服器端完成後的專案目錄架構,客戶端的程式碼都放在 public 目錄下,public 目錄也是 express 伺服器的靜態檔案服務的根目錄:
伺服器端身份驗證 API
我們將建立 3 使用者服務 API,並對這些 API 實作權限控管:
- /api/v1/users 需要有管理者權限,才可以取得所有使用使用者的資料。
- /api/v1/users/:id 只有目前登入者有權限取得自己的資料。
- /api/v1/authenticate 登入認證用,不須有權限的控管。
為了將焦點放在架構上,我們將使用者資料直接放在一個 JavaScript 陣列上,正式上線的系統應該將它移到資料庫上。
services/users.data.js1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| module.exports = [ { id: 1, username: "admin", password: "$2b$10$JSxCrnRLV1Ypc6P.thx29O4M1hePOyBX4V8hGn0yXOl2FRq5996oi", displayName: "Administrator", roles: ["admin"] }, { id: 2, username: "user", password: "$2b$10$KRW4hJpiDt3BIusPY2bQNOrKh7QuxWT8m2YSt9Q9b.aixSPv.tCDG", displayName: "User One", roles: ["user"] }, ... ];
|
services/user.service.js1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104
| const bcrypt = require("bcryptjs"); const jwt = require("jsonwebtoken"); const jwtSecretKey = "gJ3Lck9rb5yZ6T8i66dcsuFeoYJ6x"; const users = require("./users.data");
exports.authenticate = (username, password) => { return new Promise((resolve, reject) => { if (typeof username !== "string" || typeof password !== "string") { reject(new Error("Username or password is incorrect")); }
const user = users.find( u => u.username === username && validPassword(password, u.password) );
if (!user) { reject(new Error("Username or password is incorrect")); }
const token = jwt.sign( { id: user.id, username: user.username, roles: user.roles }, jwtSecretKey, { expiresIn: "12h" } );
resolve({ id: user.id, displayName: user.displayName, roles: user.roles, token: token }); }); };
exports.jwtVerify = (token) => { if (!token) { throw new Error("Authentication failed"); } let payload;
try { payload = jwt.verify(token, jwtSecretKey); } catch (err) { if (err.name === "TokenExpiredError") { throw new Error("Token Expired"); } else { throw new Error("Authentication failed"); } } return payload; };
exports.allUsers = () => { return new Promise((resolve, reject) => { let rows = users.map(u => ({ id: u.id, username: u.username, displayName: u.displayName, roles: u.roles }));
resolve(rows); }); };
exports.findById = (id) => { return new Promise((resolve, reject) => { if (!id) { reject(new Error("User not found")); }
const user = users.find(x => x.id === id);
if (!user) { reject(new Error("User not found")); }
resolve({ id: user.id, username: user.username, displayName: user.displayName, roles: user.roles }); }); };
exports.findRole = (role, roles) => { if (!roles || roles.indexOf(role) == -1) { return false; } return true; };
exports.setPassword = password => { return bcrypt.hashSync(password, 10); };
const validPassword = (password, hash) => { return bcrypt.compareSync(password, hash); };
|
這裡使用兩個套件,bcryptjs 與 jsonwebtoken。bcryptjs 用來作密碼的加密與密碼的比對,jsonwebtoken 用來產生加密的 Token 與解密。
其中最重要的兩個函式,authenticate( ) 與 jwtVerify( )。authenticate( ) 用來作登入認證,在第 20 行,當認證成功,它會產生 token,token 會包含使用者的 id、username 與 roles。token 會加密,並下傳到客戶端,當客戶端要訪問伺服器端的 API 時,必須都要附帶此 token 作權限控管。這裡的 token 有效性設定為 12 小時,可視需要修改。
第 30 行是當認證成功時傳給客戶端的使用者物件,除了 token 還另外含有 id、displayName 與 roles,你應該有注意到,token 裡也含有 roles 的資訊,這要區分兩者有不同的用途,token 裡的 roles 是用來作伺服器端 API 的權限控管,另外下傳使用者物件中的 roles 則可提供 SPA 客戶端作基本的客戶端路由(或頁面)控管,你不能只控管客戶端的權限,伺服器端的 API 安全控管更加重要。
jwtVerify( ) 函式則是用來解開 token 加密的資料,客戶端在訪問伺服器端 API 時都必須附帶此 token,依 token 中的 id 與 roles 作權限的控管。
其它的服務 allUsers、findById 直接抓取陣列,雖然都是同步的,但我還是用 Promise 使用非同步的方式,方便以後改為用資料庫存儲使用者資料,也讓所有的服務都一致使用非同步存取,以免混淆。
Express 中間件 (Middleware)
接下來我們來看如何使用 express 的中間件 (Middleware)來保護資料。
如果沒有中間件,你必須在一個函式中處理一個請求 (Request) 的所有事情,使用中間件則可將一個複雜的處理函式模組化,不僅較好維護、較為彈性,可用性也較佳。
當 Express 接收到一個 Request 要求時,Request 物件將會經過一個中間件堆疊,然後透過 Response 物件回應給客戶端,堆疊中的每個中間件隨時有可能會結束這個流程直接回應給客戶端。所以我們可以在堆疊中插入我們的認證機制中間件,成功則繼續堆疊的運作,失敗就直接回應給客戶端,不繼續往下流程。這是一個強大的功能,也是 Express 運作的基石。
Authorize middleware
首先來處理權限控管中間件:
middlewares/authorize.js1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67
| const { jwtVerify, findRole } = require("../services/user.service");
exports.authorize = (role) => { return [ validateToken(), isRole(role) ]; }
exports.isCurrentUser = () => { return [ validateToken(), currentUser() ]; };
function validateToken() { return (req, res, next) => { let token; let payload;
if (!req.headers.authorization) { return res.status(401).json({ message: "You are not authorized" }); }
token = req.headers.authorization.split(" ")[1];
if (!token) { return res.status(401).json({ message: "You are not authorized" }); }
try { payload = jwtVerify(token); req.currentUser = payload; next(); } catch (err) { if (err.name === "TokenExpiredError") { return res.status(401).json({ message: err.message }); } else { return res.status(401).json({ message: err.message }); } } }; }
function isRole(role) { return (req, res, next) => { if (!req.currentUser) { return res.status(401).json({ message: "You are not authorized" }); }
if (!role || findRole(role, req.currentUser.roles)) { next(); } else { res.status(401).json({ message: "You are not authorized" }); } } }
function currentUser() { return (req, res, next) => { if (!req.currentUser) { return res.status(401).json({ message: "You are not authorized" }); } let id = !isNaN(req.params.id) ? parseInt(req.params.id) : undefined;
if (id === req.currentUser.id || findRole("admin", req.currentUser.roles)) { next(); } else { res.status(401).json({ message: "You are not authorized" }); } }; }
|
Express 中間件函式有一定的標準格式:
middleware standard1 2 3 4
| function myMiddleware(request, response, next) { ... next(); }
|
中間件可接受 3 個參數,當你的處理函式處理完畢後,呼叫 next( ) 來呼叫堆疊中的下一個中間件。
- 第 11 行 validateToken 中間件專門處理 token 的權限,包括每個 Request 有沒有附帶 token、token 是不是我們發出的合法 token 與 token 是否過期。
- 第 16 行判斷 Request 物件的 headers 有沒有 authorization 屬性,客戶端的每個 Request headers 都必須將 token 附在這個屬性上。
- 第 26 行如果是合法的 token,會將它解密,並將資料附加到 Request 物件上的屬性 currentUser 上,這是一個自建的新屬性,將會隨著 Request 物件傳給下個中間件,下遊的中間件都可從 Request 物件參考到這個自建的屬性。
- 第 29 行呼叫 next( ) 函式,才會往下執行中間件堆疊的下一個中間件。
- 第 31 行判斷 token 是否已過期。
isRole 與 currentUser 也都是標準的中間件函式格式,你需要甚麼權限控管就依此範例加上去。
最後則看我們 export 出去的中間件 authorize 與 isCurrentUser,它們返回的都是一個中間件陣列,它們是有順序的,可依需求組合你需要的權限控管,這太棒了。
Users service
可以開始建立實際的 Users services API 了,API 裡的函式實際上也是使用中間件的格式,但我們把這種中間件放在 controllers 目錄中,可以與其它有共用且有專们目的的中間件區分。
controllers/user.controller.js1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| const { authenticate, allUsers, findById } = require("../services/user.service");
exports.allUsers = (req, res, next) => { allUsers() .then( users => res.status(200).json( { items: users })) .catch(err => res.status(404).json({ message: err.message })); };
exports.getUser = (req, res, next) => { const id = !isNaN(req.params.id) ? parseInt(req.params.id) : undefined;
findById(id) .then(user => res.status(200).json(user)) .catch(err => res.status(404).json({ message: err.message })); };
exports.authenticate = (req, res, next) => { const username = req.body.username; const password = req.body.password;
authenticate(username, password) .then(user => res.status(200).json(user)) .catch(err => res.status(401).json({ message: err.message })); };
|
allUsers 與 getUser 將會使用在 HTTP GET 方法,authenticate 用於登入認證,會使用 HTTP POST 方法。
第 10 行用 req.params.id 可以取得路由參數,例如, /api/v1/users/:id。
第 18 行可以從 HTTP POST 的 Request 物件 body 屬性取得客戶端送來的資料。
Users 的路由設定現在看起來就很簡潔了。
routes/users.js1 2 3 4 5 6 7 8 9 10 11 12
| const express = require('express'); const router = express.Router(); const { allUsers, getUser, authenticate } = require('../controllers/user.controller'); const { authorize, isCurrentUser } = require('../middlewares/authorize');
router.get('/', authorize('admin'), allUsers);
router.get("/:id", isCurrentUser(), getUser);
router.post("/authenticate", authenticate);
module.exports = router;
|
這裡的路由就是一連串的中間件堆疊組合了,前面兩個路由 / 與 /:id 分別有用 authorize( ) 與 isCurrentUser( ) 保護,登入用的 /authenticate 則不需要權限控管。注意這裡的中間件是有順序性的。
現在當你拜訪 /api/v1/users API 時,必須要有管理者身份。拜訪 /api/v1/users/:id 則只能是登入者本身。
以下則是伺服器端最終 app.js 的程式碼,這裡我多加了一個 cors 套件,雖然在此範例不需要,但如果是一般的 REST API 伺服器都會需要。
重新啟動伺服器,你的 SPA 伺服器就 OK 了,Go!
app.js1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| const createError = require('http-errors'); const express = require('express'); const path = require('path'); const cookieParser = require('cookie-parser'); const logger = require('morgan'); const cors = require('cors'); const history = require("connect-history-api-fallback");
const indexRouter = require('./routes/index'); const usersRouter = require('./routes/users');
const app = express();
app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs');
app.use(logger('dev')); app.use(express.json()); app.use(history({ rewrites: [ { from: /^\/api\/.*$/, to: (context) => context.parsedUrl.pathname } ] })); app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); app.use(express.static(path.join(__dirname, 'public'))); app.use(cors());
app.use('/', indexRouter); app.use('/api/v1/users', usersRouter);
app.use(function(req, res, next) { next(createError(404)); });
app.use(function(err, req, res, next) { res.locals.message = err.message; res.locals.error = req.app.get('env') === 'development' ? err : {};
res.status(err.status || 500); res.render('error'); });
module.exports = app;
|
客戶端 Vue SPA
客戶端的資源都放在伺服器的 public 目錄下,如果你用的是 Vue Cli 打包後的目錄 dist,可將整個 dist 目錄複製到伺服器端,然後取代掉 public 目錄下的所有資源。你可以有另外一種選擇,就是將 express 的靜態檔案服務的根目錄改為 dist 目錄,取代原先的 public 目錄。
以下會是客戶端完成後的目錄架構:
大部分的程式碼都在 public/javascripts 目錄下,components 與 views 目錄放的是 Vue 組件,services 目錄則是與後端 REST API 的連結,helpers 目錄擺放一些不好歸屬但共用的一些模組,Vue 的一些起始程式碼,index.html、main.js、router.js 與 App.vue.js 根組件直接就放在 public 目錄下。如果你使用 Vue Cli 則模組的寫法與 import 的方式會不一樣,請自行修改。
客戶端的 REST API Services
首先從 helpers.js 程式碼開始:
public/javascripts/helpers/helpers.js1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75
| const _helpers = (() => { const config = { apiUrl: `http://${location.host}/api/v1` };
function handleResponse(response) { return response.text().then(text => { const data = text && JSON.parse(text); if (!response.ok) { if ([401, 403].indexOf(response.status) !== -1) { authenticationService.logout(); } const error = (data && data.message) || response.statusText; return Promise.reject(error); } return data; }); }
const requestOptions = { get() { return { method: "GET", ...headers() }; }, post(body) { return { method: "POST", ...headers(), body: JSON.stringify(body) }; }, patch(body) { return { method: "PATCH", ...headers(), body: JSON.stringify(body) }; }, put(body) { return { method: "PUT", ...headers(), body: JSON.stringify(body) }; }, delete() { return { method: "DELETE", ...headers() }; } };
function headers() { const currentUser = authenticationService.currentUserValue || {}; const authHeader = currentUser.token ? { Authorization: "Bearer " + currentUser.token } : {}; return { headers: { ...authHeader, "Content-Type": "application/json" } }; }
return { config, requestOptions, handleResponse, }; })();
|
_helpers 模組擺放的是一些使用 REST API 服務時會用到的共用程式碼,把它包裝起來讓連結 REST API 時方便一點。
handleResponse( ) 函式處理從 API 返回的資料,第 11 行有個 authenticationService 物件,這稍後我們會討論到。authenticationService 負責登入、登出及登入者狀態的維護,是這個範例的核心。這裡當 API 認證失敗時(Response 返回狀態是 401 或 403)會呼叫 authenticationService.logout( ),強迫使用者登出,這意味著,任何從伺服器端 API 只要返回 401 或 403 狀態,都會強迫客戶端登出,這包括不合法的 API 攫取。稍後我們會看到 logout( ) 函式作了些甚麼。
requestOptions 物件則會依據 HTTP 方法設定 HTTP Request 所需的一些可選的引數 (Arguments),每個方法都會再呼叫 headers() 函式。
headers( ) 函式每次都會從 authenticationService.currentUserValue 取得目前的登入者(如果尚未登入則會是 null 值),取得他們的 token,將這個 token 附在每個 HTTP Request 的 HTTP Headers 上,伺服器端才能依這個 token 給予授權的資料。這就是基本的 SPA 與 API 的認證、授權機制。
Authentication Service
authenticationService 是這個範例的重點,大部分的 Vue SPA 都會使用 Vuex 來實作認證機制與認證使用者的狀態管理,但在這個範例我要借用 Angular 的認證及狀態管理機制。使用的則是 RxJS 程式庫。
RxJS 是一個使用可觀察序列 (Observable sequences)組成異步和基於事件的程式庫。它是所有 SPA 架構的核心,不熟悉沒關係,這裡只會用到它其中的一項功能 BehaviorSubject,它很適合用來管理使用者登入狀態。
BehaviorSubject 是一種觀察者模式 (Observer Pattern),基本的觀察者模式定義是:
定義物件(稱為主體 subject)於狀態改變時,能夠通知多個觀察者(或稱監聽者)
但 BehaviorSubject 除了在狀態改變時可以發送與監聽信息之外,它還能夠存儲當下的狀態,而不是單純的只是事件發送,也就是說如果有一個新的訂閱,我們希望主體 (Subject) 能立即給出最新狀態,而不是要等到下次的狀態改變。也因為它有存儲目前的狀態,所以也可直接取得它當下的狀態,而無須透過訂閱。
public/javascripts/services/authentication.service.js1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| const authenticationService = (() => { const { BehaviorSubject } = rxjs; const { config, requestOptions, handleResponse } = _helpers;
const currentUserSubject = new BehaviorSubject( JSON.parse(localStorage.getItem("currentUser")) );
function login(username, password) { return fetch(`${config.apiUrl}/users/authenticate`, requestOptions.post({ username, password }) ).then(handleResponse) .then(user => { if (user.roles && Array.isArray(user.roles)) { user.isAdmin = user.roles.indexOf("admin") !== -1 ? true : false; } localStorage.setItem("currentUser", JSON.stringify(user)); currentUserSubject.next(user);
return user; }); }
function logout() { localStorage.removeItem("currentUser"); currentUserSubject.next(null); }
return { login, logout, currentUser: currentUserSubject.asObservable(), get currentUserValue() { return currentUserSubject.value; } }; })();
|
因為 BehaviorSubject 會存儲當下的狀態,所已使用 BehaviorSubject 建構式創建一個 currentUserSubject 實例時,必須要給它一個初始值,這裡會從 localStorage 讀取目前登入者的資料,如果沒有登入者則會是 null 值。
login( ) 函式會到伺服器端 API 執行認證,如果認證失敗,先前的 handleResponse( ) 函式會攔截,並呼叫這裡的 logout( ) 函式 ( authenticationService.logout( ) ); 如果成功,則會將資料存儲到 localStorage,並呼叫 currentUserSubject.next(user) 發送( emit )使用者資料,這個使用者資料也就是 currentUserSubject 實例目前的狀態,可以使用 currentUserSubject.value 取得目前的狀態。另外則用 currentUserSubject.asObservable( ) 可以訂閱信息。
logout( ) 函式則很簡單,清除 localStorage currentUser 的資料,並發射一個 null 值。
最後將這些功能包裝成一個比較簡潔的物件,注意 currentUserValue 屬性是惟讀的。這就是這個 authenticationService 的 API。 authenticationService.currentUser 可以訂閱信息,authenticationService.currentUserValue 可以取得當下的狀態。
在 Vue 網頁還沒完成前,我們可以在瀏覽器 console 測試。但要記得將相關 JavaScript 檔案加入 index.html。
你也可同時觀察 localStorage 的變化。你也可以訂閱它:
當你開始訂閱時,它馬上會返回當前的狀態; 當我們呼叫 logout( ) 函式,它也馬上有了回應。稍後我們就可看到如何應用在 Vue 中。
User Service
使用者的 Service API 則很單純,這不用多解釋:
public/javascripts/services/user.service.js1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const userService = (() => { const { config, requestOptions, handleResponse } = _helpers;
function getAll() { return fetch(`${config.apiUrl}/users`, requestOptions.get()).then( handleResponse ); }
function getById(id) { return fetch(`${config.apiUrl}/users/${id}`, requestOptions.get()).then( handleResponse ); }
return { getAll, getById } })();
|
Vue Router Navigation Guards 客戶端路由保護
現在先來看看如何在 Vue Router 中加入客戶端路由保護。
Vue Router 提供了幾個路由防護函式可以讓你在路由器上加上防護(gurad),有些是全域性的、有些是可以加在單獨路由上、有些則可以加在在組件上的。這裡我們用一個全域性的防護 router.beforeEach( ) 函式。
public/router.js1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| const router = new VueRouter({ mode: "history", routes: [ { path: "/", name: "home", component: HomePage }, { path: "/account", name: "account", component: AccountPage, meta: { requiresAuth: true } }, { path: "/user", name: "user", component: UserPage, meta: { requiresAuth: true } }, { path: "/login", name: "login", component: LoginPage }, { path: "/about", name: "about", component: AboutPage }, { path: "*", component: PageNotFound } ] });
router.beforeEach((to, from, next) => { const currentUser = authenticationService.currentUserValue; const requiresAuth = to.matched.some((record) => { return record.meta.requiresAuth; });
if (requiresAuth && !currentUser) { next({ path: "/login", query: { returnUrl: to.path } }); } else { next(); } });
|
第 13、21 行我們在要受保護的路由上加入一個 meta 物件,你可以在 meta 物件上加入任何的資料。這裡我們只設定一個 requiresAuth 屬性,值是 true 時表示這個路由需要使用者登入。沒有設定 meta 物件或 false 則無須登入就可以拜訪此路由。
第 42 行 router.beforeEach( ) 是全域性的路由防護函式,authenticationService.currentUserValue 可以取得目前的登入者。這裡不需要使用 authenticationService.currentUser 訂閱的方式,訂閱未來的狀態變化,只需要取得目前的狀態。
第 44 行,檢查每個路由 meta 物件的 requiresAuth 屬性,看起來較複雜,主要考慮到如果使用巢狀路由,to.mata 所代表的是子組件,若在 /account 上加進 meta 物件,則使用者存取 /account/email 時,受檢查的 meta 物件所代表的是子物件,而不是父物件。要解決這個問題,可以在 to.matched 上進行迭代,其中也會包含父路由。
第 49 行,如果路由需要防護,但尚未登入,則將路由導向 /login 頁面,returnUrl 則可以讓認證成功後,重新導航回原頁面。
App 組件
現在來看 App 組件的設定。
public/App.vue.js1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120
| const App = { name: "App", components: {}, template: ` <v-app> <v-app-bar :clipped-left="$vuetify.breakpoint.lgAndUp" app > <v-toolbar-title class="headline ml-0 pl-4" > <v-app-bar-nav-icon @click.stop="drawer = !drawer"></v-app-bar-nav-icon> <span class="hidden-sm-and-down">Uni-President</span> </v-toolbar-title> <div class="flex-grow-1"></div> <v-toolbar-items> <v-btn color="indigo" tile text to="/"> <v-icon>mdi-home</v-icon> <span class="mr-2 green--text text--accent-4 hidden-sm-and-down">Home</span> </v-btn> <v-btn color="amber" tile text to="/about"> <v-icon>mdi-information</v-icon> <span class="mr-2 hidden-sm-and-down">About</span> </v-btn> <v-menu bottom left> <template v-slot:activator="{ on }"> <v-btn icon color="teal" v-on="on"> <v-icon>mdi-dots-vertical</v-icon> </v-btn> </template> <v-list dense> <v-list-item v-for="item in items" :key="item.title" link :to="item.to"> <v-list-item-icon> <v-icon>{{ item.icon }}</v-icon> </v-list-item-icon> <v-list-item-content> <v-list-item-title>{{ item.title }}</v-list-item-title> </v-list-item-content> </v-list-item> </v-list> </v-menu> </v-toolbar-items> </v-app-bar> <v-content> <v-container> <router-view /> </v-container> </v-content> <v-footer app> <small class="grey--text">© 2019</small> </v-footer> <v-navigation-drawer v-model="drawer" :clipped="$vuetify.breakpoint.lgAndUp" app> <v-list-item> <v-list-item-avatar> <v-img src="images/pec_logo.png"></v-img> </v-list-item-avatar> <v-list-item-content> <v-list-item-title class="red--text text--darken-2"> Uni-President </v-list-item-title> </v-list-item-content> </v-list-item> <v-divider></v-divider> <v-list dense> <v-list-item v-for="item in selectedItems" :key="item.title" link :to="item.to"> <v-list-item-icon> <v-icon>{{ item.icon }}</v-icon> </v-list-item-icon> <v-list-item-content> <v-list-item-title>{{ item.title }}</v-list-item-title> </v-list-item-content> </v-list-item> <v-divider class="my-3"></v-divider> <v-list-item v-if="!currentUser" link to="/login"> <v-list-item-icon> <v-icon>mdi-login</v-icon> </v-list-item-icon> <v-list-item-content> <v-list-item-title>Login</v-list-item-title> </v-list-item-content> </v-list-item> <v-list-item v-else @click="logout"> <v-list-item-icon> <v-icon>mdi-logout</v-icon> </v-list-item-icon> <v-list-item-content> <v-list-item-title>Logout <span class="blue--text text--darken-2">({{ currentUser && currentUser.displayName }})</span></v-list-item-title> </v-list-item-content> </v-list-item> </v-list> </v-navigation-drawer> </v-app>`,
data: () => ({ currentUser: null, drawer: null, items: [ { title: "Home", icon: "mdi-view-dashboard", to: "/" }, { title: "About", icon: "mdi-forum", to: "/about" }, { title: "Account", icon: "mdi-account-card-details", to: "/account" }, { title: "Not Exists", icon: "mdi-forum", to: "/not/exists" } ], adminItems: [ { title: "User", icon: "mdi-account-card-details", to: "/user" } ] }), computed: { selectedItems() { if (!this.currentUser || !this.currentUser.isAdmin) { return this.items; } return [...this.items, ...this.adminItems]; } }, methods: { logout() { authenticationService.logout(); router.push("/login"); } }, created() { authenticationService.currentUser.subscribe(x => (this.currentUser = x)); } };
|
第 117 行當組件創建時,使用身份驗證服務中的 currentUser observable 訂閱信息,這可以馬上取得目前的狀態,也可以訂閱未來的狀態變化。這裡不能使用身份驗證服務中的 currentUserValue,因為 App 組件是我們的根組件,它只會創建一次,直到流覽器關掉這個 App。如果只在創建時取得現值,將無法偵測到身份驗證服務的狀態變化。基本上,當我們不使用訂閱時,應該取消它,但這裡不用擔心取消訂閱的問題,因為這個組件是應用程序的根組件,該組件被銷毀的唯一時間是 App 關閉時,這也會銷毀任何的訂閱。
logout( ) 方法,則會呼叫身份驗證服務中的 logout( ),並重新導向登錄頁面。
第 104 行的 selectedItems ,會判斷如果登入的使用者有管理者身份,會合併導航抽屜一般使用者與管理者的顯示項目。所以只有管理者身份者才會有 /user 的路由連結。
第 70、78 行有 v-if 與 v-else 可以切換不同的 Login 與 Logout 功能。
LoginPage 組件
public/javascripts/views/LoginPage.vue.js1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87
| const LoginPage = { name: "LoginPage", template: ` <v-container class="fill-height" fluid> <v-row align="center" justify="center"> <v-col cols="12" sx="12" sm="8" md="4"> <v-card class="elevation-12"> <v-toolbar flat > <v-img src="images/pec_logo.png" max-width="45"></v-img> <v-toolbar-title class="red--text text--darken-2 ml-5"> Uni-President </v-toolbar-title> </v-toolbar> <v-card-text> <v-form v-model="valid"> <v-text-field v-model="username" :rules="[rules.required, rules.min]" label="Username" name="username" prepend-icon="mdi-account" hint="Username is required" ></v-text-field> <v-text-field v-model="password" prepend-icon="mdi-lock" :append-icon="show ? 'mdi-eye' : 'mdi-eye-off'" :rules="[rules.required, rules.min]" :type="show ? 'text' : 'password'" name="password" label="Password" hint="Password is required" @click:append="show = !show" ></v-text-field> </v-form> </v-card-text> <v-card-actions> <div class="flex-grow-1"></div> <v-btn :disabled="!valid || loading" color="blue" block large @click="handleSubmit" >Login</v-btn> </v-card-actions> </v-card> </v-col> <v-col cols="12" sx="12" sm="12" md="12"> <v-alert v-if="error" type="error"> {{ error }} </v-alert> </v-col> </v-row> </v-container> `, props: { source: String }, data: () => ({ valid: true, show: false, username: "", password: "", rules: { required: value => !!value || "Required.", min: v => v.length >= 4 || "Min 4 characters" }, loading: false, returnUrl: "", error: "" }), created() { if (authenticationService.currentUserValue) { return router.push("/"); }
this.returnUrl = this.$route.query.returnUrl || "/"; }, methods: { handleSubmit(e) { this.loading = true; authenticationService.login(this.username, this.password).then( user => router.push(this.returnUrl), error => { this.error = error; this.loading = false; } ); } } };
|
HTML 部分主要的就是 Vuetify v-form 組件建立的,需要注意的就是它的驗正規則也可以是陣列堆疊式的。
第 69 行當組件創建時直接抓取身份驗證服務的 currentUserValue 當下的狀態。如果已經登入了,就直接將頁面導向 Home Page。
第 73 行如果是由別的受防護的路由轉過來的,則可以在驗證成功後依據 this.$route.query.returnUrl,重新導回那個路由。
第 76 行 handleSubmit( ) 呼叫身份驗證服務的 login( ) ,認證成功則將頁面導向原頁面或 Home Page。
UserPage 組件
這個組件只有登入的使用者有管理者身份才會顯示在 App 組件的導航抽屜(navigation-drawer),但是我們在路由的防護設定只有針對有沒有登入作防護,並沒有針對何種身分作防護,所以如果你已經登入,不管是甚麼身份,仍然可以直接從流覽器上輸入網址 http://localhost:3000/user 重載拜訪這個組件,所以我們也應該在此組件上再多一層防護。
另外一種選擇則可以在路由器上加上身份的防護,例如,在 meta 物件加上防護資訊:
public/router.js1 2 3 4 5 6 7 8
| { path: "/user", name: "user", component: UserPage, meta: { requiresRole: 'admin' } }
|
你可以在身份驗證服務的 currentUserValue 取到相關角色 (roles) 的資訊,然後修改 router.beforeEach( ) 路由防護規則。
這個組件有一個子組件 ListUsers.vue.js 組件,只有有管理者身份者才能看到所有使用者的資料。
public/javascripts/components/ListUsers.vue.js1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| Vue.component("ListUsers", { template: ` <div> <div if="currentUser && currentUser.isAdmin"> <v-card class="mx-auto" max-width="344" tile > <v-list dense> <v-subheader class="title teal--text text--darken-1">Users</v-subheader> <em v-if="users.loading">Loading users...</em> <span v-if="users.error" class="red--text">ERROR: {{ users.error }}</span> <v-list-item-group color="primary"> <v-list-item v-for="item in users.items" :key="item.displayName" > <v-list-item-icon> <v-icon color="blue" v-text="'mdi-account'"></v-icon> </v-list-item-icon> <v-list-item-content> <v-list-item-title v-text="item.displayName"></v-list-item-title> </v-list-item-content> </v-list-item> </v-list-item-group> </v-list> </v-card> </div> </div>`,
data: () => ({ currentUser: authenticationService.currentUserValue, users: [], }), created() { if (this.currentUser && this.currentUser.isAdmin) { userService.getAll().then(users => this.users = users); } } });
|
除了第 4 行對 HTML 保護外,也在第 30 行對資料作了保護。這裡要提醒一下,看看我在 if 中的測試規則 if ( this.currentUser && this.currentUser.isAdmin ) {…} 要對一個物件的屬性作測試,先要測試這個物件是否存在,否則你的臭蟲會抓不完。
以下則是 UserPage 父組件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const UserPage = { name: "UserPage", template: ` <div> <v-row> <v-col> <v-card max-width="344" class="mx-auto"> <v-card-title>I'm {{ currentUser && currentUser.displayName }}</v-card-title> <v-card-text>Some text</v-card-text> </v-card> </v-col> <v-col v-if="currentUser && currentUser.isAdmin"> <ListUsers /> </v-col> </v-row> </div>`, data: () => ({ currentUser: authenticationService.currentUserValue }) };
|
這個組件中有兩個 v-col 元素,一個有身份防護,一個沒有。但是你應該會發覺,在客戶端的這些防護其實都是很脆弱的,客戶端很容易就可被竄改,所以伺服器端的資料防護才是重要。在我們的範例,即使你竄改了客戶端的身份,你仍然無法取得伺服器端 API 的資料,伺服器端的資料保護就依賴在我們的 Token,所以要好好保護你的 Token,至少不要發出一個沒有期限的 Token。資料安全是一個大主題,目前的範例只是一個基本機制。
AccountPage 組件與 UserPage 組件一模一樣。只是在展示不同的身份會有不同的內容。
最後的 index.html 則如下:
public/index.html1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| <!DOCTYPE html> <html> <head> <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet"> <link href="stylesheets/@mdi/font/css/materialdesignicons.min.css" rel="stylesheet" /> <link href="stylesheets/vuetify.min.css" rel="stylesheet" /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui"> <style> [v-cloak] { display: none; } </style> </head> <body> <div id="app" v-cloak></div>
<script src="libs/vue.js"></script> <script src="libs/vuetify.js"></script> <script src="libs/vue-router.js"></script> <script src="libs/rxjs.umd.min.js"></script> <script src="javascripts/views/HomePage.vue.js"></script> <script src="javascripts/views/LoginPage.vue.js"></script> <script src="javascripts/views/AboutPage.vue.js"></script> <script src="javascripts/components/ListUsers.vue.js"></script> <script src="javascripts/views/AccountPage.vue.js"></script> <script src="javascripts/views/UserPage.vue.js"></script> <script src="javascripts/views/PageNotFound.vue.js"></script> <script src="javascripts/helpers/helpers.js"></script> <script src="javascripts/services/authentication.service.js"></script> <script src="javascripts/services/user.service.js"></script> <script src="App.vue.js"></script> <script src="router.js"></script> <script src="main.js"></script> </body> </html>
|
祝 身體健康、快樂!