30 天打造 MERN Stack Boilerplate
Day 17 - Infrastructure - Permission Control
API Server 通常需要做到 2 種權限控制,第一是檢查使用者是否已經登入(Authentication),另一種是對已登入的使用者檢查是否具有足夠權限(Authorization),以 Express 的特性來看,在 Middleware 上面實作權限控制是最理想的,所以接下來將舉例兩個 Boilerplate 中實際用到的 Middlewares。
這個 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);};
未經認證卻想要存取資源的使用者將會收到 ;經過認證的使用者的 Mongoose Model Instance 會被掛到 req 物件上,方便後續 Middleware 存取使用者資訊。
這個 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);
在我們 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.verifyEmail 設定為一個隨機數:
const user = User({ name: req.body.name, // ... nonce: { verifyEmail: Math.random(), },});
再依據此 Nonce 產生 Token 寄送驗證連結至註冊信箱:
let token = user.toVerifyEmailToken();
nodemailerAPI() .sendMail({ to: user.email.value, subject: 'Email Verification', html: renderToString( <VerifyEmailMail token={token} /> ), }) .catch(err => { /* ... */ }) .then(info => { /* ... */ });
當使用者打開驗證連結後,要如何驗證 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(); }));};
驗證成功後,我們直接把 Nonce 清除為 -1(任何 Math.random() 無法產生的值皆可),如此一來,下一次如果收到夾帶同樣 Token 的 Request 時,Nonce 將會比對失敗,也就達到了防止 Replay Attack 的效果。