09月09, 2018

oauth授权笔记

在2月份的时候,有看到前端大神小武有写了一个项目:web-oauth-app

刚好最近学习了这方面的东西,所以打算系统整理一下。

应用场景

比如百度希望能获取QQ用户的相册,问题是只有得到用户的授权,QQ才会同意让百度读取这些照片 那么,我们怎样获得用户的授权呢?

传统方法是,QQ用户将自己的QQ号和密码,告诉百度,后者就可以读取用户的相册了。这样的做法有以下几个严重的缺点:

  • 百度为了后续的服务,会保存用户的密码,这样很不安全
  • 百度不得不部署密码登录,而我们知道,单纯的密码登录并不安全
  • 百度拥有了获取用户储存在QQ上所有资料的权力,用户没法限制百度获得授权的范围和有效期
  • 用户只有修改密码,才能收回赋予百度的权力。但是这样做,会使得其他所有获得用户授权的第三方应用程序全部失效
  • 只要有一个第三方应用程序被破解,就会导致用户密码泄漏,以及所有被密码保护的数据泄漏。

OAuth就是为了解决上面这些问题而诞生的。

名词

  • client: 第三方应用程序,本文中又称客户端,即上个例子中的"百度"
  • Resource Owner:资源所有者,本文中又称"QQ用户"(user)。
  • User Agent:用户代理,本文中就是指浏览器。
  • http service: 提供服务的HTTP服务提供商, 即上个例子中的"QQ服务器"
  • Authorization server:认证服务器,即服务提供商专门用来处理认证的服务器。
  • Resource server:资源服务器,即服务提供商存放用户生成的资源的服务器。它与认证服务器,可以是同一台服务器,也可以是不同的服务器。

OAuth的作用就是让"客户端"安全可控地获取"用户"的授权,与"服务商提供商"进行互动。

设计思路

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

工作流程

alt

  • A 用户打开客户端以后,客户端要求用户给予授权。
  • B 用户同意给予客户端授权。
  • C 客户端使用上一步获得的授权,向认证服务器申请令牌。
  • D 认证服务器对客户端进行认证以后,确认无误,同意发放令牌。
  • E 客户端使用令牌,向资源服务器申请获取资源。
  • F 资源服务器确认令牌无误,同意向客户端开放资源。

客户端的授权模式

  • 客户端必须得到用户的授权(authorization grant),才能获得令牌(access token)
  • OAuth 2.0定义了四种授权方式。

授权码模式

  • 授权码模式(authorization code)是功能最完整、流程最严密的授权模式
  • 它的特点就是通过客户端的后台服务器,与"服务提供商"的认证服务器进行互动。

alt

它的步骤如下:

  • A 用户访问客户端,后者将前者导向认证服务器
  • B 用户选择是否给予客户端授权
  • C 假设用户给予授权,认证服务器将用户导向客户端事先指定的"重定向URI"(redirection URI),同时附上一个授权码
  • D 客户端收到授权码,附上早先的"重定向URI",向认证服务器申请令牌。这一步是在客户端的后台的服务器上完成的,对用户不可见
  • E 认证服务器核对了授权码和重定向URI,确认无误后,向客户端发送访问令牌(access token)和更新令牌(refresh token)

简化模式

简化模式(implicit grant type)不通过第三方应用程序的服务器,直接在浏览器中向认证服务器申请令牌,跳过了"授权码"这个步骤,因此得名。所有步骤在浏览器中完成,令牌对访问者是可见的,且客户端不需要认证。

alt

它的步骤如下:

  • A 客户端将用户导向认证服务器。
  • B 用户决定是否给于客户端授权。
  • C 假设用户给予授权,认证服务器将用户导向客户端指定的"重定向URI",并在URI的Hash部分包含了
  • D 浏览器向资源服务器发出请求,其中不包括上一步收到的Hash值。
  • E 资源服务器返回一个网页,其中包含的代码可以获取Hash值中的令牌。
  • F 浏览器执行上一步获得的脚本,提取出令牌。
  • G 浏览器将令牌发给客户端。

密码模式

  • 密码模式(Resource Owner Password Credentials Grant)中,用户向客户端提供自己的用户名和密码。客户端使用这些信息,向"服务商提供商"索要授权。
  • 在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分,或者由一个著名公司出品。而认证服务器只有在其他授权模式无法执行的情况下,才能考虑使用这种模式。

alt

它的步骤如下:

  • A 用户向客户端提供用户名和密码。
  • B 客户端将用户名和密码发给认证服务器,向后者请求令牌。
  • C 认证服务器确认无误后,向客户端提供访问令牌。

客户端模式

  • 客户端模式(Client Credentials Grant)指客户端以自己的名义,而不是以用户的名义,向"服务提供商"进行认证。
  • 严格地说,客户端模式并不属于OAuth框架所要解决的问题。在这种模式中,用户直接向客户端注册,客户端以自己的名义要求"服务提供商"提供服务,其实不存在授权问题。

alt

它的步骤如下:

  • A 客户端向认证服务器进行身份认证,并要求一个访问令牌。
  • B 认证服务器确认无误后,向客户端提供访问令牌。

接入QQ

接入流程

alt

  • 开发者注册
  • 放置QQ登录按钮
  • 获取Access Token
  • 获取用户的OpenID
  • 调用OpenAPI访问修改用户信息

QQ开发平台的前置条件

  • QQ
  • 可以通过域名访问的服务器
  • 通过备案
  • 服务器域名环境

具体实现

可以参考这个文档

大体就是先通过authorize这个URL,获取code,再通过code获取accessToken(令牌),再通过令牌去获取openId,最后通过openId来拿到用户信息。

开发自己的oauth系统

设计表

用户表

用来注册、登录的

// User
new Schema({
    username: { type: String, required: true },//用户名
    password: { type: String, required: true },//密码
    email: { type: String, required: true },//密码
    avatar: String,//头像
    gender: { type: Number, default: 1 }//1 男 0 女
}

在npm包中有一个库: gravatar,能通过邮箱生成头像。

应用信息

用来存放存放第三方的信息

// Application
new Schema({
    appKey: { type: String, required: true },
    website: { type: String, required: true },//网站名称
    redirect_uri: { type: String, required: true },//此应用的回调地址
}));

appKey可以随机生成一个,例如:

uuid.v4(); // https://www.npmjs.com/package/uuid

通过新增一条第三方信息,可以拿到生成的ID,作为appId

权限

// Permission
new Schema({
    name: { type: String, required: true },//权限的名称 获得您的昵称、头像、性别
    route: { type: String, required: true } //路径,如get_user_info
})

授权码

// AuthorizationCode
new Schema({
    appId: { type: String, required: true },//客户端的appId
    createAt: { type: Date, default: Date.now }, //创建时间
    //permissions是一个外键的数组 类型是的文档的主键ObjectId, ref是指定这个外键是哪个集合的外键
    permissions: [{ type: ObjectId, ref: 'Permission' }],
    isUsed: { type: Boolean, default: false }, // 是否已被使用
    user: { type: ObjectId, ref: 'User' } //哪个用户的授权
})

access_code模型

new Schema({
    appId: { type: String, required: true },//客户端的appId
    refresh_token: String, //刷新token
    createAt: { type: Date, default: Date.now }, //创建时间
    //permissions是一个外键的数组 类型是的文档的主键ObjectId, ref是指定这个外键是哪个集合的外键
    permissions: [{ type: ObjectId, ref: 'Permission' }],
    user: { type: ObjectId, ref: 'User' }
})

具体代码

// 授权页面,里面要判断一下callback回调是否和应用中的回调一致,并且将所有的权限传到页面中
router.get('/authorize', async function (req, res, next) {
  //scope get_user_info,list_album
  let { client_id, redirect_uri, state, scope = "get_user_info" } = req.query;
  let application = await Application.findById(client_id);
  logger(application, application.redirect_uri, redirect_uri);
  if (application.redirect_uri != redirect_uri) {
    return next(new Error('参数中的uri和应用注册的时候保存的uri不匹配'));
  }
  //get_user_info,list_album=>[get_user_info,list_album]=>
  let query = { $or: scope.split(',').map(route => ({ route })) };
  let permissions = await Permission.find(query);
  res.render('authorize', { title: '授权第三方应用权限', permissions, application });
});

// 生成code
router.post('/authorize', async function (req, res, next) {
  let { client_id, redirect_uri, state } = req.query;
  let { permissions = [], username, password } = req.body;
  if (!Array.isArray(permissions)) permissions = [permissions];
  let user;
  if (username && password) {
    user = await User.findOne({ username, password });
    req.session.user = user;
    logger('登录并授权 ', user);
  } else {
    user = req.session.user._id;//从会话中获取当前用户的ID
    logger('从会话中获取用户信息 ', user);
  }
  if (!user) {
    return next(new Error('用户权限错误'));
  }
  //生成授权码
  let authorizationCode = await AuthorizationCode.create({
    appId: client_id,
    permissions,
    user,
    state
  });
  logger('生成授权码 ', authorizationCode._id);
  redirect_uri = decodeURIComponent(redirect_uri);
  let redirectTo = redirect_uri + (redirect_uri.indexOf('?') == -1 ? '?' : '') + `code=${authorizationCode._id}&state=${state}`;
  logger('处理成功,跳回回调路径 ', redirectTo);
  res.redirect(redirectTo);
});

// 生成access_token
router.get('/token', async function (req, res, next) {
  let options = { client_id, client_secret, code, redirect_uri } = req.query;
  let authorizationCode = await AuthorizationCode.findById(code);
  if (!authorizationCode || Date.now() - authorizationCode.createAt.getTime() > 10 * 60 * 1000 || authorizationCode.isUsed === true) {
    return next(new Error('授权码错误或者已经失效或者已经被使用过'));
  }
  let accessToken = new AccessToken({
    appId: authorizationCode.appId,
    refresh_token: uuid.v4(),
    permissions: authorizationCode.permissions,
    user: authorizationCode.user,
  });
  await accessToken.save();
  logger('生成accessToken', accessToken);
  authorizationCode.isUsed = true;
  await authorizationCode.save();
  logger('把授权码设置为已经使用过', authorizationCode);
  //access_token=FE04&expires_in=7776000&refresh_token=88E4BE14
  options = {
    access_token: accessToken._id.toString(),
    expires_in: 60 * 60 * 24 * 30 * 3,
    refresh_token: accessToken.refresh_token
  }
  res.send(querystring.stringify(options));
});

// 生成openId
router.get('/me', async function (req, res, next) {
  let { access_token } = req.query;
  let accessToken = await AccessToken.findById(access_token);
  //callback( {"client_id":"YOUR_APPID","openid":"YOUR_OPENID"} );
  let options = {
    client_id: accessToken.appId,
    openid: accessToken.user.toString()//就是用户的真实ID
  }
  logger('返回用户的openId', `callback(${JSON.stringify(options)})`);
  res.send(`callback(${JSON.stringify(options)})`);
});

// 获取用户信息,需要判断是否有这个权限
router.get('/get_user_info', async function (req, res, next) {
  let options = {
    access_token,
    oauth_consumer_key,//client_id
    openid//user._id
  } = req.query;
  let { permissions } = await AccessToken.findById(access_token).populate('permissions');
  let findItem = permissions.find(item => item.route.toString() == 'get_user_info');
  logger('findItem', findItem);
  if (findItem) {
    let user = await User.findById(openid);
    logger('返回用户详情', user);
    res.json(user);
  } else {
    return res.json({ code: 1, error: '你的Token无权限访问此接口' });
  }
});

结语

了解oauth的整个设计思路后,我们便能够实现一个自己的oauth了。

本文链接:www.my-fe.pub/post/oauth-note.html

-- EOF --

Comments

评论加载中...

注:如果长时间无法加载,请针对 disq.us | disquscdn.com | disqus.com 启用代理。