假设一名轻度用户,偶然看到一篇不错的文章,想刷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,自然可以查到有无登录记录,若有,则更新授权信息,若无,则新建。
其中,若新建用户,需要挑选出用户昵称和头像,头像要存在自己的服务器中,避免头像失效。
通过以上流程,就可以拿到用户信息,完成登录。
下面放出其他平台的实现,逻辑是一样的,给需要的同学吧。
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);
});
};