Skip to content

egg-passport 使用方法 #6

@OnedayLiu

Description

@OnedayLiu

应用场景

在日常的业务使用场景中,涉及到获取用户信息的场景基本流程是,用户登录后系统才能获取到该信息。
假设用户使用系统 A,如果需要展示系统 A 存储的用户信息,那只需提供在系统 A 注册的用户名和密码即可。如果需要在系统 A 上展示用户的 GitHub 基本信息,系统 A 是没有该用户的 GitHub 登录名和密码的,需要用户提供,这种情况下存在以下弊端:

  • 系统 A 为了后续能够继续展示用户的 GitHub 信息,会保存用户的登录信息
  • 系统 A 能够读取用户在 GitHub 上的所有信息,包括公开信息和私密信息,用户无法限制系统 A 访问 GitHub 的权限和范围
  • 如果用户想收回系统 A 访问 GitHub 的权限,只能修改登录信息,这样会影响该用户之前授权给的其他第三方服务
  • 一旦系统 A 被破解了,所有存储的用户信息都会泄露,包括通过账号密码授权方式给系统 A 的应用信息也会被泄露

基于以上问题,OAuth 诞生了。

OAuth

Wiki 的定义:

OAuth 是一个开放标准,允许用户让第三方应用访问该用户在某一网站上存储的私密的资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用。
OAuth 允许用户提供一个令牌,而不是用户名和密码来访问他们存放在特定服务提供者的数据。每一个令牌授权一个特定的网站(例如,视频编辑网站)在特定的时段(例如,接下来的 2 小时内)内访问特定的资源(例如仅仅是某一相册中的视频)。这样,OAuth 让用户可以授权第三方网站访问他们存储在另外服务提供者的某些特定信息,而非所有内容。

简单来说,OAuth 在客户端与服务提供商之间,设置了一个授权层(authorization layer)。客户端不能直接登录服务提供商,只能登录授权层,以此将用户与客户端区分开来。客户端登录授权层所用的令牌(token),与用户的密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期。

客户端登录授权层以后,服务提供商根据令牌的权限范围和有效期,向客户端开放用户储存的资料。

Passport

Passport 是用于 Node.js 程序的中间件鉴权方式。它在 OAuth 的基础上,以中间件的方式插入到 Node.js 程序当中,非常的灵活简便;通过 Passport 开发者可以使用鉴权方式访问 GithubTwitterFacebook 等服务厂商;也可以通过用户名和密码的方式进行登录授权校验。

passport 提供了很多知名服务厂商的 strategy 如:passport-github, passport-twitter 等。

Egg 的解决方案

Egg 推荐使用 egg-passportegg-passport 是基于 PassportEgg 插件,屏蔽了裸写 passport 的一些细节,如初始化、鉴权成功后的回调处理等,并且提供了方便开发者使用的 API

passport 一样,egg-passport 提供了通过用户名密码授权方式和各大服务商授权的方式:

  • 通过用户名密码的方式鉴权

image

各个步骤使用的 API:

进行登录验证:passport 对鉴权的用户名和密码的处理
业务方校验和存储用户:app.passport.verify()
序列化用户信息存入 sessionapp.passport.serializeUser()
反序列化用户信息从 session 取出:app.passport.deserializeUser

  • 通过 strategy 的授权方式鉴权

GitHub 为例:

image

如何使用 egg-passport

安装

npm install egg-passport --save

配置

Egg 中开启该插件

// config/plugin.js
exports.passport = {
  enable: true,
  package: 'egg-passport',
};

直接使用 passport-${strategy}

Github 为例:

npm install passport-github --save
// app/router.js
module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
  router.get('/authCallback', controller.home.githubCallback);  

  // 使用 app.passport.authenticate(),指定 'github' 为鉴权 strategy,对请求进行授权
  const githubAuth = app.passport.authenticate('github', {
    // 鉴权成功后的重定向
    successReturnToOrRedirect: '/authCallback',
  });
  // 申请鉴权 url
  router.get('/passport/github', githubAuth);
  // 鉴权后的回调函数,可以处理成功 / 失败的情况
  router.get('/auth/github/callback', githubAuth);
}
// app.js
module.exports = app => {
  // 配置 strategy
  const GitHubStrategy = require('passport-github').Strategy;
  app.passport.use(new GitHubStrategy({
    clientID: '填写你的 clientID',
    clientSecret: '填写你的 clientSecret',
    callbackURL: '/auth/github/callback',
    passReqToCallback: true,
  }, (req, accessToken, refreshToken, params, profile, done) => {
    const user = {
      provider: 'github',
      id: profile.id,
      name: profile.username,
    };
    app.passport.doVerify(req, user, done);
  }
  ));
};

鉴权通过后,应用层用户一般还需要处理一些事项,如校验和存储用户信息等。

// app.js
module.exports = app => {
  app.passport.verify(async (ctx, user) => {
    // 检查用户
    assert(user.provider, 'user.provider should exists');
    assert(user.id, 'user.id should exists');

    // 从数据库中查找用户信息
    //
    // Authorization Table
    // column   | desc
    // ---      | --
    // provider | provider name, like github, twitter, facebook, weibo and so on
    // uid      | provider unique id
    // user_id  | current application user id
    const auth = await ctx.model.Authorization.findOne({
      uid: user.id,
      provider: user.provider,
    });
    const existsUser = await ctx.model.User.findOne({ id: auth.user_id });
    if (existsUser) {
      return existsUser;
    }
    // 调用 service 注册新用户
    const newUser = await ctx.service.user.register(user);
    return newUser;
  });
  
  // 为了保持数据量小,只把 id 序列化后存进 session 里面
  app.passport.serializeUser(async (ctx, user) => {
    return user.id;
  });
  
  // 反序列化后,根据 id 把用户信息从数据库中取出来
  app.passport.deserializeUser(async (ctx, id) => {
    db.users.findById(id, function (err, user) {
      if (err) { return cb(err); }
      return user;
    });
  });
};

使用 egg-passport-${strategy}

如果觉得上面的这种配置方式很繁琐,可以通过安装 egg-passport-github 来简化配置流程:

npm install egg-passport-github --save

开启插件:

// config/plugin.js
exports.passportGithub = {
  enable: true,
  package: 'egg-passport-github',
};

// config/default.js
config.passportGithub = {
  key: '填写你的 clientID',
  secret: '填写你的 clientSecret'
};

注意这里配置的字段是 keysecret

module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
  router.get('/authCallback', controller.home.githubCallback);

  app.passport.mount('github', {
    callbackURL: '/auth/github/callback',
    successRedirect: '/authCallback'
  });
  
  // 上面是简便的写法,等价于以下写法
  // const github = app.passport.authenticate('github', {
  //   successRedirect: '/authCallback',
  // });
  // app.get('/passport/github', github);
  // app.get('/auth/github/callback', github);
}

校验和存储用户信息:

// app.js
module.exports = app => {
  app.passport.verify(async (ctx, user) => {});
  
  // 为了保持数据量小,只把 id 序列化后存进 session 里面
  app.passport.serializeUser(async (ctx, user) => {});
  
  // 反序列化后,根据 id 把用户信息从数据库中取出来
  app.passport.deserializeUser(async (ctx, id) => {});
};

使用用户名密码方式鉴权

  • 安装 passport-local
npm install passport-local --save
  • 设置路由
// app/router.js
module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
  
  // 鉴权成功后的回调页面
  router.get('/authCallback', controller.home.authCallback);

  // 登录
  router.get('/login', controller.home.render);
  
  // 鉴权
  router.post('/login', app.passport.authenticate('local', { successReturnToOrRedirect: '/authCallback' }));
}
  • 配置 strategy
module.exports = app => {
  const LocalStrategy = require('passport-local').Strategy;
  const config = {};
  config.passReqToCallback = true;
  app.passport.use(new LocalStrategy(config, (req, username, password, done) => {
    const user = {
      username,
      password,
    };
    app.passport.doVerify(req, user, done);
  }));
  
  app.passport.verify(async (ctx, user) => {});
  
  // 为了保持数据量小,只把 id 序列化后存进 session 里面
  app.passport.serializeUser(async (ctx, user) => {});
  
  // 反序列化后,根据 id 把用户信息从数据库中取出来
  app.passport.deserializeUser(async (ctx, id) => {});
}

这里页面模板以 egg-view-ejs 为例

npm install egg-view-ejs --save

配置信息

// config/config.default.js
config.view = {
  mapping: {
    '.ejs': 'ejs',
  },
};

// 为了演示方便这里把 csrf 暂时关闭
config.security = {
  csrf: {
    enable: false
  },
};
// config/plugin
exports.ejs = {
  enable: true,
  package: 'egg-view-ejs',
};
// app/router.js
router.get('/', controller.home.login);
// app/controller/home
async render() {
  await this.ctx.render('login.ejs', {
    data: JSON.stringify(this.ctx.user) || 'login page',
  });
}
// app/view/login.ejs
<form method="post" action="/login">
  <div>
      <label>Username:</label>
      <input type="text" name="username"/>
  </div>
  <div>
      <label>Password:</label>
      <input type="password" name="password"/>
  </div>
  <div>
      <input type="submit" value="Log In"/>
  </div>
</form>

APIs

extend application

  • app.passport.mount(strategy, options): 用指定的 strategy 来设置登录鉴权路由和鉴权后回调方法的路由配置
    /**
     * @param {String} startegy - 策略提供商名称
     * @param {Object} [options] - 可选的选项配置,loginURL:登录鉴权的 url,默认值为 /passport/${strategy};callbackURL 鉴权后的回调 url,默认值为 /passport/${strategy}/callback;其他的配置项可查看 http://www.passportjs.org/docs/
     */
    function mount(startegy, options){}

注意:egg-passport 2.0.0 版本不支持 post 方法,所以 mount 方法对用户名密码登录方式不支持

  • app.passport.authenticate(strategy, options):配置 strategyoptions 来创建一个中间件,用于鉴权使用
    /**
     * @param {String} startegy - 策略提供商名称
     * @param {Object} [options] - 可选的选项配置,配置项可查看 http://www.passportjs.org/docs/
     */
    function authenticate(startegy, options){}
  • app.passport.verify(async (ctx, user) => {}):校验用户
  • app.passport.serializeUser(handler): 序列化用户信息后存储进 session
  • app.passport.deserializeUser(handler):反序列化后取出用户信息

extend context

  • ctx.user:获取当前已登录的用户信息
  • ctx.isAuthenticated():检查该请求是否已授权
  • ctx.login(user[, options]):为用户启动一个登录的 session
  • ctx.logout():退出,将用户信息从 session 中清除

如何开发 egg-passport-${provider} 插件

以上面的 egg-passport-github 为例

  • 插件依赖 egg-passport,主要是使用其 APIs
// package.json
{
  "eggPlugin": {
    "name": "passportGithub",
    "dependencies": [
      "passport"
    ]
  },
}
  • 配置信息
// config/default.js
config.passportGithub = {
  key: '填写你的 clientID',
  secret: '填写你的 clientSecret'
};

注意:必须使用 keysecret 代替 consumerKey|clientIDconsumerSecret|clientSecret,这样有利于保持一致的开发体验

  • app.js 中初始化 Strategy 并且在 verify callback 中格式化用户信息
// app.js
const debug = require('debug')('egg-passport-github');
const assert = require('assert');
const Strategy = require('passport-github').Strategy;

module.exports = app => {
  const config = app.config.passportGithub;
  
  // 必须将 passReqToCallback 设置为 true,以便于在回调函数中能使用 req
  config.passReqToCallback = true;
  assert(config.key, '[egg-passport-github] config.passportGithub.key required');
  assert(config.secret, '[egg-passport-github] config.passportGithub.secret required');
  
  // 将 key 和 secret 转换成各个 provider 所需的 consumerKey 和 consumerSecret
  // 如果 Github 使用的是 clientID 和 clientSecret,这里将其一一替换
  config.clientID = config.key;
  config.clientSecret = config.secret;

  // register github strategy into `app.passport`
  // must require `req` params
  app.passport.use('github', new Strategy(config, (req, token, tokenSecret, params, profile, done) => {
    // format user
    const user = {
      provider: 'github',
      id: profile.id,
      name: profile.username,
      displayName: profile.displayName,
      photo: profile.photos && profile.photos[0] && profile.photos[0].value,
      token,
      tokenSecret,
      params,
      profile,
    };
    debug('%s %s get user: %j', req.method, req.url, user);
    
    // let passport do verify and call verify hook
    app.passport.doVerify(req, user, done);
  }));
};

如何开发 egg-passport-local

以下以本地开发为例,后续发布为插件形式:

// /lib/plugin/egg-passport-local/package.json
{
  "eggPlugin": {
    "name": "passportLocal",
    "dependencies": [
      "passport"
    ]
  },
  "dependencies": {
    "passport-local": "^1.0.0"
  }
}
// /lib/plugin/egg-passport-local/config
// 配置可参考 https://github.com/jaredhanson/passport-local
exports.passportLocal = {
};
// /lib/plugin/egg-passport-local/app
module.exports = app => {
  const LocalStrategy = require('passport-local').Strategy;
  const config = app.config.passportLocal;
  config.passReqToCallback = true;
  app.passport.use(new LocalStrategy(config, (req, username, password, done) => {
    const user = {
      username,
      password,
    };
    // 这里不处理应用层逻辑,传给 app.passport.verify 统一处理
    app.passport.doVerify(req, user, done);
  }));
};
// app/view/login.ejs
<form method="post" action="/login">
  <div>
      <label>Username:</label>
      <input type="text" name="username"/>
  </div>
  <div>
      <label>Password:</label>
      <input type="password" name="password"/>
  </div>
  <div>
      <input type="submit" value="Log In"/>
  </div>
</form>
// config/plugin.js
const path = require('path');
exports.passportLocal = {
  enable: true,
  path: path.join(__dirname, '../lib/plugin/egg-passport-local'),
};

好了,到这已经开发好了,如何使用呢?

module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
  router.get('/authCallback', controller.home.githubCallback);
  
  const localStrategy = app.passport.authenticate('local', {
    successRedirect: '/authCallback',
  });
  
  router.get('/login', controller.home.render);
  router.post('/login', localStrategy);
}

为什么这里不能使用 app.passport.mount('local')?
egg-passport 2.0.0 版本不支持 post 方法,所以 mount 方法对用户名密码登录方式不支持

参考文档
egg-passport

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions