Nodejs Passport 系列之四:Passport 源码剖析之 OAuth2 认证流程

前言

本文是笔者所总结的有关 Nodejs Passport 系列之一;本文将从源码分析的角度,来深入剖析 passport 的认证流程;

本文为作者原创作品,转载请注明出处;

综述

OAuth2orize 包模块扩展使得 Express 成为具备 OAuth 2 标准的 Authroization Server;本博文笔者仅对笔者目前所关注的 Resource Owner Password Credentials 的认证流程进行剖析,其它的方式可以触类旁通;

Resource Owner Password Credentials 标准流程概要

使用 Resource Owner Password Credentials 的方式向服务器发起请求要注意如下几点,

  1. 使用 “application/x-www-form-urlencoded” 的 URL 编码格式向服务器发起请求;
  2. Client id 和 Client Credentials 需要通过 Basic 的方式进行验证;
  3. grant_type=password
  4. username 和 password 同 grant_type 一同放在 Http Body 中 post 给服务器进行验证;

看一个请求的例子,

1
2
3
4
5
POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded
grant_type=password&username=johndoe&password=A3ddj3w

Demo

项目骨架

参考 OAuth2orize 所推荐的例子来作为源码分析的 Demo;项目工程结构如下,
auth2orize-examples project structure.png

模块结构

项目的各个模块逻辑由下面这张图概括了,
auth2orize-examples modules mindmap.png
可以看到,主要有三大主模块,auth/、db/ 以及 routers/;auth/ 模块中定义了核心的 Strategies;routers/ 模块里面定义了与 oauth2 相关的 router 的 handlers;

oauth2 模块

  1. auth/index.js 模块

    1
    2
    3
    4
    5
    6
    const passport = require('passport');
    const LocalStrategy = require('passport-local').Strategy;
    const BasicStrategy = require('passport-http').BasicStrategy;
    const ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy;
    const BearerStrategy = require('passport-http-bearer').Strategy;
    ...

    可以看到,该模块中定义了各种验证的 Strategies 以及用户自定义的验证回调方法 $\alpha$,$\alpha$ 名字的由来参考上一博文;

  2. db/ 模块
    定义了 user、client、各种 code 的存储和查找的模拟方法;
  3. routes/ 模块
    里面定义了核心的 routers 的逻辑,先来看一个我们最为熟悉的一个,site.js 模块的实现,

    1
    2
    3
    4
    5
    6
    7
    ...
    module.exports.index = (request, response) => response.send('OAuth 2.0 Server');

    module.exports.loginForm = (request, response) => response.render('login');

    module.exports.login = passport.authenticate('local', { successReturnToOrRedirect: '/', failureRedirect: '/login' });
    ...

    上面这段代码就太熟悉不过了,得到 authenticate.js 模块的 authenticate 方法的句柄和闭包上下文,为之后 /login 请求的验证提供验证方法入口;不过,这现在不是笔者所关心的,笔者关心的是 OAuth 2.0 部分的内容;这部分参考 oauth2.js 模块,里面的实现内容主要分为三大块,

    grant

    1. grant authorization code,用来 exchange 得到 access code

      1
      2
      3
      4
      5
      6
      7
      server.grant(oauth2orize.grant.code((client, redirectUri, user, ares, done) => {
      const code = utils.getUid(16);
      db.authorizationCodes.save(code, client.id, redirectUri, user.id, (error) => {
      if (error) return done(error);
      return done(null, code);
      });
      }));
    2. grant access token

      1
      2
      3
      4
      5
      6
      7
      server.grant(oauth2orize.grant.token((client, user, ares, done) => {
      const token = utils.getUid(256);
      db.accessTokens.save(token, user.id, client.clientId, (error) => {
      if (error) return done(error);
      return done(null, token);
      });
      }));

    exchange

    1. 由 authorization code 置换 access token,标准流程,

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      server.exchange(oauth2orize.exchange.code((client, code, redirectUri, done) => {
      db.authorizationCodes.find(code, (error, authCode) => {
      if (error) return done(error);
      if (client.id !== authCode.clientId) return done(null, false);
      if (redirectUri !== authCode.redirectUri) return done(null, false);

      const token = utils.getUid(256);
      db.accessTokens.save(token, authCode.userId, authCode.clientId, (error) => {
      if (error) return done(error);
      return done(null, token);
      });
      });
      }));
    2. 也可以通过用户名密码以及 client 信息来置换,

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      server.exchange(oauth2orize.exchange.password((client, username, password, scope, done) => {
      // Validate the client
      db.clients.findByClientId(client.clientId, (error, localClient) => {
      if (error) return done(error);
      if (!localClient) return done(null, false);
      if (localClient.clientSecret !== client.clientSecret) return done(null, false);
      // Validate the user
      db.users.findByUsername(username, (error, user) => {
      if (error) return done(error);
      if (!user) return done(null, false);
      if (password !== user.password) return done(null, false);
      // Everything validated, return the token
      const token = utils.getUid(256);
      db.accessTokens.save(token, user.id, client.clientId, (error) => {
      if (error) return done(error);
      return done(null, token);
      });
      });
      });
      }));

    router handlers

    1. 注册 Authroization Code 鉴权流程的 handler,

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      module.exports.authorization = [
      login.ensureLoggedIn(),
      server.authorization((clientId, redirectUri, done) => {
      ...
      }, (client, user, done) => {
      ...
      }),
      (request, response) => {
      response.render('dialog', { transactionId: request.oauth2.transactionID, user: request.user, client: request.oauth2.client });
      },
      ];
    2. 注册使用 Resource Owner Password Credentials 鉴权流程的 handler,

      1
      2
      3
      4
      5
      exports.token = [
      passport.authenticate(['basic', 'oauth2-client-password'], { session: false }),
      server.token(),
      server.errorHandler(),
      ];

app oauth2 router

与 oauth2 验证流程相关的 app router 如下,

1
2
3
app.get('/dialog/authorize', routes.oauth2.authorization);
app.post('/dialog/authorize/decision', routes.oauth2.decision);
app.post('/oauth/token', routes.oauth2.token);
  1. /dialog/authorize 请求走的是 Authroization Code 鉴权流程
  2. /oauth/token 请求走的是 Resource Owner Password Credentials 鉴权流程

需要使用 access token 才能访问的资源,

1
2
app.get('/api/userinfo', routes.user.info);
app.get('/api/clientinfo', routes.client.info);

看其中一个 handler 的实现,routes.user.info

1
2
3
4
5
6
7
8
9
10
module.exports.info = [
passport.authenticate('bearer', { session: false }),
(request, response) => {
// request.authInfo is set using the `info` argument supplied by
// `BearerStrategy`. It is typically used to indicate scope of the token,
// and used in access control checks. For illustrative purposes, this
// example simply returns the scope in the response.
response.json({ user_id: request.user.id, name: request.user.name, scope: request.authInfo.scope });
}
];

可以看到,如果想要访问该资源,必须通过 ‘bearer’ 既 BearerStrategy 的验证;

Resource Owner Password Credentials 请求用例

前置条件

首先,Resource Owner Password Credentials 获取 access token 需要分别使用有效的 client credentials 和 user credentials,可使用如下的 credentials,

  • client credentials
    db/clients.js

    1
    2
    3
    4
    const clients = [
    { id: '1', name: 'Samplr', clientId: 'abc123', clientSecret: 'ssh-secret', isTrusted: false },
    { id: '2', name: 'Samplr2', clientId: 'xyz123', clientSecret: 'ssh-password', isTrusted: true },
    ];

    可以看到,有效的 client credentials 是 xyz123/ssh-password

  • user credentials
    db/users.js

    1
    2
    3
    4
    const users = [
    { id: '1', username: 'bob', password: 'secret', name: 'Bob Smith' },
    { id: '2', username: 'joe', password: 'password', name: 'Joe Davis' },
    ];

    可以看到有效的用户有两个,bob 和 joe 都是可以的;

得到 access token

然后,使用 Postman 构建一个 http://localhost:3000/oauth/token 的模拟请求,该请求将会触发 /oauth/token 的 handler,如下所述,

1
2
3
4
5
exports.token = [
passport.authenticate(['basic', 'oauth2-client-password'], { session: false }),
server.token(),
server.errorHandler(),
];

  • 构建 client credentials 参数,此部分要求使用 Basic Auth 的方式进行验证,进行如下配置,
    postman client credentials settings.png
    设置以后,Postman 在发送请求的时候,会默认的在 Http Header 中添加字段 Authorization: basic xxx 发送给服务器端进行验证;
  • 构建 user credentials 和 grant type 参数,此部分尤其要注意的是,必须选择 x-www-form-urlencoded 编码格式,否则验证会失败,
    postman user credentials and grant type settings.png
  • 测试,点击 send 以后,发送之前记得开启服务器;
    postman access token responsed.png
    这样,我们便获取了由服务器所返回的 token_type 为 Bearer 类型的 access_token;使用该 token,我们便可以获取相应的私有资源了;

使用 access token 获取被保护资源

最后,我们使用上述的 access_token 来模拟访问 user 的被保护资源,用户的被保护资源在 routes/user.js 模块中定义如下,

1
2
3
4
5
6
7
8
9
10
module.exports.info = [
passport.authenticate('bearer', { session: false }),
(request, response) => {
// request.authInfo is set using the `info` argument supplied by
// `BearerStrategy`. It is typically used to indicate scope of the token,
// and used in access control checks. For illustrative purposes, this
// example simply returns the scope in the response.
response.json({ user_id: request.user.id, name: request.user.name, scope: request.authInfo.scope });
}
];

可见,如果要能够访问用户的被保护资源,那么必须通过 Bearer Strategy 的验证;所以,我们使用刚才我们所获得的 access_token 同样使用 Postman 来构建 bearer 类型的请求,这里我们以访问 user 的被保护资源 /api/userinfo 为例来进行相关的配置,选择 Bearer Token,将 access token 输入 token 即可,

postman access protected resource bearear token settings.png

点击 send,即可获取用户的被保护资源的信息,

postman get protected resource via access token.png

源码分析

笔者这里仅以 Resource Owner Password Credentials 为例,来分析 oauth2orize 底层的验证执行逻辑,

配置流程源码分析

oauth2orize 初始加载流程

oauth2orize 模块分析

oauth2orize modules mindmap.png

  • exchange/
    该 package 中分别对应了根据四种不同的 grant_type 请求 token 的模块,
    • authorization.js 模块对应 grant_type $\to$ authorization_code
    • clientCredentials.js 模块对应 grant_type $\to$ client_credentials
    • password.js 模块对应 grant_type $\to$ password;该模块是笔者本篇博文着重介绍的场景;
    • refreshToken.js 模块对应 grant_type $\to$ refresh_token
  • grant/
    该 package 中包含了生成 authorization_code 和 access_token 的重要逻辑,
  • middleware/
    该 package 中包含了 oauth2orize 作为 middleware 的各种 handlers;

oauth2orize 初始化流程

oauth2orize 的初始化流程主要发生在 oauth2.js 模块中,其加载的流程图如下,
oauth2orize initialize process.png

归纳起来,oauth2orize 的初始化流程主要分为如下几个流程,

  1. 加载 grant/ 和 exchange/ 包的模块并通过 module.exports.grant.* 和 module.exports.exchange.* 的方式将核心的对象给暴露出去;不过,要注意的是,其加载的过程是通过扫描文件进行的,对应步骤 1.1

    加载 grant/ 的模块并 exports 相关对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    exports.grant = {};

    fs.readdirSync(__dirname + '/grant').forEach(function(filename) {
    if (/\.js$/.test(filename)) {
    var name = path.basename(filename, '.js');
    var load = function () { return require('./grant/' + name); };
    exports.grant.__defineGetter__(name, load);
    }
    });

    // alias grants
    exports.grant.authorizationCode = exports.grant.code;
    exports.grant.implicit = exports.grant.token;

    可以看到,通过 fs.readdirSync 方法遍历所有的 .js 文件并进行加载,对应上述代码第 6 行,并直接使用 .js 的文件名作为 exports.grant 对象的属性进行 exports 出去,对应上述代码第 7 行;

    加载 exchange/ 的模块并 exports 相关对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    exports.exchange = {};

    fs.readdirSync(__dirname + '/exchange').forEach(function(filename) {
    if (/\.js$/.test(filename)) {
    var name = path.basename(filename, '.js');
    var load = function () { return require('./exchange/' + name); };
    exports.exchange.__defineGetter__(name, load);
    }
    });

    // alias exchanges
    exports.exchange.code = exports.exchange.authorizationCode;

    加载逻辑与 grant/ 包模块一模一样,不再赘述;只是,要特别注意的是,笔者所关心的一个被 exports 出来的一个方法对象,exports.exchange.password 对应流程中的步骤 1.1.4.4,这里简单的来分析一下 password.js 模块,

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    module.exports = function(options, issue) {
    if (typeof options == 'function') {
    issue = options;
    options = undefined;
    }
    options = options || {};

    if (!issue) { throw new TypeError('oauth2orize.password exchange requires an issue callback'); }

    var userProperty = options.userProperty || 'user';

    // For maximum flexibility, multiple scope spearators can optionally be
    // allowed. This allows the server to accept clients that separate scope
    // with either space or comma (' ', ','). This violates the specification,
    // but achieves compatibility with existing client libraries that are already
    // deployed.
    var separators = options.scopeSeparator || ' ';
    if (!Array.isArray(separators)) {
    separators = [ separators ];
    }

    return function password(req, res, next) {
    if (!req.body) { return next(new Error('OAuth2orize requires body parsing. Did you forget app.use(express.bodyParser())?')); }
    ...
    }
    }

    可以看到,passport.js 模块 exports 出来的是一个匿名方法,匿名方法中定义了一个新的带闭包的 password() 方法,该方法就是 Resource Owner Password Credentials 鉴权流程的核心中的核心了,该部分内容笔者将会在执行流程源码分析的部分详细的进行介绍;

    总结

    加载并 exports grant/ 和 exchange/ 包下面的各个模块,并通过 exports.grant 和 exports.exchange 对象的方式将这些模块暴露出去,这样,第三方模块就可以非常方便的引用 grant/ 和 exchange/ 包下面的各个模块了;心得,如果某个包下面的模块较多的话,可以参考使用这种方式将各个模块 exports 出去;

  2. server.grant 流程,对应步骤 5、6、7;看其中的一个例子,grant authorization code

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // Grant authorization codes. The callback takes the `client` requesting
    // authorization, the `redirectUri` (which is used as a verifier in the
    // subsequent exchange), the authenticated `user` granting access, and
    // their response, which contains approved scope, duration, etc. as parsed by
    // the application. The application issues a code, which is bound to these
    // values, and will be exchanged for an access token.

    server.grant(oauth2orize.grant.code((client, redirectUri, user, ares, done) => {
    const code = utils.getUid(16);
    db.authorizationCodes.save(code, client.id, redirectUri, user.id, (error) => {
    if (error) return done(error);
    return done(null, code);
    });
    }));

    oauth2orize.grant.code $\to$ oauth2orize/lib/grant/code.js 模块 exports 的 code 方法的句柄,这里的 grant 流程对应就是 Authorization Code 鉴权流程,这里设置了用户自定义的回调方法,该回调方法作为接口实现了 authorization code 的生成和保存的方式;

  3. server.exchange 流程,对应步骤 8、9、10,看 password 的初始化流程

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    // Exchange user id and password for access tokens. The callback accepts the
    // `client`, which is exchanging the user's name and password from the
    // authorization request for verification. If these values are validated, the
    // application issues an access token on behalf of the user who authorized the code.

    server.exchange(oauth2orize.exchange.password((client, username, password, scope, done) => {
    // Validate the client
    db.clients.findByClientId(client.clientId, (error, localClient) => {
    if (error) return done(error);
    if (!localClient) return done(null, false);
    if (localClient.clientSecret !== client.clientSecret) return done(null, false);
    // Validate the user
    db.users.findByUsername(username, (error, user) => {
    if (error) return done(error);
    if (!user) return done(null, false);
    if (password !== user.password) return done(null, false);
    // Everything validated, return the token
    const token = utils.getUid(256);
    db.accessTokens.save(token, user.id, client.clientId, (error) => {
    if (error) return done(error);
    return done(null, token);
    });
    });
    });
    }));

    做了两件事情,

    1. 调用 oauth2orize.exchange.password 方法句柄并设置用户回调方法

    这里我们就来详细的分析一下 oauth2orize.exchange.password 方法句柄是如何注册用户的回调方法的,

    oauth2orize/lib/exhange/password.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    module.exports = function(options, issue) {
    if (typeof options == 'function') {
    issue = options;
    options = undefined;
    }
    options = options || {};

    if (!issue) { throw new TypeError('oauth2orize.password exchange requires an issue callback'); }

    var userProperty = options.userProperty || 'user';

    // For maximum flexibility, multiple scope spearators can optionally be
    // allowed. This allows the server to accept clients that separate scope
    // with either space or comma (' ', ','). This violates the specification,
    // but achieves compatibility with existing client libraries that are already
    // deployed.
    var separators = options.scopeSeparator || ' ';
    if (!Array.isArray(separators)) {
    separators = [ separators ];
    }

    return function password(req, res, next) {
    ...
    执行 grant_type=password 的标准 oauth2 流程,并通过 issue 回调用户自定义方法,
    ...
    }
    }

    用户自定义的回调方法通过参数 issue 传入,然后作为内部方法 password 的闭包,最后返回该 password(req, res, next) 方法句柄;所以要注意的是闭包中的参数 issue 保存的是用户自定义的回调方法;后续在执行过程中,将会通过该参数 issue 来回调用户的方法;

    2. 注册 password(req, res, next) 方法句柄到 server._exchanges

    oauth2orize/lib/server.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    Server.prototype.exchange = function(type, fn) {
    if (typeof type == 'function') {
    fn = type;
    type = fn.name;
    }
    if (type === '*') { type = null; }

    debug('register exchanger %s %s', type || '*', fn.name || 'anonymous');
    this._exchanges.push({ type: type, handle: fn });
    return this;
    };

    注意上述代码第 9 行,将 password(req, res, next) 方法句柄( handler )以它的方法名既 password 进行了注册;在“执行流程源码分析”章节的获取 access token 小节中可以看到,是如何通过 this._exchanges 根据 password 关键字获取相应的的方法句柄并得到 access token 的;

    总结

    看多了 Node.js 的相关源码,发现最常用的模块化的使用方式是,调用某个匿名的初始化方法 $\alpha$,而 $\alpha$ 中又定义另外一个方法 $\beta$,$\alpha$ 执行结束以后,便可以返回一个带 $\alpha$ 闭包的新的方法句柄 $\beta$;心得,这个有什么好处呢?那就是,可以通过这种方式,直接将用户自定义的配置、方法也好参数也好,统统都可以直接放到这个闭包中,想想,其它语言的配置过程为什么这么复杂,特别是 Java 的 Spring,你看看,得写多么复杂的配置文件或者 @Configuration 代码,写好了还没有完,还必须得辅以特别复杂的解析流程,完了?No,还没完,解析完了以后,还得想办法小心的将这些解析完的信息好好保存,通常是放在 Java 的静态代码块或者是某个全局的缓存对象中,这部分的详细分析可以参考笔者的另一篇博文 《Spring Core Container 源码分析七:注册 Bean Definitions》里面详细的描述了 Spring Container 是如何加载 Bean 的配置文件和过程的,复杂得让你想骂娘而通过 javascript 的闭包的方式来构建相应的配置,就显得简单得不能再简单了,用户的配置直接通过一个回调方法来配置,而存储这些配置就直接通过方法的闭包,完了,没有更多的东西了

  4. 生成 Router Middleware,下面来关注一下如何获得 access token 的 /oauth/token POST 请求的 router,

    1
    2
    3
    4
    5
    exports.token = [
    passport.authenticate(['basic', 'oauth2-client-password'], { session: false }),
    server.token(),
    server.errorHandler(),
    ];
    • 使用 Basic Strategy 和 Client Password Strategy 作为获取 token 的前置验证条件;
    • server.token()
      该方法将会返回 middleware/tokens.js 的含 options 闭包的 token(req, res, next) 方法的句柄,相关代码如下,

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      module.exports = function token(server, options) {
      options = options || {};

      if (!server) { throw new TypeError('oauth2orize.token middleware requires a server argument'); }

      return function token(req, res, next) {
      var type = req.body.grant_type;

      server._exchange(type, req, res, function(err) {
      if (err) { return next(err); }
      return next(new TokenError('Unsupported grant type: ' + type, 'unsupported_grant_type'));
      });
      };
      };

      注意,代码第 9 行,调用 server._exchange 方法,这里将会调用 #3 中通过 server.exchange() 方法所注入的 exchanges,比如 password,这部分涉及到了调用过程,具体分析参考调用流程分析;

  5. 参考 /oauth/token 请求初始化流程,客户端发起 /oauth/token POST 请求后对应的方法句柄;

/oauth/token 请求初始化流程

1
app.post('/oauth/token', routes.oauth2.token);

routes.oauth2.token $\to$ routes/oauth2.js

1
2
3
4
5
exports.token = [
passport.authenticate(['basic', 'oauth2-client-password'], { session: false }),
server.token(),
server.errorHandler(),
];

可以看到 /oauth/token 的 post 请求被映射到了 routes/oauth2.js 所定义的 token middleware 上;此部分的详细分析参考 oauth2orize 初始化流程中的 #4;由此可知,当客户端通过 /oauth/token POST 请求请求 access token 调用的既是 server.token() 所初始化所得到的调用方法的句柄,由 oauth2orize 初始化流程分析可知,调用的句柄是 oauth2orize/lib/middleware/token.js 中的方法句柄 token(req, res, next)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
module.exports = function token(server, options) {
options = options || {};

if (!server) { throw new TypeError('oauth2orize.token middleware requires a server argument'); }

return function token(req, res, next) {
var type = req.body.grant_type;

server._exchange(type, req, res, function(err) {
if (err) { return next(err); }
return next(new TokenError('Unsupported grant type: ' + type, 'unsupported_grant_type'));
});
};
};

方法句柄 token(req, res, next) 既是获取 access token入口方法;

BearerStrategy 加载流程

BearerStrategy 是 access token 的标准验证方式,当客户端通过 access token 来获取用户的被保护的信息的时候,服务器端需要使用到 Bearer Strategy 对其进行验证,本章节将重点描述 Bearer Strategy 是如何被加载并初始化的;

用户自定义回调方法

通过 auth/index.js 模块加载,首先来看一下用户自定义的回调函数逻辑,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
passport.use(new BearerStrategy(
(accessToken, done) => {
db.accessTokens.find(accessToken, (error, token) => {
if (error) return done(error);
if (!token) return done(null, false);
if (token.userId) {
db.users.findById(token.userId, (error, user) => {
if (error) return done(error);
if (!user) return done(null, false);
// To keep this example simple, restricted scopes are not implemented,
// and this is just for illustrative purposes.
done(null, user, { scope: '*' });
});
} else {
// The request came from a client only since userId is null,
// therefore the client is passed back instead of a user.
db.clients.findByClientId(token.clientId, (error, client) => {
if (error) return done(error);
if (!client) return done(null, false);
// To keep this example simple, restricted scopes are not implemented,
// and this is just for illustrative purposes.
done(null, client, { scope: '*' });
});
}
});
}
));
  1. 用户自定义的匿名回调方法,可以看到,它是从 db.accessTokens 模块中去查找,db/access_token.js 的代码如下,

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    const tokens = {};

    module.exports.find = (key, done) => {
    if (tokens[key]) return done(null, tokens[key]);
    return done(new Error('Token Not Found'));
    };

    module.exports.findByUserIdAndClientId = (userId, clientId, done) => {
    for (const token in tokens) {
    if (tokens[token].userId === userId && tokens[token].clientId === clientId) return done(null, token);
    }
    return done(new Error('Token Not Found'));
    };

    module.exports.save = (token, userId, clientId, done) => {
    tokens[token] = { userId, clientId };
    done();
    };

    可简单,直接从 tokens 缓存中去查找,然后通过 done 回调函数处理后续逻辑,

  2. 如果从 db.accessTokens 找到了相应的 token,便会回调 6 - 25 行之间的代码,

    • 如果是与 user 相关的 token,则通过 done 回调方法加载用户的信息;

      1
      done(null, user, { scope: '*' });

      注意,这里同时返回了第三方应用可访问资源的 scope

    • 如果是与 client 相关的 token,过程与 user 的大同小异;

BearerStrategy 模块

1
const BearerStrategy = require('passport-http-bearer').Strategy;

可知,BearerStrategy 加载的是 passport-http-bearer 模块,

首先,先来看一下它的构造函数,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Strategy(options, verify) {
if (typeof options == 'function') {
verify = options;
options = {};
}
if (!verify) { throw new TypeError('HTTPBearerStrategy requires a verify callback'); }

passport.Strategy.call(this);
this.name = 'bearer';
this._verify = verify;
this._realm = options.realm || 'Users';
if (options.scope) {
this._scope = (Array.isArray(options.scope)) ? options.scope : [ options.scope ];
}
this._passReqToCallback = options.passReqToCallback;
}

构造函数中,将用户自定义的回调函数通过 verify 参数赋值给了实例变量 this._verify,并且将该实例命名为 ‘bearer’,这样后续在通过 authenticate 方法进行验证的时候,便可以直接通过 ‘bearer’ 名字找到该 BearerStrategy 来进行验证;

其次,来看一下 BearerStrategy 实例的验证方法,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/**
* Authenticate request based on the contents of a HTTP Bearer authorization
* header, body parameter, or query parameter.
*
* @param {Object} req
* @api protected
*/
Strategy.prototype.authenticate = function(req) {
var token;

if (req.headers && req.headers.authorization) {
var parts = req.headers.authorization.split(' ');
if (parts.length == 2) {
var scheme = parts[0]
, credentials = parts[1];

if (/^Bearer$/i.test(scheme)) {
token = credentials;
}
} else {
return this.fail(400);
}
}

if (req.body && req.body.access_token) {
if (token) { return this.fail(400); }
token = req.body.access_token;
}

if (req.query && req.query.access_token) {
if (token) { return this.fail(400); }
token = req.query.access_token;
}

if (!token) { return this.fail(this._challenge()); }

var self = this;

function verified(err, user, info) {
if (err) { return self.error(err); }
if (!user) {
if (typeof info == 'string') {
info = { message: info }
}
info = info || {};
return self.fail(self._challenge('invalid_token', info.message));
}
self.success(user, info);
}

if (self._passReqToCallback) {
this._verify(req, token, verified);
} else {
this._verify(token, verified);
}
};

上述代码整体上包含两部分的执行逻辑,

  • 代码 11 - 35 行,依次试图从 headers、body 和 query 中去解析并获得 token,注意,这里的逻辑好玩的是,如果从 headers 中已经解析并得到了 access token,结果 body 中还有 access token 参数,那么就直接抛出 400 错误,请求无效,从 query 中解析的方式同理;
  • 代码 39 - 55 行,这里通过 this._verify 回调用户自定义的验证方法,用来验证 access_token 以及执行其它相关逻辑,这部分逻辑与 Local Strategy 的执行逻辑类同,不再赘述;

执行流程源码分析

获取 access token

回顾一下相关的配置代码如下,

1
app.post('/oauth/token', routes.oauth2.token);

routes.oauth2.token $\to$ routes/oauth2.js

1
2
3
4
5
exports.token = [
passport.authenticate(['basic', 'oauth2-client-password'], { session: false }),
server.token(),
server.errorHandler(),
];

当客户端通过 /oauth/token POST 请求试图获取 access token 的流程图如下,
oauth2orize get access token process.png

上述流程主要分为两个部分,

第一个部分:使用 Basic Strategy 验证 client credentials,

主要逻辑就是通过从 Http Header 中的 Authorization 中获得 Base 64 编码的 Client Credentials 进行验证;这是要注意的是,这里验证通过以后,通过回调函数 done() 是将 client 信息注入 request 中而不是 user,
auth/index.js

1
2
3
4
5
6
7
8
9
10
11
12
function verifyClient(clientId, clientSecret, done) {
db.clients.findByClientId(clientId, (error, client) => {
if (error) return done(error);
if (!client) return done(null, false);
if (client.clientSecret !== clientSecret) return done(null, false);
return done(null, client);
});
}

passport.use(new BasicStrategy(verifyClient));

passport.use(new ClientPasswordStrategy(verifyClient));

这里要注意的是,一旦通过 Basic Strategy 验证成功以后,便会将 client 用户的身份信息保存在 req.user 对象中;

第二个部分:通过 password 模块验证 user credentials 并颁发 access token

/oauth/token 请求初始化流程的分析可知,这一步实际上调用的是 oauth2orize/lib/middleware/token.js$token(req, res, next),所以如果要调试的话,断点需要打在这个方法上面;整个流程大致如下,

  1. 通过 grant_type 的类型从 server._exchanges 找到对应的方法句柄,demo 中的例子使用的是 password 的句柄;对应流程图中的 loop 逻辑以及步骤 1.2.2.1;
  2. 调用 password 方法句柄,

    • 解析 client、user 以及 scope 参数,
      对应步骤 1.2.2.2.1,调用过程中要注意 scope 实际上是可以通过 req.body 进行参数传递的,也就是说,客户端的 Form 表单中可以添加一个 scope 字段表示它所请求的 scope 范围,不过从后续的分析可知,客户端所请求的 scope 是否能够被接受,是用户通过自定义的回调方法来决定的;
    • 回调用户自定义的回调方法,

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      server.exchange(oauth2orize.exchange.password((client, username, password, scope, done) => {
      // Validate the client
      db.clients.findByClientId(client.clientId, (error, localClient) => {
      if (error) return done(error);
      if (!localClient) return done(null, false);
      if (localClient.clientSecret !== client.clientSecret) return done(null, false);
      // Validate the user
      db.users.findByUsername(username, (error, user) => {
      if (error) return done(error);
      if (!user) return done(null, false);
      if (password !== user.password) return done(null, false);
      // Everything validated, return the token
      const token = utils.getUid(256);
      db.accessTokens.save(token, user.id, client.clientId, (error) => {
      if (error) return done(error);
      return done(null, token);
      });
      });
      });
      }));

      首先,再次验证了 Client Credentials(其实笔者觉得这里没有必要了,因为 Client Credentials 已经通过 Basic Strategy 验证过了),然后验证 User Credetials,若通过,则调用 util.js 模块生成 access token,并通过回调方法 done 将 access token 反馈给 password 方法对象;备注,done 回调的是 password 中的 issued 方法;

  3. 回调 issued 方法,生成 response,结束
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    function issued(err, accessToken, refreshToken, params) {
    if (err) { return next(err); }
    if (!accessToken) { return next(new TokenError('Invalid resource owner credentials', 'invalid_grant')); }
    if (refreshToken && typeof refreshToken == 'object') {
    params = refreshToken;
    refreshToken = null;
    }

    var tok = {};
    tok.access_token = accessToken;
    if (refreshToken) { tok.refresh_token = refreshToken; }
    if (params) { utils.merge(tok, params); }
    tok.token_type = tok.token_type || 'Bearer';

    var json = JSON.stringify(tok);
    res.setHeader('Content-Type', 'application/json');
    res.setHeader('Cache-Control', 'no-store');
    res.setHeader('Pragma', 'no-cache');
    res.end(json);
    }

通过 access token 访问被保护资源

本章节以获取资源 /app/userinfo 为例演示如何通过 access token 获取用户的被保护资源,

先来看相关的配置流程

  1. router 的相关配置

    1
    app.get('/api/userinfo', routes.user.info);

    router.user.info $\to$ routes/user.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    module.exports.info = [
    passport.authenticate('bearer', { session: false }),
    (request, response) => {
    // request.authInfo is set using the `info` argument supplied by
    // `BearerStrategy`. It is typically used to indicate scope of the token,
    // and used in access control checks. For illustrative purposes, this
    // example simply returns the scope in the response.
    response.json({ user_id: request.user.id, name: request.user.name, scope: request.authInfo.scope });
    }
    ];

    可以看到,直接使用 Bearer Strategy 来进行验证,若通过,则相关资源;备注,相关的 oauth 的信息是保存在 request.authInfo 对象中的;

  2. Bearer Strategy 的配置参考 BearerStrategy 加载流程,这里要注意用户的回调方法,回顾一下,

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    passport.use(new BearerStrategy(
    (accessToken, done) => {
    db.accessTokens.find(accessToken, (error, token) => {
    if (error) return done(error);
    if (!token) return done(null, false);
    if (token.userId) {
    db.users.findById(token.userId, (error, user) => {
    if (error) return done(error);
    if (!user) return done(null, false);
    // To keep this example simple, restricted scopes are not implemented,
    // and this is just for illustrative purposes.
    done(null, user, { scope: '*' });
    });
    } else {
    // The request came from a client only since userId is null,
    // therefore the client is passed back instead of a user.
    db.clients.findByClientId(token.clientId, (error, client) => {
    if (error) return done(error);
    if (!client) return done(null, false);
    // To keep this example simple, restricted scopes are not implemented,
    // and this is just for illustrative purposes.
    done(null, client, { scope: '*' });
    });
    }
    });
    }
    ));

    用户的回调方法是被注册在 BearerStrategy 中的实例私有属性 _verify 中的,

    passport-http-bearer/lib/strategy.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    function Strategy(options, verify) {
    if (typeof options == 'function') {
    verify = options;
    options = {};
    }
    if (!verify) { throw new TypeError('HTTPBearerStrategy requires a verify callback'); }

    passport.Strategy.call(this);
    this.name = 'bearer';
    this._verify = verify;
    this._realm = options.realm || 'Users';
    if (options.scope) {
    this._scope = (Array.isArray(options.scope)) ? options.scope : [ options.scope ];
    }
    this._passReqToCallback = options.passReqToCallback;
    }

再来分析具体的执行流程

当客户端发起对资源的请求的时候,会调用 BearerStrategy 的 authenticate 方法,

passport-http-bearer/lib/strategy.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
Strategy.prototype.authenticate = function(req) {
var token;

if (req.headers && req.headers.authorization) {
var parts = req.headers.authorization.split(' ');
if (parts.length == 2) {
var scheme = parts[0]
, credentials = parts[1];

if (/^Bearer$/i.test(scheme)) {
token = credentials;
}
} else {
return this.fail(400);
}
}

if (req.body && req.body.access_token) {
if (token) { return this.fail(400); }
token = req.body.access_token;
}

if (req.query && req.query.access_token) {
if (token) { return this.fail(400); }
token = req.query.access_token;
}

if (!token) { return this.fail(this._challenge()); }

var self = this;

function verified(err, user, info) {
if (err) { return self.error(err); }
if (!user) {
if (typeof info == 'string') {
info = { message: info }
}
info = info || {};
return self.fail(self._challenge('invalid_token', info.message));
}
self.success(user, info);
}

if (self._passReqToCallback) {
this._verify(req, token, verified);
} else {
this._verify(token, verified);
}
};

上述代码的核心逻辑主要包含三个部分,

  1. 解析 access_token,对应上述代码 4 - 28 行
    可以看到,access_token 可以从三个地方获取,req.header、req.body 或 req.query 中,且是按照次序依次尝试的,不过注意这里的一个逻辑,access_token 不同同时出现在两个或者以上的地方,否则将会抛出 400 的错误;
  2. 回调用户自定的匿名方法,代码 44 - 48 行,

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    (accessToken, done) => {
    db.accessTokens.find(accessToken, (error, token) => {
    if (error) return done(error);
    if (!token) return done(null, false);
    if (token.userId) {
    db.users.findById(token.userId, (error, user) => {
    if (error) return done(error);
    if (!user) return done(null, false);
    // To keep this example simple, restricted scopes are not implemented,
    // and this is just for illustrative purposes.
    done(null, user, { scope: '*' });
    });
    } else {
    // The request came from a client only since userId is null,
    // therefore the client is passed back instead of a user.
    db.clients.findByClientId(token.clientId, (error, client) => {
    if (error) return done(error);
    if (!client) return done(null, false);
    // To keep this example simple, restricted scopes are not implemented,
    // and this is just for illustrative purposes.
    done(null, client, { scope: '*' });
    });
    }
    });
    }

    通过 db.accessTokens.find 方法查找 access token 是否存在且有效,并且通过一个匿名回调方法处理成功或者失败的逻辑,可见如果成功,若该 token 是属于 user 对象的,从用户数据库中加载 user 对象,并通过回调方法 done() 将 user 和 scope 设置到 req 对象中,这样,当前请求的后续 handler 便可以从 req 中获取到通过 OAuth 认证的用户信息以及相应的 Scope,这样后续的 handlers 便可以通过 Scope 来设置该请求可以访问资源的权限了

附录

待研究的问题

access token 失效的问题

access token 的失效时间如何设置,两种方案,

  1. access token 的失效时间是个绝对值,也就是说,不管用户是否再次访问,access token 都会在规定的时间失效;
  2. access token 的失效时间是个相对值,也就是说,当用户再次登录以后,access token 的失效时间将会再次计算并延长;

采用 #2 的问题是,如果用户长期登录且登录的间隔时间较短,那么 access token 将会一直有效,但在某些场景下,并不期望这种情况发生,比如某些安全防护的场景;

为了某种原因,笔者更倾向于 #1 的方式,但是会遇到这样的一种情况,如果某个用户在写一篇长博文,写之前是登录成功的,但是当他提交的时候,access token 正好在规定的时间内失效了,那么用户的提交也就丢失了;这种情况可以对某些特殊的请求设置某种特殊的规则,比如,先通过请求,然后再判断 access token 的时效性,这样可以避免这种情况的发生;但是,为了解决这种特殊的情况,却打破了 access token 的默认的验证流程,存在设计上的漏洞;还是比较倾向于,不处理这种小概率事件;