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 等。
策略配置
本地认证
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 等关系型数据库,我们也可以进行
// 绑定对于用户密码进行加密的操作
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)。
混合策略
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: "用户以匿名方式登录"});
}
});
框架集成
登录认证
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')
app.post(
"/login",
passport.authenticate("local", {
successRedirect: "/",
failureRedirect: "/login",
failureFlash: true,
}),
function (req, res) {
// 验证成功则调用此回调函数
res.redirect("/users/" + req.user.username);
}
);
// 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')
}
})
})
...
const router = express.Router();
// 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 对象序列化与反序列化。调用代码如下:
// 获取用户编号,用于在 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 中。
//这里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();
};
app.get("/logout", function (req, res) {
req.logout();
res.redirect("/");
});
OAuth
* OAuth 验证策略概述
*
* 当用户点击 “ 使用 XX 登录 ” 链接
* * 若用户已登录
* * 检查该用户是否已绑定 XX 服务
* - 如果已绑定,返回错误(不允许账户合并)
* - 否则开始验证流程,为该用户绑定XX服务
* * 用户未登录
* * 检查是否老用户
* - 如果是老用户,则登录
* - 否则检查OAuth返回profile中的email,是否在用户数据库中存在
* - 如果存在,返回错误信息
* - 否则创建一个新账号
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,这样第三方服务就可以一直绑定了。不过这个方法并不是每个服务商都提供,注意看服务商的文档。
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;
});
}
)
);
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 以插件的形式支持了很多第三方网站和服务的 OAuth 验证,但并不是所有的,如果你需要在 app 中用到第三方的服务,但它们没有对应的 passport 插件,你可以用通用的 OAuth 或其他验证方法来进行验证,也可以将它们封装成 passport-x 插件。