Quantcast
Channel: CNode:Node.js专业中文社区
Viewing all articles
Browse latest Browse all 14821

使用 Egg 快速开发 OAuth 2.0 授权服务

$
0
0

原文地址:http://azard.me/blog/2017/05/29/egg-oauth2-server/

前言

随着移动互联网的发展,授权协议从 OAuth 1.0 过渡到了 OAuth 2.0,新版授权协议的草案早在 2011 年就已公布,现在已经广泛应用于移动客户端的登录和网站、客户端的第三方授权。相比于会话(session),OAuth 2.0 不关注用户状态,主要用于无状态的 API 和非浏览器的移动客户端。

本文通过实例介绍如何使用 Egg.js框架和相关插件 egg-oauth2-server,快速开发 OAuth 2.0 协议的授权服务。

用法

OAuth 2.0 的完整定义较为复杂,协议名为 RFC 6749

简单来说,就是客户端通过账号密码或者某个万能密码,请求服务端,得到一个在一段时间内有效的令牌(token),之后客户端可以通过在 HTTP 请求的 header 上添加令牌,访问服务端需要授权验证的 API。

再简单点说,就是一次登录,一段时间内有效,而且服务端不需要保存客户端已登录的状态。

如果使用过 Express 或者 Koa 框架,一定接触过在路由(Route)中加入中间件(Middleware)。如果开发好了 OAuth2.0 授权服务,使用中间件在 Egg.js 框架路由中对 API 要求授权验证会非常简单。

// {app_root}/app/route.js
app.get('/public/get', 'gold.get');
app.post('/oauth2/access_token', app.oauth.grant());
app.get('/private/get', app.oauth.authorise(), 'gold.get');

第一行路由定义的 API /public/get未作任何权限保护,一个不带任何 header 和参数的 GET 请求就能得到 gold.get的返回结果。

第二行路由定义了 API /oauth2/access_token,在 header 里添加相关授权登录信息请求该 API,服务器会返回 token,客户端保存该 token 用于请求需要授权验证的 API。

第三行路由定义的 API /private/get由于添加了中间件 app.oauth.authorise(),会在执行 gold.get前进行 OAuth 2.0 权限验证,如果不是在 GET 请求的 header 里没带正确且在合法时间的令牌(token),则会提前返回 400或者 401错误码。

实现 OAuth 2.0 授权服务

独立完整的实现 OAuth 2.0 授权协议步骤繁杂,通过 egg-oauth2-server提供的简化 API,只需要实现关键业务功能,就能开发多种模式的 OAuth 2.0 授权服务。

配置

首先在 Egg.js 项目目录里安装 egg-oauth2-server插件。

$ npm i egg-oauth2-server --save

在项目插件配置文件里开启插件。

// {app_root}/config/plugin.js
exports.oauth2Server = {
  enable: true,
  package: 'egg-oauth2-server',
};

然后再在项目的配置文件里,选择需要启用的 OAuth 2.0 模式。其他相关配置,例如 token 有效时长,debug 信息,参考插件项目说明

// {app_root}/config/config.default.js
exports.oauth2Server = {
  grants: [ 'password', 'client_credentials' ],
};

在这里我们开启了 password模式和 client_credentials模式,OAuth 2.0 协议一共定义了4种模式,这里我们只介绍这两种授权模式的实现。

password 模式实现

{app_root}/app/extend/目录下创建 oauth.js文件,实现如下5个 API 就完成了一个完整的 password 模式的 OAuth 2.0 服务,就能够在 route.js 里使用 app.oauth.grant()app.oauth.authorise()

// {app_root}/app/extend/oauth.js
'use strict';

module.exports = () => {
  const model = {};
  model.getClient = (clientId, clientSecret, callback) => {};
  model.grantTypeAllowed = (clientId, grantType, callback) => {};
  model.getUser = (username, password, callback) => {};
  model.saveAccessToken = (accessToken, clientId, expires, user, callback) => {};
  model.getAccessToken = (bearerToken, callback) => {};
  return model;
};

其中获取 token 的 API app.oauth.grant()依次调用上述 API 的顺序是: getClient --> grantTypeAllowed --> getUser --> saveAccessToken

验证 token 正确性的中间件 app.oauth.authorise()只调用 getAccessToken

第一步,插件库会自动解析 token 请求头里的 clientIdclienSecret,进行第一步验证,只有当是可被授权的客户端并且密码正确再执行下一步。在这里,我的客户端 my_app的授权密码 my_secret是提前与服务端约定好,或者服务端给客户端的一个口令,服务端可以硬编码或者从数据库里进行查询。

model.getClient = async (clientId, clientSecret, callback) => {
  if (clientId === 'my_app' && clientSecret === 'my_secret') {
    callback(null, { clientId, clientSecret });
    return;
  }
  callback(null, null);
};

第二步,插件库自动解析 token 请求头里的 grantType,只有当该客户端满足请求的授权模式时,才执行下一步。

model.grantTypeAllowed = (clientId, grantType, callback) => {
  let allowed = false;
  if (grantType === 'password' && clientId === 'my_app') {
    allowed = true;
  }
  callback(null, allowed);
};

第三步,password 模式独有的步骤,插件库自动解析 token 请求 body 里的 usernamepassword字段传到改 API 里,这里使用了 egg-mongoose插件从数据库里查询用户名,并通过 bcrypt加密库验证密码的正确性。这里会把 user._id作为用户验证正确的回调参数传给下一步进行保存,可以对应每个 token 和具体用户。

model.getUser = async (username, password, callback) => {
  const user = await app.model.User.findOne({ $or: [
      { email: username },
      { name: username },
  ] });
  if (!user) {
    callback(null, null);
    return;
  }
  const result = await bcrypt.compare(password, user.password);
  if (!result) {
    callback(null, null);
  } else {
    callback(null, { id: user._id });
  }
};

第四步也就是最后一步,非常简单,将返回给用户的 token 进行保存入库,供之后服务端查询 token 的有效性和对应的用户。

model.saveAccessToken = async (accessToken, clientId, expires, user, callback) => {
  await app.model.OauthToken({ accessToken, expires, clientId, user }).save();
  callback(null);
};

以上四步任何一步的回调传 Error,客户端都会得到 400 或者 401 错误码,客户端请求 token 失败。

验证 token 正确性的中间件 app.oauth.authorise()调用的唯一函数 getAccessToken非常简单,直接从数据库中查询该 token 并进行回调,插件库会自动比对有效期。

model.getAccessToken = async (bearerToken, callback) => {
  const token = await app.model.OauthToken.findOne({ accessToken: bearerToken });
  callback(null, token);
};

client_credentials 模式实现

client_credentials 模式顾名思义,就是服务端完全信任客户端,只通过 clientIdclientSecret验证请求 token 的有效性,和 password 模式的 API 生命周期相比,就是使用 getUserFromClient替代 getUser,这个用户信息可以藏在 clientSecret里或者通过其他调用得到,具体业务的实现比较灵活。

model.getUserFromClient = async (clientId, clientSecret, callback) => {
  callback(null, { id: clientSecret });
};

具体验证方法

现在可以使用开发好的 OAuth 2.0 授权服务了,首先请求得到 token。

const response = await app.httpRequest()
  .post('/oauth2/access_token')
  .set('Content-Type', 'application/x-www-form-urlencoded')
  .set('Authorization', 'Basic bXlfYXBwOm15X3NlY3JldA==')
  .send({
    grant_type: 'password',
    username: 'test',
    password: '123456',
  })
  .expect(200);
assert(response.body.access_token);

其中 Authorization字段的内容是 Basic加上 base64 编码后的 {clientId:clientSecret},例如事例代码的内容 bXlfYXBwOm15X3NlY3JldA==就是 base64 编码的 my_app:my_secret

客户端需要保存好 response.body.access_token,用在之后的请求头的 Authorization字段中。

await app.httpRequest()
  .get('/private/get')
  .set('Authorization', 'Bearer ' + response.body.access_token)
  .expect(200);

这样,使用 Egg 开发的 OAuth 2.0 服务就算开发完成了。

结尾

Egg.js 对 Koa 进行了人性化的封装,提供了一个规约完善的脚手架,适合团队快速上手开发。 egg-oauth2-server插件对开发者屏蔽了 OAuth 2.0 协议的细枝末节,开发者只需对关键的业务进行实现,就能开发出一个完全符合协议的授权服务。


Viewing all articles
Browse latest Browse all 14821

Trending Articles