从小程序的安全说起

背景

第一个问题 小程序中可以使用session么?

答案可能出乎大部分人意外

不可以!因为微信本身不是web方案,因此表现出来不会携带cookie 我们知道cookie和session的关系 Cookie,Session和Token的故事

分析

那么我们如何来判断用户是哪个用户呢???

2a407deb43925cb2477115b9013d58d7530.jpg

小程序的官方实践

现状

首先我们来看看我们是怎么做的?

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
getWxUserInfo (options) {
const url = `/weapp/code2session`;
wx.login({
success: res => {
if (res.code) {
//发起网络请求,用code换sessionkey
requestUtil.wxRequestGet({
url,
data: {
code: res.code,
appid: config.appId,
},
success: result => {
this.saveUserInfo(options, result.data.session_key);
},
fail: result => {
wx.showToast({title: '小程序登录失败,请稍后再试。', icon: 'none'});
},
});
} else {
wx.showToast({title: '小程序登录失败,请稍后再试。', icon: 'none'});
}
},
fail(res) {
wx.showToast({title: '小程序登录失败,请稍后再试。', icon: 'none'});
}
});
},
saveUserInfo: (options, sessionKey) => {
options = options || {};
wx.getUserInfo({
lang: "zh_CN",
withCredentials: true,
success: res => {
requestUtil.wxRequestPostForm({
url: '/weapp/login',
data: {
appid: config.appId,
userInfo: JSON.stringify(res.userInfo),
encryptedData: res.encryptedData,
iv: res.iv,
sessionKey,
},
success: result => {
app.globalData.userInfo = result.data.user;
app.globalData.sessionKey = result.data.sessionKey;
if (options.success) {
options.success(result);
}
},
fail: result => {
wx.showToast({title: '获取用户信息,请稍后再试。', icon: 'none'});
},
});
},
fail: res => {
if (res.errMsg === 'getUserInfo:fail auth deny') {
wx.showModal({
title: '提示',
content: '小程序需要获取您的公开信息,请在设置中打开。',
confirmText:'去设置',
success: res => {
if (res.confirm) {
wx.openSetting({
success: (res) => {
},
});
} else {
wx.showToast({title:'获得授权失败, 无法获取用户信息', icon: 'none'});
}
}
})
}
},
})
},
};

  1. wx.login接口返回code字段 该字段用来换取sessionkey
  2. 后端将sessionkey传递到了前端
  3. 调用wx.getUserInfo方法获取用户openid【传递sessionKey】
  4. 后端根据sessionKey对加密字段解密并存储

上述步骤其实很明显了 如果我是坏人 我不会把你的sessionkey传给你 我可以自定义一个sessionKey【用该sessionKey和我想要的数据伪造出一个假的用户】

这样就会产生一个带有openid的伪造的用户了

通俗的说sessionkey相当于钥匙 微信客户端回传加密信息 我们拼接该钥匙进行解密 我们目前的做法是把钥匙给了客户然后客户每次过来携带者钥匙和加密数据给我来解密【掩耳盗铃】

问题

再回到开始的问题 我们说小程序不支持cookie 那么自然也不支持session 那么我们要如何记住客户的状态呢???

回到cookie和session的起点【究竟啥时cookie 啥是session】 Cookie,Session和Token的故事

我们明白实质上cookie就是一个header 那么如果我们在我们的请求中将对应的header设置到请求上去 那么是不是就变相的可以使用session了呢???

答案是肯定的 因此聪明的小伙伴封装了一把

1
2
3
4
5
6
7
const getHeader = (options)=> {
if (app.globalData.sessionKey) {
options.cookie = `${config.sessionKey}=${app.globalData.sessionKey}`;
return options;
}
return options;
};

那么问题来了 session就有有效期 这个session多长时间超时呢???【这个session是真的session么 session过期了应该怎么办呢?用户有办法取消session授权么?】

来看一下我们如何check用户

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
if (!app.globalData.userInfo) {
user.getWxUserInfo({
success: ()=> {
this.fetchData();
},
});
} else {
user.getUserInfo({
success: res => {
wx.stopPullDownRefresh();
this.setData({
userInfo: res.data.user,
showSelHistory: !!app.globalData.userInfo,
remainCountText: `剩余 ${res.data.user.remainCount || 0 } 次/天`,
});
app.globalData.userInfo = res.data.user;
this.setPhoneNo(this.data.userInfo.cellPhone);
},
fail: res => {
wx.stopPullDownRefresh();
wx.showToast({title: res.data.msg, icon: 'none'});
},
});
}

当globalData中存在数据则直接将该数据设置到微信的page中===》如果客户退出微信重新登陆了怎么办???

在看后端如何获取sessionKey

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RequestMapping(value="/weapp/code2session")
@ResponseBody
public CarzoneJson weAppCode2Session(HttpServletRequest request,
String appid,
String code) throws Exception {
String jsonStr = HttpUtil.get("", String.format(WE_APP_CODE_2_SESSION_URL, appid, WE_APP_SECRET, code));
JSONObject jsonObject = new JSONObject(jsonStr);
String sessionKey = "";
try {
sessionKey = jsonObject.getString("session_key");
} catch (Exception e) {
log.error("小程序登录失败" + jsonStr);
return JsonMessage.getError();
}
CarzoneJson result = JsonMessage.getSuccess().set("session_key", sessionKey);
return result;
}

这边需要注意一下和外部接口调用最好可以估计一下信息回放 这边json数据没有任何地方记录

请求参数































appid小程序唯一标识
secret小程序的 app secret
js_code登录时获取的 code
grant_type填写为 authorization_code

参数

必填

说明

在不满足 UnionID 下发条件的情况下,返回参数


















openid用户唯一标识
session_key会话密钥

参数

说明

在满足 UnionID 下发条件的情况下,返回参数






















openid用户唯一标识
session_key会话密钥
unionid用户在开放平台的唯一标识符

参数

说明

很明显这边的openId是真实的openid 也是真正需要的openid【后续授权必须先经过这一步】

但是我们通过解析字段如何做的呢?

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
@RequestMapping(value="/weapp/login")
@ResponseBody
public CarzoneJson weAppLogin(HttpServletRequest request, String appid,
String userInfo, String encryptedData,
String sessionKey, String iv) throws Exception{
//保存或更新用户信息
if (StringUtils.isNotEmpty(userInfo) && StringUtils.isNotEmpty(encryptedData)
&& StringUtils.isNotEmpty(sessionKey) && StringUtils.isNotEmpty(iv)) {
JSONObject userInfoObj = new JSONObject(userInfo);
com.alibaba.fastjson.JSONObject encryptedJson = WechatUtils.getUserInfo(sessionKey, encryptedData, iv);
String openId = encryptedJson.getString("openId");
String unionId = encryptedJson.getString("unionId");
GetUserInfoResponse userInfoModel = new GetUserInfoResponse();
userInfoModel.setNickname(userInfoObj.getString("nickName"));
userInfoModel.setCity(userInfoObj.getString("city"));
userInfoModel.setProvince(userInfoObj.getString("province"));
userInfoModel.setCountry(userInfoObj.getString("country"));
userInfoModel.setHeadimgurl(userInfoObj.getString("avatarUrl"));
userInfoModel.setLanguage(userInfoObj.getString("language"));
userInfoModel.setSex(userInfoObj.getInt("gender"));
userInfoModel.setOpenid(openId);
userInfoModel.setUnionid(unionId);
// 代表需要授权的f6用户,1代表需要授权的零公里用户,2代表需要授权的孚美用户,3代表不需要授权的其他用户
userService.addOrUpdateWxUser(userInfoModel, appid, CommonConstants.GB_WX_USER_LEVEL_10, 0);
CarzoneJson result = this.login(request, appid, openId, unionId);
result.set("sessionKey", request.getSession().getId());
return result;
} else {
throw new InvalidParameterException();
}
}

这边OpenId如果不是上述的openid说明是伪造的噢~【由于sessionkey的流失那么可以出现客户伪造】===》如果我们前面记录下了日志我们可以根据openid查找不在名单内的用户了~【最简单放到redis中可以check是否存在】

实践

按照微信的文档所描述 一段时间类用code换取的sessionKey实际上是不变的【可能达到30天 但是随着用户操作频率来说可能发生变化===》理解成我们的session 最长存在30天但是长时间不访问session将自动过期 也就是说获取wx用户的api取不到数据了】

因此这边存在问题 如何让微信授权过后的用户无缝访问我们的受保护api呢???

按照官方推荐方案使用自己平台的session【本质是还是验证我是谁】

每次请求过来都会带上用户的sessionid 这样我们根据sessionid解析出对应的session信息【由于session信息未暴露出去 自然提供了更好的安全性】

那么值钱提的问题 如何保证session的过期【比如用户切换了微信号】

微信提供了如下接口

会话密钥 session_key 有效性

开发者如果遇到因为 session_key 不正确而校验签名失败或解密失败,请关注下面几个与 session_key 有关的注意事项。

  1. wx.login() 调用时,用户的 session_key 会被更新而致使旧 session_key 失效。开发者应该在明确需要重新登录时才调用 wx.login(),及时通过登录凭证校验接口更新服务器存储的 session_key。
  2. 微信不会把 session_key 的有效期告知开发者。我们会根据用户使用小程序的行为对 session_key 进行续期。用户越频繁使用小程序,session_key 有效期越长。
  3. 开发者在 session_key 失效时,可以通过重新执行登录流程获取有效的 session_key。使用接口 wx.checkSession() 可以校验 session_key 是否有效,从而避免小程序反复执行登录流程。
  4. 当开发者在实现自定义登录态时,可以考虑以 session_key 有效期作为自身登录态有效期,也可以实现自定义的时效性策略。

wx.checkSession(OBJECT)

校验用户当前 session_key 是否有效。

OBJECT 参数说明:






























successFunction接口调用成功的回调函数,session_key 未过期
failFunction接口调用失败的回调函数,session_key 已过期
completeFunction接口调用结束的回调函数(调用成功、失败都会执行)

参数名

类型

必填

说明

示例代码:

1
2
3
4
5
6
7
8
9
10
wx.checkSession({
success: function( ){
//session_key 未过期,并且在本生命周期一直有效
},
fail: function( ){
// session_key 已经失效,需要重新执行登录流程
wx.login() //重新登录
....
}
})

在我们的系统中由于globalData一直存在用户将不会每次触发login 因此建议在小程序初始打开时调用checkSession方法

再思考

为何我们需要用户手机号???

  1. 更好的安全性 用户必须受到验证码
  2. 更好的全平台统一打通【手机号可以唯一标致客户】
  3. 更好的融资【带客户手机号的更好卖吧 ^_^】
  4. 其他思考

那么针对来说

  1. 手机短信平台很常见 大概大家不知道人心险恶===》由于验证码被人刷的概率太多了fe64bbf7376f74d3ae434ae3a8e03c154ac.jpg
  2. 这个是必要的
  3. 这个也是必要的
  4. 但是由于引入手机号输入势必造成客户手动输入手机号~同时要防止恶意注册等等

    微信其实也提供了安全的手机号获取接口 【很不巧 我们也有权限 ^_^】

    getPhoneNumber(OBJECT)

    说明

    获取微信用户绑定的手机号,需先调用 login 接口。

    因为需要用户主动触发才能发起获取手机号接口,所以该功能不由 API 来调用,需用 <button> 组件的点击来触发。

    注意:目前该接口针对非个人开发者,且完成了认证的小程序开放。需谨慎使用,若用户举报较多或被发现在不必要场景下使用,微信有权永久回收该小程序的该接口权限。

    使用方法

    需要将 <button> 组件 open-type 的值设置为 getPhoneNumber,当用户点击并同意之后,可以通过 bindgetphonenumber 事件回调获取到微信服务器返回的加密数据, 然后在第三方服务端结合 session_key 以及 app_id 进行解密获取手机号。

    注意

    在回调中调用 wx.login 登录,可能会刷新登录态。此时服务器使用 code 换取的 sessionKey 不是加密时使用的 sessionKey,导致解密失败。建议开发者提前进行 login;或者在回调中先使用 checkSession 进行登录态检查,避免 login 刷新登录态。

    例子

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <button open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumber"> </button>
    Page({
    getPhoneNumber: function(e) {
    console.log(e.detail.errMsg)
    console.log(e.detail.iv)
    console.log(e.detail.encryptedData)
    }
    })

    返回参数说明





















    encryptedDataString包括敏感数据在内的完整用户信息的加密数据,详细见加密数据解密算法
    ivString加密算法的初始向量,详细见加密数据解密算法

    参数

    类型

    说明

    encryptedData 解密后为以下 json 结构,详见加密数据解密算法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    {
    "phoneNumber": "13580006666",
    "purePhoneNumber": "13580006666",
    "countryCode": "86",
    "watermark":
    {
    "appid":"APPID",
    "timestamp":TIMESTAMP
    }
    }

























    phoneNumberString用户绑定的手机号(国外手机号会有区号)
    purePhoneNumberString没有区号的手机号
    countryCodeString区号

    参数

    类型

    说明

    比如某牛