0%

Fullstack Vue 實作範例 - Authorization

此範例,將介紹如何在 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 取得壓縮檔,解壓縮後:
1
2
3
npm install

npm start

如果要用在正試環境時,記得將 package.json 的屬性 start 改為用 Node.js 啟動:

package.json
1
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.js
1
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.js
1
2
3
4
5
...
const history = require("connect-history-api-fallback");
...
app.use(history());
...

但是因為我們想讓此 express 兼作 API 伺服器,這樣的設定將會把 express 的伺服器端路由全部攔截,因此必須加入一些條件,讓它跳過一些路由:

app.js
1
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.js
1
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", // admin
displayName: "Administrator",
roles: ["admin"]
},
{
id: 2,
username: "user",
password: "$2b$10$KRW4hJpiDt3BIusPY2bQNOrKh7QuxWT8m2YSt9Q9b.aixSPv.tCDG", // user
displayName: "User One",
roles: ["user"]
},
...
];
services/user.service.js
1
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.js
1
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 standard
1
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.js
1
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.js
1
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.js
1
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();

// view engine setup
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);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
next(createError(404));
});

// error handler
app.use(function(err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};

// render the error page
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.js
1
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();
//location.reload(true);
}
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.js
1
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.js
1
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.js
1
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.js
1
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">&copy; 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.js
1
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.js
1
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.js
1
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.html
1
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>

祝 身體健康、快樂!