进击的三方登录

645 阅读4分钟

假设一名轻度用户,偶然看到一篇不错的文章,想刷666却被登录注册拦住了。

最讨厌输入用户名密码什么的了!!!

默默点击了关闭……

用户看帖老不回,多半是退了。

你可能需要【三方登录】。

作者收集了尽可能多的三方登录方案,最终实现了qq,微信,微博,github,outlook5种。

历经千辛万苦终于集齐了7颗龙珠,是时候召唤真正的神龙了!

体验地址:react.mobi/login

关于如何实现三方登录,大概是这样的逻辑:

先来看下我的路由。

const router = new Router();
export default router  .get('/github', github.login)  .get('/github/callback', github.callback)    .get('/wechat', wechat.login)  .get('/wechat/callback', wechat.callback)    .get('/qq', qq.login)  .get('/qq/callback', qq.callback)    .get('/weibo', weibo.login)  .get('/weibo/callback', weibo.callback)    .get('/outlook', outlook.login)  .get('/outlook/callback', outlook.callback);

很好,是同一个医生。

可以看出,要实现精简的三方登录只需两步,将请求转发到对应服务器,以及接受回调。

先来看微信的实现:

微信

这边直接给出与核心逻辑无关的三个工具方法,不同平台有差异,但功能是一样的。

拼接url

function getOauthUrl() {
  let url = 'https://open.weixin.qq.com/connect/qrconnect';
  url += `?appid=${wechat.appid}`;
  url += `&redirect_uri=${API_DOMAIN}/oauth/wechat/callback`;
  url += '&response_type=code&scope=snsapi_login&state=123#wechat_redirect ';
  return url;
}

获取access_token,三方登录的核心方法

async function getAccessToken(code) {
  try {
    // 文档地址
    // https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421140842

    let url = 'https://api.weixin.qq.com/sns/oauth2/access_token';
    url += `?appid=${wechat.appid}`;
    url += `&secret=${wechat.secret}`;
    url += `&code=${code}`;
    url += '&grant_type=authorization_code';

    const data = await fetch(url);

    return data;
    // 返回值示例
    //     { "access_token":"ACCESS_TOKEN",
    // "expires_in":7200,
    // "refresh_token":"REFRESH_TOKEN",
    // "openid":"OPENID",
    // "scope":"SCOPE" }
  } catch (error) {
    console.log('error');
    console.log(error);
  }
}

获取用户信息

async function getUserInfo(access_token, openid) {
  try {
    // 文档地址
    // http://wiki.connect.qq.com/get_user_info

    let url = 'https://api.weixin.qq.com/sns/userinfo';
    url += `?access_token=${access_token}`;
    url += `&openid=${openid}`;
    url += '&lang=zh_CN';

    const data = await fetch(url);
    return data;
    // 返回值示例
    //     {    "openid":" OPENID",
    // " nickname": NICKNAME,
    // "sex":"1",
    // "province":"PROVINCE"
    // "city":"CITY",
    // "country":"COUNTRY",
    // "headimgurl":    "http://thirdwx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/46",
    // "privilege":[ "PRIVILEGE1" "PRIVILEGE2"     ],
    // "unionid": "o6_bmasdasdsad6_2sgVt7hMZOPfL"
    // }
  } catch (error) {
    console.log('error');
    console.log(error);
  }
}

然后是三方登录第一步,将用户请求重定向到指定url

login(ctx) {
    console.log('微信账号登录');
    ctx.redirect(getOauthUrl());
}

这一步按文档拼接即可,其实可以省略,前端直接去请求拼接好的url也是可以的,这边只是为了让前端更加简洁,就放在后端实现了。

第二步,接受服务器回调,拿到code。

async callback(ctx) {
    try {
      console.log('微信账号登录回调');
      const { code } = ctx.query;

      const data = await getAccessToken(code);
      const { access_token, openid, unionid } = data;
      if (!access_token) {
        console.log('微信获取access_token失败');
        ctx.redirect(DOMAIN);
      }

      // 从数据库查找对应用户第三方登录信息
      let oauth = await Oauth.findOne({ from: 'wechat', 'data.unionid': unionid });

      if (oauth) {
        // 更新三方登录信息
        await oauth.update({ data });
      } else {
        // 如果不存在则获取用户信息,创建新用户,并保存该用户的第三方登录信息
        const userInfo = await getUserInfo(access_token, openid);
        const { nickname, headimgurl } = userInfo;
        // 将用户头像上传至七牛,避免头像过期或无法访问
        const avatarUrl = await fetchToQiniu(headimgurl);
        // 创建该用户
        const user = await User.create({ avatarUrl, nickname });
        // 创建三方登录信息
        oauth = await Oauth.create({ from: 'wechat', data, userInfo, user });
      }
      // 生成token(用户身份令牌)
      const token = await getUserToken(oauth.user);
      // 重定向页面到用户登录页,并返回token
      ctx.redirect(`${DOMAIN}/login/oauth?token=${token}`);
    } catch (error) {
      ctx.redirect(DOMAIN);
      console.log('error');
      console.log(error);
    }
  }

以上就是全部逻辑了,基本上其他平台也是相同的套路。

拿到code去换取access_token,以及一个用户唯一id。

在我的逻辑中,三方登录会将三方登录信息存在oauth表中。用户授权以后,通过平台来源和唯一id,自然可以查到有无登录记录,若有,则更新授权信息,若无,则新建。

其中,若新建用户,需要挑选出用户昵称和头像,头像要存在自己的服务器中,避免头像失效。

通过以上流程,就可以拿到用户信息,完成登录。

下面放出其他平台的实现,逻辑是一样的,给需要的同学吧。

qq

function getOauthUrl() {
  let url = 'https://graph.qq.com/oauth2.0/authorize';
  url += `?client_id=${qq.App_Id}`;
  url += `&redirect_uri=${API_DOMAIN}/oauth/qq/callback`;
  url += '&state=state123';
  url += '&scope=get_user_info';
  url += '&response_type=code';
  return url;
}

async function getAccessToken(code) {
  try {
    // 文档地址
    // http://wiki.connect.qq.com/%E4%BD%BF%E7%94%A8authorization_code%E8%8E%B7%E5%8F%96access_token

    let url = 'https://graph.qq.com/oauth2.0/token';
    url += `?client_id=${qq.App_Id}`;
    url += `&client_secret=${qq.App_Key}`;
    url += `&code=${code}`;
    url += '&grant_type=authorization_code';
    url += `&redirect_uri=${API_DOMAIN}/oauth/qq/callback`;

    const data = await fetch(url, { method: 'GET' })
      .then(res => res.text())
      .then(res => parse(res));

    return data;
    // 返回值示例
    // access_token,expires_in,refresh_token
  } catch (error) {
    console.log('error');
    console.log(error);
  }
}

async function getOpenid(access_token) {
  try {
    // 文档地址
    // http://wiki.connect.qq.com/%E8%8E%B7%E5%8F%96%E7%94%A8%E6%88%B7openid_oauth2-0

    const url = `https://graph.qq.com/oauth2.0/me?access_token=${access_token}`;
    const data = await fetch(url, { method: 'GET' })
      .then(res => res.text())
      .then((res) => {
        let str = res.replace('callback( ', '');
        str = str.replace(' );', '');
        return JSON.parse(str);
      });

    return data;
    // 返回值示例
    // {"client_id":"YOUR_APPID","openid":"YOUR_OPENID"}
  } catch (error) {
    console.log('error');
    console.log(error);
  }
}

async function getUserInfo(access_token, openid) {
  try {
    // 文档地址
    // http://wiki.connect.qq.com/get_user_info
    let url = 'https://graph.qq.com/user/get_user_info';
    url += `?access_token=${access_token}`;
    url += `&oauth_consumer_key=${qq.App_Id}`;
    url += `&openid=${openid}`;

    const data = await fetch(url, { method: 'GET' })
      .then(res => res.json());

    return data;
    // 返回值示例
    // {
    //   "ret":0,
    //   "msg":"",
    //   "nickname":"Peter",
    //   "figureurl":"http://qzapp.qlogo.cn/qzapp/111111/942FEA70050EEAFBD4DCE2C1FC775E56/30",
    //   "figureurl_1":"http://qzapp.qlogo.cn/qzapp/111111/942FEA70050EEAFBD4DCE2C1FC775E56/50",
    //   "figureurl_2":"http://qzapp.qlogo.cn/qzapp/111111/942FEA70050EEAFBD4DCE2C1FC775E56/100",
    //   "figureurl_qq_1":"http://q.qlogo.cn/qqapp/100312990/DE1931D5330620DBD07FB4A5422917B6/40",
    //   "figureurl_qq_2":"http://q.qlogo.cn/qqapp/100312990/DE1931D5330620DBD07FB4A5422917B6/100",
    //   "gender":"男",
    //   "is_yellow_vip":"1",
    //   "vip":"1",
    //   "yellow_vip_level":"7",
    //   "level":"7",
    //   "is_yellow_year_vip":"1"
    //   }
  } catch (error) {
    console.log('error');
    console.log(error);
  }
}

async login(ctx) {
    console.log('qq账号登录');
    ctx.redirect(getOauthUrl());
  }

  async callback(ctx) {
    console.log('qq账号登录回调');
    try {
      const { code } = ctx.query;

      const data = await getAccessToken(code);
      const { access_token } = data;

      if (!access_token) {
        console.log('qq获取access_token失败');
        ctx.redirect(DOMAIN);
      }

      const { openid } = await getOpenid(access_token);
      if (!openid) {
        console.log('qq获取openid失败');
        ctx.redirect(DOMAIN);
      }

      // qq比较特殊,openid居然还要再单独获取一次
      data.openid = openid;

      // 从数据库查找对应用户第三方登录信息
      let oauth = await Oauth.findOne({ from: 'qq', 'data.openid': openid });

      if (oauth) {
        // 更新三方登录信息
        console.log('更新三方登录信息');
        console.log(data);
        await oauth.update({ data });
      } else {
        // 如果不存在则获取用户信息,创建新用户,并保存该用户的第三方登录信息
        const userInfo = await getUserInfo(access_token, openid);
        const { nickname, figureurl_qq_1, figureurl_qq_2 } = userInfo;
        // 将用户头像上传至七牛,避免头像过期或无法访问
        const avatarUrl = await fetchToQiniu(figureurl_qq_2 || figureurl_qq_1);
        // 创建该用户
        const user = await User.create({ avatarUrl, nickname });
        // 创建三方登录信息
        oauth = await Oauth.create({ from: 'qq', data, userInfo, user });
      }

      // 生成token(用户身份令牌)
      const token = await getUserToken(oauth.user);
      // 重定向页面到用户登录页,并返回token
      ctx.redirect(`${DOMAIN}/login/oauth?token=${token}`);
    } catch (error) {
      ctx.redirect(DOMAIN);
      console.log('error');
      console.log(error);
    }
  }

新浪微博

function getOauthUrl() {
  let url = 'https://api.weibo.com/oauth2/authorize';
  url += `?client_id=${weibo.App_Key}`;
  url += `&redirect_uri=${weibo.redirect_uri}`;
  return url;
}

async function getAccessToken(code) {
  try {
    let url = 'https://api.weibo.com/oauth2/access_token';
    url += `?client_id=${weibo.App_Key}`;
    url += `&client_secret=${weibo.App_Secret}`;
    url += `&code=${code}`;
    url += '&grant_type=authorization_code';
    url += `&redirect_uri=${weibo.redirect_uri}`;

    const data = await request(url);

    return data;
  } catch (error) {
    console.log('error');
    console.log(error);
  }
}

async function getUserInfo(access_token, uid) {
  try {
    const data = await fetch(`https://api.weibo.com/2/users/show.json?access_token=${access_token}&uid=${uid}`, { method: 'GET' })
      .then((res) => {
        return res.json();
      });
    return data;
  } catch (error) {
    console.log('error');
    console.log(error);
  }
}

async login(ctx) {
    console.log('微博用户登录');
    ctx.redirect(getOauthUrl());
  }

  async callback(ctx) {
    try {
      const { code } = ctx.query;

      const data = await getAccessToken(code);
      const { access_token, uid } = data;
      if (!access_token) {
        ctx.redirect(DOMAIN);
      }

      // 从数据库查找对应用户第三方登录信息
      let oauth = await Oauth.findOne({ from: 'weibo', 'data.uid': uid });

      // 如果不存在则创建新用户,并保存该用户的第三方登录信息
      if (oauth) {
        // 更新三方登录信息
        console.log('更新三方登录信息');
        console.log(data);
        await oauth.update({ data });
      } else {
        // 获取用户信息
        const userInfo = await getUserInfo(access_token, uid);
        const { name: nickname, profile_image_url } = userInfo;
        // // 将用户头像上传至七牛
        const avatarUrl = await fetchToQiniu(profile_image_url);
        const user = await User.create({ avatarUrl, nickname });
        oauth = await Oauth.create({ from: 'weibo', data, userInfo, user });
      }
      // 生成token(用户身份令牌)
      const token = await getUserToken(oauth.user);
      // 重定向页面到用户登录页,并返回token
      ctx.redirect(`${DOMAIN}/login/oauth?token=${token}`);
    } catch (error) {
      ctx.redirect(DOMAIN);

      console.log('error');
      console.log(error);
    }
  }

Github

function getOauthUrl() {
  const dataStr = (new Date()).valueOf();
  let url = 'https://github.com/login/oauth/authorize';
  url += `?client_id=${github.client_id}`;
  url += `&scope=${github.scope}`;
  url += `&state=${dataStr}`;
  return url;
}

async function getAccessToken(code) {
  try {
    // 文档地址
    // https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421140842

    const url = 'https://github.com/login/oauth/access_token';
    const params = { client_id: github.client_id, client_secret: github.client_secret, code };
    const data = await fetch(url, params);

    return data;
    // 返回值示例
    // {"access_token":"e72e16c7e42f292c6912e7710c838347ae178b4a",
    // "scope":"repo,gist",
    // "token_type":"bearer"}
  } catch (error) {
    console.log('error');
    console.log(error);
  }
}

async function getUserInfo(access_token) {
  try {
    const data = await fetch(`https://api.github.com/user?access_token=${access_token}`);
    return data;
    // 返回值示例
    //   {
    //     "login": "Diamondtest",
    //     "id": 28478049,
    //     "avatar_url": "https://avatars0.githubusercontent.com/u/28478049?v=3",
    //     "gravatar_id": "",
    //     "url": "https://api.github.com/users/Diamondtest",
    //     "html_url": "https://github.com/Diamondtest",
    //     "followers_url": "https://api.github.com/users/Diamondtest/followers",
    //     "following_url": "https://api.github.com/users/Diamondtest/following{/other_user}",
    //     "gists_url": "https://api.github.com/users/Diamondtest/gists{/gist_id}",
    //     "starred_url": "https://api.github.com/users/Diamondtest/starred{/owner}{/repo}",
    //     "subscriptions_url": "https://api.github.com/users/Diamondtest/subscriptions",
    //     "organizations_url": "https://api.github.com/users/Diamondtest/orgs",
    //     "repos_url": "https://api.github.com/users/Diamondtest/repos",
    //     "events_url": "https://api.github.com/users/Diamondtest/events{/privacy}",
    //     "received_events_url": "https://api.github.com/users/Diamondtest/received_events",
    //     "type": "User",
    //     "site_admin": false,
    //     "name": null,
    //     "company": null,
    //     "blog": "",
    //     "location": null,
    //     "email": null,
    //     "hireable": null,
    //     "bio": null,
    //     "public_repos": 0,
    //     "public_gists": 0,
    //     "followers": 0,
    //     "following": 0,
    //     "created_at": "2017-05-06T08:08:09Z",
    //     "updated_at": "2017-05-06T08:16:22Z"
    // }
  } catch (error) {
    console.log('error');
    console.log(error);
  }
}

async login(ctx) {
    console.log('github用户登录');
    ctx.redirect(getOauthUrl());
  }

  async callback(ctx) {
    try {
      const { code } = ctx.query;

      const data = await getAccessToken(code);
      const { access_token } = data;

      // github得先去获取用户信息才能知道唯一id
      const userInfo = await getUserInfo(access_token);
      const { id } = userInfo;

      data.id = id;

      // 从数据库查找对应用户第三方登录信息
      let oauth = await Oauth.findOne({ from: 'github', 'data.id': id });

      if (oauth) {
        // 更新三方登录信息
        console.log('更新三方登录信息');
        console.log(data);
        await oauth.update({ data, userInfo });
      } else {
        // 如果不存在则创建新用户,并保存该用户的第三方登录信息
        const { avatar_url, name, login } = userInfo;
        const nickname = name || login;
        const avatarUrl = await fetchToQiniu(avatar_url);
        const user = await User.create({ avatarUrl, nickname });
        oauth = await Oauth.create({ from: 'github', data, userInfo, user });
      }
      // 生成token(用户身份令牌)
      const token = await getUserToken(oauth.user);
      // 重定向页面到用户登录页,并返回token
      ctx.redirect(`${DOMAIN}/login/oauth?token=${token}`);
    } catch (error) {
      ctx.redirect(DOMAIN);
      console.log('error');
      console.log(error);
    }
  }

Outlook

outlook最奇葩,做成了一团,可能是没仔细研究吧

async function getOauth(code) {
  console.log('new user just submitted the code');
  console.log(`code:${code}`);

  // 下面构造个post请求,换取用户信息
  const url = 'https://login.microsoftonline.com/common/oauth2/v2.0/token';

  const params = {
    // client_id:通过注册应用程序生成的客户端ID
    client_id: config.client_id,
    // client_secret:通过注册应用程序生成的客户端密钥。
    client_secret: config.client_secret,
    // code:在前一步骤中获得的授权码。
    code,
    // redirect_uri:此值必须与授权代码请求中使用的值相同。
    redirect_uri: config.redirect_uri,
    // grant_type:应用程序使用的授权类型。对于授权授权流程,应始终如此authorization_code
    grant_type: config.grant_type,
  };

  const paramsTemp = new URLSearchParams();

  Object.keys(params).map((key) => {
    paramsTemp.append(key, params[key]);
  });

  // 这些参数被编码为application/x-www-form-urlencoded内容类型并发送到令牌请求URL。
  const options = {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: paramsTemp,
  };
  const data = await fetch(url, {}, options);
  const buff = Buffer.from(data.id_token.split('.')[1], 'base64');
  const result = JSON.parse(buff.toString());
  result.token = data;

  return result;
}

async login(ctx) {
    console.log('a new user want to login in outlook');
    console.log('config');
    console.log(config);

    // 重定向到认证接口,并配置参数
    let path = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize';

    // client_id 通过注册应用程序生成的客户端ID。这使Azure知道哪个应用程序正在请求登录。
    path += `?client_id=${config.client_id}`;

    // redirect_uri 一旦用户同意应用程序,Azure将重定向到的位置。此值必须与注册应用程序时使用的重定向URI的值相对应
    path += `&redirect_uri=${encodeURI(config.redirect_uri)}`;

    // response_type 应用程序期望的响应类型。对于授权授权流程,应始终如此code
    path += `&response_type=${config.response_type}`;

    // scope 您的应用所需的以空格分隔的访问范围列表。有关Microsoft Graph中Outlook范围的完整列表
    // 具体参考:https://developer.microsoft.com/graph/docs/authorization/permission_scopes
    path += `&scope=${config.scope}`;

    // 转发到授权服务器
    ctx.redirect(path);
  }

  async callback(ctx) {
    try {
      const { code } = ctx.query;
      const result = await getOauth(code);

      // 从数据库查找对应用户第三方登录信息
      let oauth = await Oauth.findOne({ from: 'outlook', 'data.preferred_username': result.preferred_username });
      if (!oauth) {
        // 前面半天都是为了获取用户在此app的唯一标识,username,拿稳存好
        const { preferred_username: username, name: nickname } = result;
        // outlook 暂时不知道怎么拿用户头像
        const user = await User.create({ username, nickname, avatarUrl: 'https://imgs.react.mobi/FthXc5PBp6PrhR7z9RJI6aaa46Ue' });
        // 用户第三方信息存一下
        oauth = await Oauth.create({ from: 'outlook', data: result, user });
      } else {
        const ssss = await oauth.update({ data: result });
        // todo 刷新一下用户信息,避免token过期
        console.log('ssss');
        console.log(ssss);
      }

      // 生成token(用户身份令牌)
      const token = await getUserToken(oauth.user);

      //
      //
      // 这里注意,我们使用简易方式,直接将jwt传给前端,
      // 如果安全性要求较高,或者有过期时间的需求,可以使用redis存缓token,只将引索传给前端
      //
      //
      // 重定向页面到用户登录页,并返回token
      ctx.redirect(`${DOMAIN}/login/oauth?token=${token}`);
    } catch (error) {
      console.log('error');
      console.log(error);
    }
  }

以上代码仅在我这边运行良好,具体运用请自行调试。

我封装的request库:

import fetch from 'node-fetch';

export default (url, params = {}, options = {}) => {
  return fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      accept: 'application/json',
    },
    body: JSON.stringify(params),
    ...options,
  })
    .then((res) => {
      return res.json();
    })
    .catch((e) => {
      console.log(e);
    });
};