folder_open

30 天打造 MERN Stack Boilerplate

arrow_right
article

Day 17 - Infrastructure - Permission Control

Day 17 - Infrastructure - Permission Control

API Server 通常需要做到 2 種權限控制,第一是檢查使用者是否已經登入(Authentication),另一種是對已登入的使用者檢查是否具有足夠權限(Authorization),以 Express 的特性來看,在 Middleware 上面實作權限控制是最理想的,所以接下來將舉例兩個 Boilerplate 中實際用到的 Middlewares。

authRequired

#

這個 Middleware 大家應該已經在前面的章節看過一兩次了,我們使用它來檢查 Request 中是否夾帶 JWT,如果有,則透過 Passport 的 JWT Strategy 取出目前 Request 的使用者:

const authRequired = (req, res, next) => {
passport.authenticate(
'jwt',
{ session: false },
handleError(res)((user, info) => {
handlePassportError(res)((user) => {
if (!user) {
res.pushError(Errors.USER_UNAUTHORIZED);
return res.errors();
}
req.user = user;
next();
})(info, user);
})
)(req, res, next);
};

完整程式碼:src/server/middlewares/authRequired.jsopen_in_new

未經認證卻想要存取資源的使用者將會收到 ;經過認證的使用者的 Mongoose Model Instance 會被掛到 req 物件上,方便後續 Middleware 存取使用者資訊。

roleRequired

#

這個 Middleware 相依於 ,使用 之前一定要先使用 ,因為我們要依賴 掛到 req 上的 物件。

比對目前使用者的角色 是否包含在指定的角色 中:

const roleRequired = (requiredRoles) => (req, res, next) => {
if ((
requiredRoles instanceof Array &&
requiredRoles.indexOf(req.user.role) >= 0
) || (
req.user.role === requiredRoles
)) {
next();
} else {
return res.errors([Errors.PERMISSION_DENIED]);
}
};

權限不足者會收到 ;權限正確的使用者不作任何處理,直接呼叫 進入後續 Middlewares。

使用範例

#

有了以上權限控制的 Middlewares,就可以針對不同 API 實作不同的權限控管,例如只有已登入的 Admin 使用者才能存取 這項資源:

import authRequired from '../middlewares/authRequired';
import roleRequired from '../middlewares/roleRequired';
// ...
app.get('/api/users',
authRequired,
roleRequired([Roles.ADMIN]),
userController.list
);

完整程式碼:src/server/routes/api.jsopen_in_new

Nonce

#

在我們 Boilerplate 中還使用了一個有趣的東西 ,這是網路安全領域中的術語,用來防止 Replay Attack。

Web Service 在某些情況下必須散播一次性的 Token,例如信箱驗證或是重設密碼,通常是讓使用者打開一串夾帶著 Token 的超連結,一旦驗證完成或是重設完成,該連結就應該要失效,也就是說該連結夾帶的 Token 是一次性的,不能被重複使用,所以我們在 Boilerplate 中必須要實作防護機制,這個機制正是利用 Nonce 來完成的。

以驗證信箱的功能為例,我們在資料庫中設有 欄位,並且提供 這個 Instance Method 產生驗證信箱的 Token,Token 中會包入 Nonce:

let UserSchema = new mongoose.Schema({
// ...
nonce: {
verifyEmail: Number,
},
});
UserSchema.methods.toVerifyEmailToken = function(cb) {
const user = {
_id: this._id,
nonce: this.nonce.verifyEmail,
};
const token = jwt.sign(user, configs.jwt.verifyEmail.secret, {
expiresIn: configs.jwt.verifyEmail.expiresIn,
});
return token;
};

完整程式碼:src/server/models/User.jsopen_in_new
註:Instance Method 不能使用 Arrow Function,因為我們要在內部使用到

產生 Nonce

#

當使用者註冊時,將 nonce.verifyEmail 設定為一個隨機數:

const user = User({
name: req.body.name,
// ...
nonce: {
verifyEmail: Math.random(),
},
});

完整程式碼:src/server/controllers/user.jsopen_in_new

再依據此 Nonce 產生 Token 寄送驗證連結至註冊信箱:

let token = user.toVerifyEmailToken();
nodemailerAPI()
.sendMail({
to: user.email.value,
subject: 'Email Verification',
html: renderToString(
<VerifyEmailMail token={token} />
),
})
.catch(err => { /* ... */ })
.then(info => { /* ... */ });

完整程式碼:src/server/controllers/mail.jsopen_in_new

驗證 Nonce

#

當使用者打開驗證連結後,要如何驗證 Token 是否被重複使用呢?我們只要將 Nonce 從 Token 中取出,並且和資料庫中儲存的 Nonce 比對就可以知道 Token 是第一次使用,還是被重複使用了:

const verifyUserNonce = (req, res, next) => {
let { _id, nonce } = req.decodedPayload;
User.findById(_id, handleDbError(res)((user) => {
if (nonce !== user.nonce.verifyEmail) {
return res.errors([Errors.TOKEN_REUSED]);
}
user.nonce.verifyEmail = -1;
next();
}));
};

完整程式碼:src/server/middlewares/validate.jsopen_in_new

驗證成功後,我們直接把 Nonce 清除為 -1(任何 Math.random() 無法產生的值皆可),如此一來,下一次如果收到夾帶同樣 Token 的 Request 時,Nonce 將會比對失敗,也就達到了防止 Replay Attack 的效果。