基于 Passport.js 的權限認證
# 基于 Passport.js 的權限認證
參考了 [Passport.js 學習筆記](http://ju.outofmemory.cn/entry/99459)與 [Wiki.js](https://github.com/Requarks/wiki) 的源代碼
認證又稱 “ 驗證 ”、“ 鑒權 ”,是指通過一定的手段,完成對用戶身份的確認。身份驗證的方法有很多,基本上可分為:基于共享密鑰的身份驗證、基于生物學特征的身份驗證和基于公開密鑰加密算法的身份驗證。
登陸認證,是用戶在訪問應用或者網站時,通過是先注冊的用戶名和密碼,告訴應用使用者的身份,從而獲得訪問權限的一種操作。
幾乎所有的應用都需要登陸認證! Passport.js 是 Node.js 中的一個做登錄驗證的中間件,極其靈活和模塊化,并且可與 Express、Sails 等 Web 框架無縫集成。Passport 功能單一,即只能做登錄驗證,但非常強大,支持本地賬號驗證和第三方賬號登錄驗證(OAuth 和 OpenID 等),支持大多數 Web 網站和服務。
策略(Strategy )是 passport 中最重要的概念。passport 模塊本身不能做認證,所有的認證方法都以策略模式封裝為插件,需要某種認證時將其添加到 package.json 即可。策略模式是一種設計模式,它將算法和對象分離開來,通過加載不同的算法來實現不同的行為,適用于相關類的成員相同但行為不同的場景,比如在 passport 中,認證所需的字段都是用戶名、郵箱、密碼等,但認證方法是不同的。依據策略模式,passport 支持了眾多的驗證方案,包括 Basic、Digest 、 OAuth(1.0 ,和 2.0 的三種實現)、 JWT 等。
# 策略配置
## 本地認證
```js
const LocalStrategy = require("passport-local").Strategy;
passport.use(
"local",
new LocalStrategy(
{
usernameField: "email",
passwordField: "password"
},
(uEmail, uPassword, done) => {
db.User.findOne({ email: uEmail, provider: "local" })
.then(user => {
if (user) {
// validatePassword 是 User 模型自帶的數據校驗輔助函數
return user
.validatePassword(uPassword)
.then(() => {
return done(null, user) || true;
})
.catch(err => {
return done(err, null);
});
} else {
return done(new Error("INVALID_LOGIN"), null);
}
})
.catch(err => {
done(err, null);
});
}
)
);
```
如果使用 MySQL、PostgreSQL 等關系型數據庫,我們也可以進行
```js
// 綁定對于用戶密碼進行加密的操作
userSchema.statics.hashPassword = rawPwd => {
return bcrypt.hash(rawPwd);
};
// 綁定對于密碼的驗證操作
userSchema.methods.validatePassword = function(rawPwd) {
return bcrypt.compare(rawPwd, this.password).then(isValid => {
return isValid
? true
: Promise.reject(new Error(lang.t("auth:errors:invalidlogin")));
});
};
```
注意,這里的字段名稱應該是頁面表單提交的名稱,即 `req.body.xxx`,而不是 user 數據庫中的字段名稱。
將 options 作為 LocalStrategy 第一個參數傳入即可。
passport 本身不處理驗證,驗證方法在策略配置的回調函數里由用戶自行設置,它又稱為驗證回調。驗證回調需要返回驗證結果,這是由 done() 來完成的。
在 passport.use() 里面,done() 有三種用法:
當發生系統級異常時,返回 done(err),這里是數據庫查詢出錯,一般用 next(err),但這里用 done(err),兩者的效果相同,都是返回 error 信息;當驗證不通過時,返回 done(null, false, message),這里的 message 是可選的,可通過 express-flash 調用;當驗證通過時,返回 done(null, user)。
## 混合策略
```js
const passport = require('passport')
, LocalStrategy = require('passport-local').Strategy
, AnonymousStrategy = require('passport-anonymous').Strategy;
...
// 匿名登錄認證作為本地認證的 fallback
passport.use(new AnonymousStrategy());
...
app.get('/',
passport.authenticate(['local', 'anonymous'], { session: false }),
function(req, res){
if (req.user) {
res.json({ msg: "用戶已登錄"});
} else {
res.json({ msg: "用戶以匿名方式登錄"});
}
});
```
# 框架集成
## 登錄認證
```js
const express = require('express');
const cookieParser = require('cookie-parser');
const session = require('express-session');
const flash = require('express-flash');
const passport = require('passport');
...
// 在使用 app.use 之前需要進行 passport 的配置
app.use(cookieParser());
app.use(session({...}));
app.use(flash())
app.use(passport.initialize());
app.use(passport.session());
...
const ExpressBrute = require('express-brute')
const ExpressBruteMongooseStore = require('express-brute-mongoose')
```
```js
app.post(
"/login",
passport.authenticate("local", {
successRedirect: "/",
failureRedirect: "/login",
failureFlash: true
}),
function(req, res) {
// 驗證成功則調用此回調函數
res.redirect("/users/" + req.user.username);
}
);
```
```js
// controllers/auth.js
...
// 使用 ExpressBruteMongooseStore 來存放爆破信息,也可以使用 MemoryStore 將信息存放于內存
const EBstore = new ExpressBruteMongooseStore(db.Bruteforce)
const bruteforce = new ExpressBrute(EBstore, {
freeRetries: 5,
minWait: 60 * 1000,
maxWait: 5 * 60 * 1000,
refreshTimeoutOnRequest: false,
failCallback (req, res, next, nextValidRequestDate) {
req.flash('alert', {
class: 'error',
title: lang.t('auth:errors.toomanyattempts'),
message: lang.t('auth:errors.toomanyattemptsmsg', { time: moment(nextValidRequestDate).fromNow() }),
iconClass: 'fa-times'
})
res.redirect('/login')
}
})
// 處理來自表單提交中包含的登錄信息
router.post('/login', bruteforce.prevent, function (req, res, next) {
new Promise((resolve, reject) => {
// [1] LOCAL AUTHENTICATION
passport.authenticate('local', function (err, user, info) {
if (err) { return reject(err) }
if (!user) { return reject(new Error('INVALID_LOGIN')) }
resolve(user)
})(req, res, next)
}).then((user) => {
// LOGIN SUCCESS
// 執行用戶登錄操作,將用戶 ID 寫入到 Session 中
return req.logIn(user, function (err) {
if (err) { return next(err) }
req.brute.reset(function () {
return res.redirect('/')
})
}) || true
}).catch(err => {
// LOGIN FAIL
if (err.message === 'INVALID_LOGIN') {
req.flash('alert', {
title: lang.t('auth:errors.invalidlogin'),
message: lang.t('auth:errors.invalidloginmsg')
})
return res.redirect('/login')
} else {
req.flash('alert', {
title: lang.t('auth:errors.loginerror'),
message: err.message
})
return res.redirect('/login')
}
})
})
...
```
```js
const router = express.Router();
```
[koa-passport](https://github.com/rkusa/koa-passport)
```js
// body parser
const bodyParser = require("koa-bodyparser");
app.use(bodyParser());
// Sessions
const session = require("koa-session");
app.keys = ["secret"];
app.use(session({}, app));
const passport = require("koa-passport");
app.use(passport.initialize());
app.use(passport.session());
```
## 訪問校驗
注意上面的代碼里有個 req.logIn(),它不是 http 模塊原生的方法,也不是 express 中的方法,而是 passport 加上的,passport 擴展了 HTTP request,添加了四種方法。
logIn(user, options, callback) :用 login() 也可以。作用是為登錄用戶初始化 session。options 可設置 session 為 false,即不初始化 session,默認為 true。 logOut() :別名為 logout()。作用是登出用戶,刪除該用戶 session。不帶參數。 isAuthenticated() :不帶參數。作用是測試該用戶是否存在于 session 中(即是否已登錄)。若存在返回 true。事實上這個比登錄驗證要用的更多,畢竟 session 通常會保留一段時間,在此期間判斷用戶是否已登錄用這個方法就行了。 isUnauthenticated() :不帶參數。和上面的作用相反。
驗證用戶提交的憑證是否正確,是與 session 中儲存的對象進行對比,所以涉及到從 session 中存取數據,需要做 session 對象序列化與反序列化。調用代碼如下:
```js
// 獲取用戶編號,用于在 logIn 方法執行時向 Session 中寫入用戶編號,ID 或者 Token 皆可
passport.serializeUser(function(user, done) {
done(null, user._id);
});
// 根據 ID 查找用戶,也是為了判斷用戶是否存在
passport.deserializeUser(function(id, done) {
db.User.findById(id)
.then(user => {
if (user) {
done(null, user);
} else {
done(new Error(lang.t("auth:errors:usernotfound")), null);
}
return true;
})
.catch(err => {
done(err, null);
});
});
```
這里第一段代碼是將環境中的 user.id 序列化到 session 中,即 sessionID,同時它將作為憑證存儲在用戶 cookie 中。
第二段代碼是從 session 反序列化,參數為用戶提交的 sessionID,若存在則從數據庫中查詢 user 并存儲與 req.user 中。
```js
//這里getUser方法需要自定義
app.get("/user", isAuthenticated, getUser);
// 將req.isAuthenticated()封裝成中間件
module.exports = (req, res, next) => {
// 判斷用戶是否經過認證
if (!req.isAuthenticated()) {
if (req.app.locals.appconfig.public !== true) {
return res.redirect("/login");
} else {
req.user = rights.guest;
res.locals.isGuest = true;
}
} else {
res.locals.isGuest = false;
}
// 進行角色的權限校驗
res.locals.rights = rights.check(req);
if (!res.locals.rights.read) {
return res.render("error-forbidden");
}
// Expose user data
res.locals.user = req.user;
return next();
};
```
```js
app.get("/logout", function(req, res) {
req.logout();
res.redirect("/");
});
```
# OAuth
```
* OAuth 驗證策略概述
*
* 當用戶點擊 “ 使用 XX 登錄 ” 鏈接
* * 若用戶已登錄
* * 檢查該用戶是否已綁定 XX 服務
* ? ? - 如果已綁定,返回錯誤(不允許賬戶合并)
* ? ? - 否則開始驗證流程,為該用戶綁定XX服務
* * 用戶未登錄
* * 檢查是否老用戶
* ? ? - 如果是老用戶,則登錄
* ? ? - 否則檢查OAuth返回profile中的email,是否在用戶數據庫中存在
* ? ? ? - 如果存在,返回錯誤信息
* ? ? ? - 否則創建一個新賬號
```
```js
const OAuth2Strategy = require('passport-oauth').OAuth2Strategy;
passport.use('provider', new OAuth2Strategy({
authorizationURL: 'https://www.provider.com/oauth2/authorize',
tokenURL: 'https://www.provider.com/oauth2/token',
clientID: '123-456-789',
clientSecret: 'shhh-its-a-secret'
callbackURL: 'https://www.example.com/auth/provider/callback'
},
function(accessToken, refreshToken, profile, done) {
User.findOrCreate(..., function(err, user) {
done(err, user);
});
}
));
```
refreshToken 是重新獲取 access token 的方法,因為 access token 是有使用期限的,到期了必須讓用戶重新授權才行,現在有了 refresh token,你可以讓應用定期的用它去更新 access token,這樣第三方服務就可以一直綁定了。不過這個方法并不是每個服務商都提供,注意看服務商的文檔。
```js
const GitHubStrategy = require("passport-github2").Strategy;
passport.use(
"github",
new GitHubStrategy(
{
clientID: appconfig.auth.github.clientId,
clientSecret: appconfig.auth.github.clientSecret,
callbackURL: appconfig.host + "/login/github/callback",
scope: ["user:email"]
},
(accessToken, refreshToken, profile, cb) => {
db.User.processProfile(profile)
.then(user => {
return cb(null, user) || true;
})
.catch(err => {
return cb(err, null) || true;
});
}
)
);
```
```js
router.get(
"/login/ms",
passport.authenticate("windowslive", {
scope: ["wl.signin", "wl.basic", "wl.emails"]
})
);
router.get(
"/login/google",
passport.authenticate("google", { scope: ["profile", "email"] })
);
router.get(
"/login/facebook",
passport.authenticate("facebook", { scope: ["public_profile", "email"] })
);
router.get(
"/login/github",
passport.authenticate("github", { scope: ["user:email"] })
);
router.get(
"/login/slack",
passport.authenticate("slack", {
scope: ["identity.basic", "identity.email"]
})
);
router.get("/login/azure", passport.authenticate("azure_ad_oauth2"));
router.get(
"/login/ms/callback",
passport.authenticate("windowslive", {
failureRedirect: "/login",
successRedirect: "/"
})
);
router.get(
"/login/google/callback",
passport.authenticate("google", {
failureRedirect: "/login",
successRedirect: "/"
})
);
router.get(
"/login/facebook/callback",
passport.authenticate("facebook", {
failureRedirect: "/login",
successRedirect: "/"
})
);
router.get(
"/login/github/callback",
passport.authenticate("github", {
failureRedirect: "/login",
successRedirect: "/"
})
);
router.get(
"/login/slack/callback",
passport.authenticate("slack", {
failureRedirect: "/login",
successRedirect: "/"
})
);
router.get(
"/login/azure/callback",
passport.authenticate("azure_ad_oauth2", {
failureRedirect: "/login",
successRedirect: "/"
})
);
```
[Passport-GitHub strategy.js](https://github.com/jaredhanson/passport-github/blob/master/lib/strategy.js)
passport 以插件的形式支持了很多第三方網站和服務的 OAuth 驗證,但并不是所有的,如果你需要在 app 中用到第三方的服務,但它們沒有對應的 passport 插件,你可以用通用的 OAuth 或其他驗證方法來進行驗證,也可以將它們封裝成 passport-x 插件。
JavaScript
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。