进击的三方登录

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

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

默默点击了关闭……

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

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

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

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

体验地址:https://react.mobi/login

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

先来看下我的路由。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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

1
2
3
4
5
6
7
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,三方登录的核心方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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);
}
}

获取用户信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
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);
}
}

新浪微博

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
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最奇葩,做成了一团,可能是没仔细研究吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
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库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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);
});
};