小程序用户登录授权逻辑及踩坑

小程序的微信登录授权前前后后折腾了有两周,这里记录一下整个流程和踩坑记录。

1. 登录时序图

首先祭出官方提供的登录时序图,登录流程主要按照时序图来完成。

2. 静默获取用户数据

1. wx.login() 登录接口

调用接口获取登录凭证(code)进而换取用户登录态信息,包括用户的唯一标识(openid) 及本次登录的 会话密钥(session_key)等。用户数据的加解密通讯需要依赖会话密钥完成。

注:调用 login 会引起登录态的刷新,之前的 sessionKey 可能会失效。

2. wx.checkSession(OBJECT)

通过上述接口获得的用户登录态拥有一定的时效性。用户越久未使用小程序,用户登录态越有可能失效。反之如果用户一直在使用小程序,则用户登录态一直保持有效。具体时效逻辑由微信维护,对开发者透明。开发者只需要调用wx.checkSession接口检测当前用户登录态是否有效。登录态过期后开发者可以再调用wx.login获取新的用户登录态。

以上两个方法静默调用,直接在 app.js 的 onLaunch 方法使用即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//app.js
App({
onLaunch: function () {
wx.checkSession({
success: function () {
//session 未过期,并且在本生命周期一直有效
},
fail: function () {
//登录态过期
wx.login({ //重新登录
success: function (res) {
if (res.code) {
//发起网络请求
this.getSessionKeyFromServer(res.code);
} else {
console.log('获取用户登录态失败!' + res.errMsg)
}
}
});
}
})
}
})

3. login 方法成功后返回 code, 为用户登录凭证(有效期五分钟)。开发者需要在开发者服务器后台调用 api,使用 code 换取 openid 和 session_key 等信息

小程序端代码

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
getSessionKeyFromServer: function (code) {
var that = this;
wx.request({
url: 'api address', // 我们自己服务器定义的接口地址
method: 'POST',
data: {
code: code,
},
success: function (res) {
console.log('get session key: ', res.data);
if (res.data.code == 200) {
// 满足一定条件的用户,可以直接获取到 union_id,无需再次去解密
if (res.data.data.union_id) {
that.globalData.unionId = res.data.data.union_id;
} else {
if (res.data.data.cj_session_key) {
var cjSessionKey = res.data.data.cj_session_key;
// 存入全局变量中,后续获取用户信息会使用到
that.globalData.cjSessionKey = res.data.data.cj_session_key;
that.globalData.sessionKeyExpiresAt = res.data.data.expiresAt;
// that.getUserInfoFromServer(cjSessionKey);
}
}
} else {
wx.showToast({
title: 'session 获取失败',
})
}
},
fail: function (res) {
wx.showToast({
title: 'session 获取失败',
})
}
});
},

server 端代码

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
public function postSessionKey(Request $request)
{
$timestamp = (new Carbon())->timestamp;
$code = trim($request->input('code', ''));
$flag = true;
$returnData = [];
if (empty($code)) {
$this->markFailed(301, 'code 不能为空');
$flag = false;
}
if ($flag) {
$url = 'https://api.weixin.qq.com/sns/jscode2session';
$curl = new Curl();
$getData = [
'appid' => 'app id',
'secret' => 'app secret',
'js_code' => $code,
'grant_type' => 'authorization_code',
];
$curl->get($url, $getData);
if ($curl->httpStatusCode != 200) {
$this->markFailed(302, '获取session_key失败');
} else {
$response = json_decode($curl->rawResponse, true);
if (isset($response['openid']) && !empty($response['openid'])) {
$openId = $response['openid'];
$unionId = isset($response['unionid']) ? $response['unionid'] : '';
$wechatAppUserData = [
'open_id' => trim($openId),
'expires_at' => $response['expires_in'] + $timestamp,
'session_key' => trim($response['session_key']),
'union_id' => $unionId
];
// 生成 3rd_session
$cjSessionKey = $this->wechatAppUserRepository->opensslEncrypt($openId . $timestamp);
$expiresAt = Carbon::now()->addSeconds($response['expires_in'])->addWeek();
// 所需的数据存入 redis 缓存
Cache::put(md5($cjSessionKey), $wechatAppUserData, $expiresAt);
$returnData = [
'cj_session_key' => $cjSessionKey,
'expiresAt' => $expiresAt,
'union_id' => $unionId
];
$this->markSuccess();
} else {
$this->markFailed(302, '获取session_key失败');
}
}
}
$this->returnData['data'] = $returnData;
return $this->returnData;
}

3. 如果需要获取 openid,unionid 等更多用户数据需要用户授权后才能拿到

1. wx.authorize(OBJECT)

提前向用户发起授权请求。调用后会立刻弹窗询问用户是否同意授权小程序使用某项功能或获取用户的某些数据,但不会实际调用对应接口。如果用户之前已经同意授权,则不会出现弹窗,直接返回成功。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 可以通过 wx.getSetting 先查询一下用户是否授权了 "scope.userInfo" 这个 scope
wx.getSetting({
success(res) {
if (!res.authSetting['scope.userInfo']) {
wx.authorize({
scope: 'scope.userInfo',
success() {
// 用户已经同意小程序使用用户信息功能,后续调用 wx.getUserInfo 接口不会弹窗询问
wx.getUserInfo()
}
})
}
}
})

2. wx.getUserInfo(OBJECT)

获取用户信息,withCredentials 为 true 时需要先调用 wx.login 接口。

在合适的时机,调用该方法,弹窗用户授权窗口,用户点击同意后进行后续操作。同时必须要完成用户拒绝的逻辑。

需要传入小程序本地的 sessionKey (全局变量中, 先根据全局变量的 sessionKeyExpiresAt 判断是否过期,如已过期,需要重新调用 login 方法获取新的),服务端拿到sessionKey后从缓存中取得用户的 session_key,才可以进行后续的操作。

小程序端代码

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
getUserInfoFromServer: function (cjSessionKey) {
var that = this;
wx.getUserInfo({
withCredentials: true,
success: function (res) {
that.globalData.userInfo = res.userInfo;
wx.request({
url: 'api userinfo address',
method: 'POST',
data: {
iv: res.iv,
cjSessionKey: cjSessionKey,
encryptedData: res.encryptedData
},
success: function (res) {
if (res.data.code != 200) {
wx.showToast({
title: res.data.msg,
});
} else {
that.globalData.unionId = res.data.data.union_id;
that.globalData.userId = res.data.data.user_id;
that.globalData.password = res.data.data.password;
that.globalData.username = res.data.data.username;
}
},
fail: function () {
wx.showToast({
title: '服务器连接失败',
})
}
})
},
fail: function (res) {
console.log(res);
}
})
}

服务端代码

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
public function postUserInfo(Request $request)
{
$iv = trim($request->input('iv', ''));
$cjSessionKey = trim($request->input('cjSessionKey', ''));
$encryptedData = trim($request->input('encryptedData', ''));
$ip = getClientIp();
$flag = true;
$source = 'mina';
$returnData = [];
if (empty($iv)) {
$this->markFailed(303, 'iv 不能为空');
$flag = false;
} elseif (empty($cjSessionKey)) {
$this->markFailed(304, 'cjSessionKey 不能为空');
$flag = false;
} elseif (empty($encryptedData)) {
$this->markFailed(305, 'encryptedData 不能为空');
$flag = false;
}
if ($flag) {
$sessionData = Cache::get(md5($cjSessionKey));
if (empty($sessionData)) {
$this->markFailed(306, 'session 信息已过期');
} else {
$sessionKey = $sessionData['session_key'];
$configData = config('mina');
$data = null;
$pc = new WXBizDataCrypt($configData['app_id'], trim($sessionKey));
$errorCode = $pc->decryptData($encryptedData, $iv, $data);
$cLogModel->addInfo('解密后的 code: ' . $errorCode);
$cLogModel->addInfo('解密后的 data: ' . $data);
if ($errorCode == 0) {
$userInfo = json_decode($data, true);
// 处理自己的业务逻辑,这里没有 unionId 不处理
if (isset($userInfo['unionId']) &&
!empty($userInfo['unionId']) &&
!empty($userInfo['watermark']) &&
!empty($userInfo['watermark']['appid']) &&
$userInfo['watermark']['appid'] == $configData['app_id']
) {
$unionId = $userInfo['unionId'];
if (condition) {
// 已经在平台注册,取出用户基本数据并返回
$returnData = [
'user_id' => 'data user id',
'username' => 'data username',
'password' => 'data password',
'union_id' => $unionId
];
$this->markSuccess();
} else {
// 没有在平台注册,模拟注册,基本数据返回
$returnData = [
'user_id' => 'data user id',
'username' => 'data username',
'password' => 'data password',
'union_id' => $unionId
];
$this->markSuccess();
}
} else {
$this->markFailed(310, 'appid 不匹配');
}
} else {
$this->markFailed(307, 'encryptedData 解密失败');
}
}
}
$this->returnData['data'] = $returnData;
return $this->returnData;
}

4. 大坑

1. UnionID机制说明

如果开发者拥有多个移动应用、网站应用、和公众帐号(包括小程序),可通过unionid来区分用户的唯一性,因为只要是同一个微信开放平台帐号下的移动应用、网站应用和公众帐号(包括小程序),用户的unionid是唯一的。换句话说,同一用户,对同一个微信开放平台下的不同应用,unionid是相同的。

同一个微信开放平台下的相同主体的App、公众号、小程序,如果用户已经关注公众号,或者曾经登录过App或公众号,则用户打开小程序时,开发者可以直接通过wx.login获取到该用户UnionID,无须用户再次授权。

需要到微信开发平台绑定小程序之后才能拿到 unionid

2. 加密数据解密算法

微信官方提供了多种编程语言的示例代码(点击下载)。每种语言类型的接口名字均一致。调用方式可以参照示例。

这里提供的PHP代码,首先编码格式就有问题,完全无法使用。需要复制出来以项目所用的编码方式重新进行保存。

类 WXBizDataCrypt 的构造方法需要改为:

1
2
3
4
5
public function __construct($appid, $sessionKey)
{
$this->sessionKey = $sessionKey;
$this->appid = $appid;
}

使用方法见上面的服务端代码。

文章目录
  1. 1. 登录时序图
  2. 2. 静默获取用户数据
    1. 1. wx.login() 登录接口
    2. 2. wx.checkSession(OBJECT)
    3. 3. login 方法成功后返回 code, 为用户登录凭证(有效期五分钟)。开发者需要在开发者服务器后台调用 api,使用 code 换取 openid 和 session_key 等信息
  3. 3. 如果需要获取 openid,unionid 等更多用户数据需要用户授权后才能拿到
    1. 1. wx.authorize(OBJECT)
    2. 2. wx.getUserInfo(OBJECT)
  4. 4. 大坑
    1. 1. UnionID机制说明
    2. 2. 加密数据解密算法
|