Passport

基于 Passport.js 的权限认证

参考了 Passport.js 学习笔记Wiki.js 的源代码
认证又称 “ 验证 ”、“ 鉴权 ”,是指通过一定的手段,完成对用户身份的确认。身份验证的方法有很多,基本上可分为:基于共享密钥的身份验证、基于生物学特征的身份验证和基于公开密钥加密算法的身份验证。
登陆认证,是用户在访问应用或者网站时,通过是先注册的用户名和密码,告诉应用使用者的身份,从而获得访问权限的一种操作。
几乎所有的应用都需要登陆认证! 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 等。

策略配置

本地认证

1
const LocalStrategy = require('passport-local').Strategy;
2
3
passport.use(
4
'local',
5
6
new LocalStrategy(
7
{
8
usernameField: 'email',
9
10
passwordField: 'password'
11
},
12
13
(uEmail, uPassword, done) => {
14
db.User.findOne({ email: uEmail, provider: 'local' })
15
16
.then(user => {
17
if (user) {
18
// validatePassword 是 User 模型自带的数据校验辅助函数
19
20
return user
21
22
.validatePassword(uPassword)
23
24
.then(() => {
25
return done(null, user) || true;
26
})
27
28
.catch(err => {
29
return done(err, null);
30
});
31
} else {
32
return done(new Error('INVALID_LOGIN'), null);
33
}
34
})
35
36
.catch(err => {
37
done(err, null);
38
});
39
}
40
)
41
);
Copied!
如果使用 MySQL、PostgreSQL 等关系型数据库,我们也可以进行
1
// 绑定对于用户密码进行加密的操作
2
3
userSchema.statics.hashPassword = rawPwd => {
4
return bcrypt.hash(rawPwd);
5
};
6
7
// 绑定对于密码的验证操作
8
9
userSchema.methods.validatePassword = function(rawPwd) {
10
return bcrypt.compare(rawPwd, this.password).then(isValid => {
11
return isValid
12
? true
13
: Promise.reject(new Error(lang.t('auth:errors:invalidlogin')));
14
});
15
};
Copied!
注意,这里的字段名称应该是页面表单提交的名称,即 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)。

混合策略

1
const passport = require('passport')
2
3
, LocalStrategy = require('passport-local').Strategy
4
5
, AnonymousStrategy = require('passport-anonymous').Strategy;
6
7
...
8
9
10
11
// 匿名登录认证作为本地认证的 fallback
12
13
passport.use(new AnonymousStrategy());
14
15
...
16
17
app.get('/',
18
19
passport.authenticate(['local', 'anonymous'], { session: false }),
20
21
function(req, res){
22
23
if (req.user) {
24
25
res.json({ msg: "用户已登录"});
26
27
} else {
28
29
res.json({ msg: "用户以匿名方式登录"});
30
31
}
32
33
});
Copied!

框架集成

登录认证

1
const express = require('express');
2
3
const cookieParser = require('cookie-parser');
4
5
const session = require('express-session');
6
7
const flash = require('express-flash');
8
9
const passport = require('passport');
10
11
12
13
...
14
15
// 在使用 app.use 之前需要进行 passport 的配置
16
17
18
19
app.use(cookieParser());
20
21
app.use(session({...}));
22
23
app.use(flash())
24
25
app.use(passport.initialize());
26
27
app.use(passport.session());
28
29
30
31
...
32
33
34
35
const ExpressBrute = require('express-brute')
36
37
const ExpressBruteMongooseStore = require('express-brute-mongoose')
Copied!
1
app.post(
2
'/login',
3
4
passport.authenticate('local', {
5
successRedirect: '/',
6
7
failureRedirect: '/login',
8
9
failureFlash: true
10
}),
11
12
function(req, res) {
13
// 验证成功则调用此回调函数
14
15
res.redirect('/users/' + req.user.username);
16
}
17
);
Copied!
1
// controllers/auth.js
2
3
4
5
...
6
7
8
9
// 使用 ExpressBruteMongooseStore 来存放爆破信息,也可以使用 MemoryStore 将信息存放于内存
10
11
const EBstore = new ExpressBruteMongooseStore(db.Bruteforce)
12
13
const bruteforce = new ExpressBrute(EBstore, {
14
15
freeRetries: 5,
16
17
minWait: 60 * 1000,
18
19
maxWait: 5 * 60 * 1000,
20
21
refreshTimeoutOnRequest: false,
22
23
failCallback (req, res, next, nextValidRequestDate) {
24
25
req.flash('alert', {
26
27
class: 'error',
28
29
title: lang.t('auth:errors.toomanyattempts'),
30
31
message: lang.t('auth:errors.toomanyattemptsmsg', { time: moment(nextValidRequestDate).fromNow() }),
32
33
iconClass: 'fa-times'
34
35
})
36
37
res.redirect('/login')
38
39
}
40
41
})
42
43
44
45
// 处理来自表单提交中包含的登录信息
46
47
router.post('/login', bruteforce.prevent, function (req, res, next) {
48
49
new Promise((resolve, reject) => {
50
51
// [1] LOCAL AUTHENTICATION
52
53
passport.authenticate('local', function (err, user, info) {
54
55
if (err) { return reject(err) }
56
57
if (!user) { return reject(new Error('INVALID_LOGIN')) }
58
59
resolve(user)
60
61
})(req, res, next)
62
63
}).then((user) => {
64
65
// LOGIN SUCCESS
66
67
// 执行用户登录操作,将用户 ID 写入到 Session 中
68
69
return req.logIn(user, function (err) {
70
71
if (err) { return next(err) }
72
73
req.brute.reset(function () {
74
75
return res.redirect('/')
76
77
})
78
79
}) || true
80
81
}).catch(err => {
82
83
// LOGIN FAIL
84
85
if (err.message === 'INVALID_LOGIN') {
86
87
req.flash('alert', {
88
89
title: lang.t('auth:errors.invalidlogin'),
90
91
message: lang.t('auth:errors.invalidloginmsg')
92
93
})
94
95
return res.redirect('/login')
96
97
} else {
98
99
req.flash('alert', {
100
101
title: lang.t('auth:errors.loginerror'),
102
103
message: err.message
104
105
})
106
107
return res.redirect('/login')
108
109
}
110
111
})
112
113
})
114
115
116
117
...
Copied!
1
const router = express.Router();
Copied!
1
// body parser
2
3
const bodyParser = require('koa-bodyparser');
4
5
app.use(bodyParser());
6
7
// Sessions
8
9
const session = require('koa-session');
10
11
app.keys = ['secret'];
12
13
app.use(session({}, app));
14
15
const passport = require('koa-passport');
16
17
app.use(passport.initialize());
18
19
app.use(passport.session());
Copied!

访问校验

注意上面的代码里有个 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 对象序列化与反序列化。调用代码如下:
1
// 获取用户编号,用于在 logIn 方法执行时向 Session 中写入用户编号,ID 或者 Token 皆可
2
3
passport.serializeUser(function(user, done) {
4
done(null, user._id);
5
});
6
7
// 根据 ID 查找用户,也是为了判断用户是否存在
8
9
passport.deserializeUser(function(id, done) {
10
db.User.findById(id)
11
12
.then(user => {
13
if (user) {
14
done(null, user);
15
} else {
16
done(new Error(lang.t('auth:errors:usernotfound')), null);
17
}
18
19
return true;
20
})
21
22
.catch(err => {
23
done(err, null);
24
});
25
});
Copied!
这里第一段代码是将环境中的 user.id 序列化到 session 中,即 sessionID,同时它将作为凭证存储在用户 cookie 中。
第二段代码是从 session 反序列化,参数为用户提交的 sessionID,若存在则从数据库中查询 user 并存储与 req.user 中。
1
//这里getUser方法需要自定义
2
3
app.get('/user', isAuthenticated, getUser);
4
5
// 将req.isAuthenticated()封装成中间件
6
7
module.exports = (req, res, next) => {
8
// 判断用户是否经过认证
9
10
if (!req.isAuthenticated()) {
11
if (req.app.locals.appconfig.public !== true) {
12
return res.redirect('/login');
13
} else {
14
req.user = rights.guest;
15
16
res.locals.isGuest = true;
17
}
18
} else {
19
res.locals.isGuest = false;
20
}
21
22
// 进行角色的权限校验
23
24
res.locals.rights = rights.check(req);
25
26
if (!res.locals.rights.read) {
27
return res.render('error-forbidden');
28
}
29
30
// Expose user data
31
32
res.locals.user = req.user;
33
34
return next();
35
};
Copied!
1
app.get('/logout', function(req, res) {
2
req.logout();
3
4
res.redirect('/');
5
});
Copied!

OAuth

1
* OAuth 验证策略概述
2
3
*
4
5
* 当用户点击 “ 使用 XX 登录 ” 链接
6
7
* * 若用户已登录
8
9
* * 检查该用户是否已绑定 XX 服务
10
11
* - 如果已绑定,返回错误(不允许账户合并)
12
13
* - 否则开始验证流程,为该用户绑定XX服务
14
15
* * 用户未登录
16
17
* * 检查是否老用户
18
19
* - 如果是老用户,则登录
20
21
* - 否则检查OAuth返回profile中的email,是否在用户数据库中存在
22
23
* - 如果存在,返回错误信息
24
25
* - 否则创建一个新账号
Copied!
1
const OAuth2Strategy = require('passport-oauth').OAuth2Strategy;
2
3
4
5
passport.use('provider', new OAuth2Strategy({
6
7
authorizationURL: 'https://www.provider.com/oauth2/authorize',
8
9
tokenURL: 'https://www.provider.com/oauth2/token',
10
11
clientID: '123-456-789',
12
13
clientSecret: 'shhh-its-a-secret'
14
15
callbackURL: 'https://www.example.com/auth/provider/callback'
16
17
},
18
19
function(accessToken, refreshToken, profile, done) {
20
21
User.findOrCreate(..., function(err, user) {
22
23
done(err, user);
24
25
});
26
27
}
28
29
));
Copied!
refreshToken 是重新获取 access token 的方法,因为 access token 是有使用期限的,到期了必须让用户重新授权才行,现在有了 refresh token,你可以让应用定期的用它去更新 access token,这样第三方服务就可以一直绑定了。不过这个方法并不是每个服务商都提供,注意看服务商的文档。
1
const GitHubStrategy = require('passport-github2').Strategy;
2
3
passport.use(
4
'github',
5
6
new GitHubStrategy(
7
{
8
clientID: appconfig.auth.github.clientId,
9
10
clientSecret: appconfig.auth.github.clientSecret,
11
12
callbackURL: appconfig.host + '/login/github/callback',
13
14
scope: ['user:email']
15
},
16
17
(accessToken, refreshToken, profile, cb) => {
18
db.User.processProfile(profile)
19
20
.then(user => {
21
return cb(null, user) || true;
22
})
23
24
.catch(err => {
25
return cb(err, null) || true;
26
});
27
}
28
)
29
);
Copied!
1
router.get(
2
'/login/ms',
3
4
passport.authenticate('windowslive', {
5
scope: ['wl.signin', 'wl.basic', 'wl.emails']
6
})
7
);
8
9
router.get(
10
'/login/google',
11
12
passport.authenticate('google', { scope: ['profile', 'email'] })
13
);
14
15
router.get(
16
'/login/facebook',
17
18
passport.authenticate('facebook', { scope: ['public_profile', 'email'] })
19
);
20
21
router.get(
22
'/login/github',
23
24
passport.authenticate('github', { scope: ['user:email'] })
25
);
26
27
router.get(
28
'/login/slack',
29
30
passport.authenticate('slack', {
31
scope: ['identity.basic', 'identity.email']
32
})
33
);
34
35
router.get('/login/azure', passport.authenticate('azure_ad_oauth2'));
36
37
router.get(
38
'/login/ms/callback',
39
40
passport.authenticate('windowslive', {
41
failureRedirect: '/login',
42
43
successRedirect: '/'
44
})
45
);
46
47
router.get(
48
'/login/google/callback',
49
50
passport.authenticate('google', {
51
failureRedirect: '/login',
52
53
successRedirect: '/'
54
})
55
);
56
57
router.get(
58
'/login/facebook/callback',
59
60
passport.authenticate('facebook', {
61
failureRedirect: '/login',
62
63
successRedirect: '/'
64
})
65
);
66
67
router.get(
68
'/login/github/callback',
69
70
passport.authenticate('github', {
71
failureRedirect: '/login',
72
73
successRedirect: '/'
74
})
75
);
76
77
router.get(
78
'/login/slack/callback',
79
80
passport.authenticate('slack', {
81
failureRedirect: '/login',
82
83
successRedirect: '/'
84
})
85
);
86
87
router.get(
88
'/login/azure/callback',
89
90
passport.authenticate('azure_ad_oauth2', {
91
failureRedirect: '/login',
92
93
successRedirect: '/'
94
})
95
);
Copied!
passport 以插件的形式支持了很多第三方网站和服务的 OAuth 验证,但并不是所有的,如果你需要在 app 中用到第三方的服务,但它们没有对应的 passport 插件,你可以用通用的 OAuth 或其他验证方法来进行验证,也可以将它们封装成 passport-x 插件。
Last modified 2yr ago