koa-passport是koa的一個(gè)中間件题翻,它實(shí)際上只是對(duì)passport的一個(gè)封裝揩徊。利用koa-passport可以簡(jiǎn)便的實(shí)現(xiàn)登錄注冊(cè)功能,不但包括本地驗(yàn)證嵌赠,還有很多提供第三方登錄的模塊可以使用塑荒。
基本流程
passport
的主要功能就是能夠提供一個(gè)用戶鑒權(quán)的框架,并把鑒權(quán)得到的用戶身份供后續(xù)的業(yè)務(wù)邏輯來使用猾普。而鑒權(quán)的具體過程是通過插件來實(shí)現(xiàn)袜炕,用戶可以自己來實(shí)現(xiàn),也可以使用已有的第三方模塊實(shí)現(xiàn)各種方式的鑒權(quán)(包括OAuth或者OpenID)初家。
passport
的代碼還是有點(diǎn)復(fù)雜的偎窘,我們先看一個(gè)最簡(jiǎn)單的例子,然后逐步介紹后面的細(xì)節(jié)溜在。
最小樣例
下面是一個(gè)使用koa-passport
的可以執(zhí)行的最小樣例
const Koa = require('koa')
const app = new Koa()
// 定義一個(gè)驗(yàn)證用戶的策略陌知,需要定義name作為標(biāo)識(shí)
const naiveStrategy = {
name: 'naive',
// 策略的主體就是authenticate(req)函數(shù),在成功的時(shí)候返回用戶身份掖肋,失敗的時(shí)候返回錯(cuò)誤
authenticate: function (req) {
let uid = req.query.uid
if (uid) {
// 策略很簡(jiǎn)單仆葡,就是從參數(shù)里獲取uid,然后組裝成一個(gè)user
let user = {id: parseInt(uid), name: 'user' + uid}
this.success(user)
} else {
// 如果找不到uid參數(shù)志笼,認(rèn)為鑒權(quán)失敗
this.fail(401)
}
}
}
// 調(diào)用use()來為passport新增一個(gè)可用的策略
const passport = require('koa-passport')
passport.use(naiveStrategy)
// 添加一個(gè)koa的中間件沿盅,使用naive策略來鑒權(quán)。這里暫不使用session
app.use(passport.authenticate('naive', {session: false}))
// 業(yè)務(wù)代碼
const Router = require('koa-router')
const router = new Router()
router.get('/', async (ctx) => {
if (ctx.isAuthenticated()) {
// ctx.state.user就是鑒權(quán)后得到的用戶身份
ctx.body = 'hello ' + JSON.stringify(ctx.state.user)
} else {
ctx.throw(401)
}
})
app.use(router.routes())
// server
const http = require('http')
http.createServer(app.callback()).listen(3000)
這段代碼雖然沒有實(shí)際意義纫溃,但是已經(jīng)展示完整的鑒權(quán)流程和錯(cuò)誤處理腰涧,運(yùn)行結(jié)果如下
$ curl http://localhost:3000/\?uid\=128
hello {"id":128,"name":"user128"}%
$ curl http://localhost:3000
Unauthorized%
可以看到這里鑒權(quán)的作用基本就是兩個(gè)
- 鑒權(quán)失敗可以拒絕用戶訪問(也可以不做處理)
- 鑒權(quán)成功會(huì)把用戶記錄到context
主流程
明白了上面的例子之后,我們就可以照著代碼來看一下passport
的主流程紊浩,定義在passport/authenticator.js文件中窖铡。
首先看看上面使用的passport.use()
函數(shù),其內(nèi)容非常簡(jiǎn)單坊谁,就是把一個(gè)策略保存在本地费彼,后續(xù)可以通過name來訪問
Authenticator.prototype.use = function(name, strategy) {
if (!strategy) {
strategy = name;
name = strategy.name;
}
if (!name) { throw new Error('Authentication strategies must have a name'); }
this._strategies[name] = strategy;
return this;
};
上面樣例中使用的中間件passport/authenticate()
,實(shí)際定義在passport/middleware/authenticate.js中口芍,其返回值是一個(gè)函數(shù)箍铲,具體的實(shí)現(xiàn)如下,刪減了部分代碼鬓椭,加了一下注釋
module.exports = function authenticate(passport, name, options, callback) {
// FK: 允許傳入一個(gè)策略的數(shù)組
if (!Array.isArray(name)) {
name = [ name ];
multi = false;
}
return function authenticate(req, res, next) {
// FK: 省略部分代碼...
function allFailed() {
// FK: 所有策略都失敗后虹钮,如果設(shè)置了回調(diào)就調(diào)用聋庵,否則找到第一個(gè)failure,根據(jù)option進(jìn)行flash/redirect/401
}
// FK: 這部分是主要邏輯
(function attempt(i) {
// FK: 嘗試第i個(gè)策略
var layer = name[i];
if (!layer) { return allFailed(); }
var prototype = passport._strategy(layer);
var strategy = Object.create(prototype);
strategy.success = function(user, info) {
// FK: 省略部分代碼芙粱,用于記錄flash/message
// FK: 鑒權(quán)成功后會(huì)調(diào)用logIn(),把用戶寫入當(dāng)前ctx
req.logIn(user, options, function(err) {
if (err) { return next(err); }
// FK: 成功跳轉(zhuǎn)氧映,代碼比較復(fù)雜春畔,省略了
};
strategy.fail = function(challenge, status) {
// FK: 記錄錯(cuò)誤,使用下一個(gè)策略進(jìn)行嘗試岛都,省略部分代碼
attempt(i + 1);
};
strategy.redirect = function(url, status) {
// FK: 處理跳轉(zhuǎn)
res.statusCode = status || 302;
res.setHeader('Location', url);
res.setHeader('Content-Length', '0');
res.end();
};
strategy.authenticate(req, options);
})(0); // attempt
};
這部分代碼就是passport
的核心流程律姨,其實(shí)就是定義好一些鑒權(quán)成功/失敗/跳轉(zhuǎn)等處理機(jī)制,然后就調(diào)用具體的策略進(jìn)行鑒權(quán)臼疫。需要注意的是荣赶,雖然外面上面的樣例中只傳入了一個(gè)策略鸽斟,但是其實(shí)passport
支持同時(shí)使用多個(gè)策略拔创,它會(huì)從頭開始嘗試各個(gè)策略,直到有一個(gè)策略做出處理或者已嘗試所有策略為止富蓄。
小結(jié)
通過上面的樣例和主流程的分析剩燥,大家應(yīng)該能清楚passport
大概做的事情是什么,也能夠知道最基礎(chǔ)的使用方式立倍。
session
在上面的例子中灭红,我們沒有使用session变擒,因此每個(gè)請(qǐng)求都需要帶上uid參數(shù)。實(shí)際使用中疆导,一般會(huì)把鑒權(quán)后的用戶身份會(huì)保存在cookie中供后續(xù)請(qǐng)求來使用赁项。雖然passport并沒有要求一定使用session澈段,但其實(shí)是默認(rèn)會(huì)使用session败富。
在上面的樣例中悔醋,為了支持session,我們需要添加一些代碼兽叮。
首先芬骄,我們需要在app中開啟session支持猾愿,即使用koa-session
const session = require('koa-session')
app.keys = ['some secret']
const conf = {
encode: json => JSON.stringify(json),
decode: str => JSON.parse(str)
}
app.use(session(conf, app))
然后,因?yàn)槲覀兊挠脩粜畔⑿枰A粼趕ession存儲(chǔ)中(利用cookie或者服務(wù)端存儲(chǔ))账阻,因此需要定義序列化和反序列的操作蒂秘。下面的例子是一個(gè)示例。真實(shí)場(chǎng)景中淘太,反序列化的時(shí)候肯定需要根據(jù)uid來檢索真正的用戶信息姻僧。
passport.serializeUser(function (user, done) {
// 序列化的結(jié)果只是一個(gè)id
done(NO_ERROR, user.id)
})
passport.deserializeUser(async function (str, done) {
// 根據(jù)id恢復(fù)用戶
done(NO_ERROR, {id: parseInt(str), name: 'user' + str})
})
再然后,因?yàn)槲覀儾恍枰猲aiveStrategy作為默認(rèn)策略了蒲牧,因此要把相應(yīng)的use()語句去掉撇贺,轉(zhuǎn)而只在用戶明確要登錄的時(shí)候才調(diào)用
router.get('/login',
passport.authenticate('naive', { successRedirect: '/' })
)
最后,我們需要在app中開啟koa-passport
對(duì)session的支持
app.use(passport.initialize())
app.use(passport.session())
initialzie()
函數(shù)的作用是只是簡(jiǎn)單為當(dāng)前context添加passport字段冰抢,便于后面的使用松嘶。而
passport.session()
則是passport
自帶的策略,用于從session中提取用戶信息挎扰,其代碼位于passport/strategies/session.js翠订,內(nèi)容如下
SessionStrategy.prototype.authenticate = function(req, options) {
// FK: 確保已經(jīng)初始化
if (!req._passport) { return this.error(new Error('passport.initialize() middleware not in use')); }
options = options || {};
// FK: 從session中獲取序列化后的user
var self = this, su;
if (req._passport.session) {
su = req._passport.session.user;
}
if (su || su === 0) {
// FK: 如果用戶字段存在,調(diào)用自定義的反序列化函數(shù)來獲取用戶信息
this._deserializeUser(su, req, function(err, user) {
if (err) { return self.error(err); }
if (!user) {
delete req._passport.session.user;
} else {
var property = req._passport.instance._userProperty || 'user';
req[property] = user;
}
self.pass();
// FK: 省略
});
} else {
// FK: 如果在session中找不到用戶字段鼓鲁,直接略過
self.pass();
}
};
和我們上面自定義的naive策略類似蕴轨,session策略的作用也即生成用戶信息,只不過數(shù)據(jù)來源不是請(qǐng)求字段骇吭,而是session信息橙弱。
Session Manager
在上面的主流程里,我們看到當(dāng)鑒權(quán)成功時(shí)燥狰,會(huì)調(diào)用req.logIn()函數(shù)棘脐,其實(shí)還有l(wèi)ogOut()和isAuthenticated(),都定義在passport/http/request.js。
其中l(wèi)ogIn()和logOut()操作真正調(diào)用的是
SessionManager中的操作龙致,其定義在passport/sessionmanager.js,主要流程如下:
function SessionManager(options, serializeUser) {
// FK: ... 省略
this._key = options.key || 'passport';
this._serializeUser = serializeUser;
}
SessionManager.prototype.logIn = function(req, user, cb) {
this._serializeUser(user, req, function(err, obj) {
if (err) {
return cb(err);
}
// FK: ... 省略
req._passport.session.user = obj;
// FK: ... 省略
req.session[self._key] = req._passport.session;
cb();
});
}
SessionManager.prototype.logOut = function(req, cb) {
if (req._passport && req._passport.session) {
delete req._passport.session.user;
}
cb && cb();
}
module.exports = SessionManager;
Session Manager里定義了login和logout兩個(gè)操作
- logIn()操作蛀缝,會(huì)調(diào)用一個(gè)_serializeUser(), 然后把序列化的結(jié)果存到req.session['passport']。此時(shí)session的內(nèi)容類似
{"passport":{"user":128},"_expire":1517357892908,"_maxAge":86400000}
- logOut()操作更加簡(jiǎn)單目代,就是直接刪除session里passport里的user字段屈梁。
此外,request還定義了isAuthenticated()函數(shù)榛了,用于檢查當(dāng)前是否已經(jīng)鑒權(quán)成功在讶,代碼如下
req.isAuthenticated = function() {
var property = 'user';
if (this._passport && this._passport.instance) {
property = this._passport.instance._userProperty || 'user';
}
return (this[property]) ? true : false;
};
小結(jié)
至此,我們基本已經(jīng)看完了passport
的主要工作霜大。passport
之所以強(qiáng)大构哺,在于他定義好了框架,但并沒有確定具體的鑒權(quán)策略战坤,用戶可以根據(jù)需求來加入各種自定義的策略曙强,現(xiàn)在已經(jīng)有大量的模塊可以使用了残拐。
passport-local
在上面的樣例中,我們定義了自己的NaiveStrategy來實(shí)現(xiàn)對(duì)用戶的鑒權(quán)碟嘴,當(dāng)然上面的代碼毫無安全性可言溪食。在真實(shí)環(huán)境中,最簡(jiǎn)單的鑒權(quán)一般是用戶提交用戶名和密碼娜扇,然后服務(wù)端來校驗(yàn)密碼眠菇,準(zhǔn)確無誤后才認(rèn)為鑒權(quán)成功。
雖然這個(gè)過程可以通過擴(kuò)展我們的NaiveStrategy來實(shí)現(xiàn)袱衷,不過我們已經(jīng)有了passport-local
這個(gè)庫提供了一個(gè)本地鑒權(quán)的代碼框架,可以直接使用笑窜。我們來看看其流程:
// FK: passport-local 省略部分代碼
function Strategy(options, verify) {
// FK: 記錄verify函數(shù)
this._verify = verify;
this._passReqToCallback = options.passReqToCallback;
}
Strategy.prototype.authenticate = function(req, options) {
// 從req里找到username 和 pass
options = options || {};
var username = lookup(req.body, this._usernameField) || lookup(req.query, this._usernameField);
var password = lookup(req.body, this._passwordField) || lookup(req.query, this._passwordField);
if (!username || !password) {
return this.fail({ message: options.badRequestMessage || 'Missing credentials' }, 400);
}
var self = this;
function verified(err, user, info) {
if (err) { return self.error(err); }
if (!user) { return self.fail(info); }
self.success(user, info);
}
// FK: 省略部分代碼
try {
this._verify(username, password, verified);
} catch (ex) {
return self.error(ex);
}
};
看代碼其實(shí)流程也非常簡(jiǎn)單致燥,就是自動(dòng)從請(qǐng)求中獲取username
和password
兩個(gè)字段,然后提交給用戶自定義的verify函數(shù)進(jìn)行鑒權(quán)排截,然后處理鑒權(quán)的結(jié)果嫌蚤。
可以看到,這個(gè)框架做的事情其實(shí)很有限断傲,主要的校驗(yàn)操作還是需要用戶自己來定義脱吱,一個(gè)簡(jiǎn)單用法樣例如下:
const LocalStrategy = require('passport-local').Strategy
passport.use(new LocalStrategy(async function (username, password, done) {
// FK: 根據(jù)username從數(shù)據(jù)庫或者其他存儲(chǔ)中拿到用戶信息
let user = await userStore.getUserByName(username)
// FK: 把傳入的password和數(shù)據(jù)庫中存儲(chǔ)的密碼進(jìn)行比較。當(dāng)然這里不應(yīng)該是明文认罩,一般是加鹽的hash值
if (user && validate(password, user.hash)) {
done(null, user)
} else {
log.info(`auth failed for`, username)
done(null, false)
}
}))
自此箱蝠,我們看到用戶只需要再定義一下用戶的存儲(chǔ)流程,基本上就可以實(shí)現(xiàn)一個(gè)簡(jiǎn)單的用戶注冊(cè)登錄功能了垦垂。
后記
本文記錄對(duì)koa-passport
相關(guān)模塊的代碼學(xué)習(xí)過程宦搬,里邊細(xì)節(jié)比較多,行文有些亂劫拗,還請(qǐng)見諒间校。如果大家只是想?看使用方法,可以參考其他的文章页慷,比如這篇憔足。
和之前看koa-session
模塊相比,passport
模塊沒有使用async語法酒繁,就能明顯感受到回調(diào)地獄的威力滓彰,代碼一開始看的還是比較痛苦∮樱總體感覺js還是過于靈活了找蜜,如果用來寫業(yè)務(wù),一定需要非常強(qiáng)健的工程規(guī)范才行稳析。
文中兩個(gè)樣例的完整代碼請(qǐng)參見 https://github.com/fankaigit/vauth/tree/master/server/sample