在2021微信公开小程序分论坛上,微信公布数据称,2020年小程序日活超4亿,活跃小程序增长75%,人均使用小程序个数同比增长25%,使用小程序交易金额同比增长67%。
商业化方面, 2020 年小程序GMV(交易总额)增长超过100%,其中实物商品交易年增长154%,商家自营GMV同比提升255%。
防疫方面,2020年小程序粤康码累计服务超8亿用户,累计展码200亿次以上。防疫行程卡累计服务超5.5亿用户,累计访问次数达到19.4亿。在线参保人数1.6亿。
依托微信生态小程序迅速茁壮成长,衣食住行玩各类小程序的出现便捷人民的生活,如今已成为日常生活中不可或缺的一部分,作为服务端的开发人员,了解一下小程序的开发是挺有必要的,说不定哪天公司刚好要开发小程序就用上了。
切入正题,今天我来给同学们讲解一下小程序登录授权这一块在服务器端是怎么实现的,小程序官方开发文档地址:
https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html
大家可以看着官方文档上画的登录流程时序图去做,但可能对于初学者来说看了文档还是有点模糊,不知从何下手,思路不是很清晰,在这里我主要是给大家整理思路,将上面的登录流程时序图逐个拆分,从登录、获取code、换取openId/sessionKey、解密手机号、注册几个方面讲解,详细梳理每一个步骤。
大家先想一下,用户使用我们的小程序,那么在小程序中我们怎么识别是哪个用户,用什么来作为他的唯一身份?
看上图,其实你的微信号对应不同的小程序,都会有一个唯一的识别码对应,并且是不变的,例如你的微信打开美团小程序,对于你的微信账号和美团小程序来说会产生一个唯一的识别码,这个唯一识别码是不变的,即使你把美团小程序删除了下次再安装还是一样;因此我们可以使用这个唯一识别码作为用户标识, 而 这 个 唯 一 识 别 码 在 小 程 序 中 称 为 o p e n I d \color{FF0000}{而这个唯一识别码在小程序中称为openId} 而这个唯一识别码在小程序中称为openId
那为什么不直接使用微信账号作为身份标识,那不是更简单吗?
出 于 对 用 户 安 全 和 隐 私 保 护 , 小 程 序 授 权 是 无 法 获 取 到 用 户 微 信 号 的 ( 除 非 你 自 己 手 动 填 写 那 就 另 说 ) \color{FF0000}{出于对用户安全和隐私保护,小程序授权是无法获取到用户微信号的(除非你自己手动填写那就另说)} 出于对用户安全和隐私保护,小程序授权是无法获取到用户微信号的(除非你自己手动填写那就另说),如果随便打开一个小程序都能获得用户微信账号,那得多少用户信息被泄露卖猪仔了。
微信登录接口地址:
https://developers.weixin.qq.com/miniprogram/dev/api/open-api/login/wx.login.html
wx.login({
success (res) {
if (res.code) {
//发起网络请求
wx.request({
url: 'https://你的服务器域名/onLogin',
data: {
code: res.code //发送到服务器的code
}
})
} else {
console.log('登录失败!' + res.errMsg)
}
}
})
获取openid接口文档地址:
https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/login/auth.code2Session.html
/**
* 小程序获取用户openId以及sessionKey
* @param code 前端传的code
* @return
* @throws UserException
*/
@PostMapping("/onLogin")
public ResultVo onLogin(@RequestParam String code) throws Exception {
if(StringUtils.isEmpty(code)) {
//前端传的code为空,直接返回错误提示
return ResultVo.error(StateCode.B10031);
}
UserInfoDto userInfoDto = getOpenId(code);
return ResultVo.success(userInfoDto);
}
private UserInfoDto getOpenId(String code) throws Exception {
String url = "https://api.weixin.qq.com/sns/jscode2session?appid={你的小程序appid}&secret={你的小程序secret}&js_code=" + code + "&grant_type=authorization_code";
//请求微信接口
String result = httpRequest(url);
//获取结构为空,返回错误提示
if (StringUtils.isEmpty(result)) {
throw new UserException(StateCode.B3000000);
}
//返回结构不包含openid,返回错误提示
JSONObject json = JSONObject.parseObject(result);
if (!json.containsKey("openid")) {
throw new UserException(StateCode.B3000002);
}
//下面开始是你自己的业务代码,组装返回给前端的数据
UserInfoDto dto = new UserInfoDto();
dto.setExpiresIn(json.getInteger("expires_in"));
dto.setOpenId(json.getString("openid"));
dto.setSessionKey(json.getString("session_key"));
//根据openId查询是否已在我们平台注册过
UserAppletInfoDto userInfo = userAppletInfoService.findByAppletOpenId(dto.getOpenId());
//openid绑定用户注册
if (userInfo != null) {
dto.setUserId(userInfo.getUserId());
//设置已注册标识给前端
dto.setHasRegister(1);
dto.setPhone(userInfo.getPhone());
dto.setInviteCode(userInfo.getInviteCode());
dto.setHeadImg(userInfo.getHeadImg());
dto.setUserType(userInfo.getUserType());
dto.setNickname(userInfo.getNickname());
} else {
//设置为注册标识给前端
dto.setHasRegister(0);
}
//保存sessionKey, 7200秒过期
redissonClient.getBucket(dto.getOpenId() + ":sessionKey").set(dto.getSessionKey(), (dto.getExpiresIn() == null ? 7200 : dto.getExpiresIn()), TimeUnit.SECONDS);
return dto;
}
private static String httpRequest(String requestUrl) {
try {
CloseableHttpClient client = HttpClients.createDefault();
RequestConfig requestConfig = RequestConfig.custom().setSocketTimeout(3000).setConnectTimeout(3000).build();
HttpGet get = new HttpGet(requestUrl);
get.setConfig(requestConfig);
get.setHeader("Accept", "application/json");
get.setHeader("Content-type", "application/json;charset=utf-8");
get.setHeader("Authorization", "test");
CloseableHttpResponse response = client.execute(get);
try {
HttpEntity entity = response.getEntity();
if (entity != null) {
String result = EntityUtils.toString(entity, "UTF-8");
return result;
} else {
}
} finally {
response.close();
client.close();
}
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
小结:
1、小程序端调起微信登录接口,获得code,传给服务端;
2、服务端对传过来的code进行合法性判断;
3、服务端使用appid + appsecret + code 请求微信接口服务,获得openId 和 session_key 以及 unionid;
4、使用openid查询我们自己的数据库,是否已经绑定了用户,如绑定用户将用户信息一并返回到前端,并标上已注册标记,未绑定则标记未注册标记,给前端做业务处理;
5、将获取到的sessionKey存储到缓存里,例如redis,并设置过期时间 ( s e s s i o n K e y 后 面 解 密 手 机 号 需 要 用 到 , 有 过 期 时 间 , 默 认 7200 秒 , 如 过 期 需 要 前 端 再 次 发 起 登 录 换 取 新 的 s e s s i o n K e y ) ; \color{FF0000}{(sessionKey后面解密手机号需要用到,有过期时间,默认7200秒,如过期需要前端再次发起登录换取新的sessionKey);} (sessionKey后面解密手机号需要用到,有过期时间,默认7200秒,如过期需要前端再次发起登录换取新的sessionKey);
6、将openid以及用户信息(如已绑定用户)返回到前端。
注意:
小程序不能获取微信号,但特定条件下可以获取到微信绑定的手机号,需要条件:
1、是 企 业 小 程 序 \color{red}{企业小程序} 企业小程序,个人小程序无法获得微信绑定手机号;
2、 完 成 了 认 证 \color{red}{完成了认证} 完成了认证的小程序开放(不包含海外主体);
2、 用 户 主 动 点 击 按 钮 \color{red}{用户主动点击按钮} 用户主动点击按钮弹出获取用户手机号提示,并得到用户的确认;
3、需要 解 密 \color{red}{解密} 解密,才能得到真正的手机号。
上面的步骤,获取openid后,如未绑定用户信息,服务端会返回一个未注册的标记给前端,前端根据这个标记来判断用户未注册,引导用户点击授权按钮弹出获取手机号弹框。
文档地址:
https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/getPhoneNumber.html
<button open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumber"></button>
Page({
getPhoneNumber (e) {
console.log(e.detail.errMsg)
console.log(e.detail.iv)
console.log(e.detail.encryptedData)
}
})
用户点击同意获取绑定手机号,得到以下参数:
1、encryptedData:包括敏感数据在内的完整用户信息的加密数据;
2、iv:加密算法的初始向量;
3、cloudID:敏感数据对应的云 ID,开通云开发的小程序才会返回,可通过云调用直接获取开放数据;
4、errMsg:错误提示信息。
用 户 同 意 授 权 , 拿 到 的 其 实 是 加 密 后 的 数 据 , 无 法 直 接 使 用 , 需 要 将 获 取 到 的 参 数 以 及 前 面 得 到 的 o p e n i d 提 交 到 服 务 端 , 用 来 解 密 手 机 号 。 \color{red}{用户同意授权,拿到的其实是加密后的数据,无法直接使用,需要将获取到的参数以及前面得到的openid提交到服务端,用来解密手机号。} 用户同意授权,拿到的其实是加密后的数据,无法直接使用,需要将获取到的参数以及前面得到的openid提交到服务端,用来解密手机号。
//发起网络请求
wx.request({
url: 'https://你的服务器域名/decodePhone',
data: {
openId: openId,
encryptedData: encryptedData,
iv: iv
}
});
/**
* 解密小程序用户信息
* @param dto
* @return
* @throws Exception
*/
public DecodePhoneDto decodePhone(DecodePhoneDto dto) throws Exception {
try {
RBucket<Object> bucket = redissonClient.getBucket(dto.getOpenId() + ":sessionKey");
if (!bucket.isExists() || bucket.get() == null) {
throw new UserException(StateCode.B3000001);
}
String decodeData = decrypt(bucket.get().toString(), dto.getIv(), dto.getEncryptedData());
if (StringUtils.isNotEmpty(decodeData)) {
JSONObject json = JSONObject.parseObject(decodeData);
String phoneNumber = null;
if (json.containsKey(StringConstant.purePhoneNumber)) {
phoneNumber = json.getString(StringConstant.purePhoneNumber);
if (StringUtils.isNotEmpty(phoneNumber)) {
phoneNumber = phoneNumber.replaceAll("\\+", "");
dto.setPhone(phoneNumber);
}
} else if (json.containsKey(StringConstant.phoneNumber)) {
if (StringUtils.isNotEmpty(phoneNumber)) {
phoneNumber = json.getString(StringConstant.phoneNumber);
if (StringUtils.isNotEmpty(phoneNumber)) {
phoneNumber = phoneNumber.replaceAll("\\+", "");
dto.setPhone(phoneNumber);
}
}
}
if (json.containsKey(StringConstant.countryCode)) {
String areaCode = json.getString(StringConstant.countryCode);
if (StringUtils.isNotEmpty(areaCode)) {
areaCode = areaCode.replaceAll("\\+", "");
dto.setAreaCode(areaCode);
}
}
return dto;
}
} catch (Exception e) {
throw new UserException(StateCode.B3000001);
}
return null;
}
private static String decrypt(String session_key, String iv, String encryptData) {
String decryptString = "";
init();
byte[] sessionKeyByte = Base64.decodeBase64(session_key);
byte[] ivByte = Base64.decodeBase64(iv);
byte[] encryptDataByte = Base64.decodeBase64(encryptData);
try {
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
Key key = new SecretKeySpec(sessionKeyByte, "AES");
AlgorithmParameters algorithmParameters = AlgorithmParameters.getInstance("AES");
algorithmParameters.init(new IvParameterSpec(ivByte));
cipher.init(Cipher.DECRYPT_MODE, key, algorithmParameters);
byte[] bytes = cipher.doFinal(encryptDataByte);
decryptString = new String(bytes);
} catch (Exception e) {
e.printStackTrace();
}
return decryptString;
}
private static boolean hasInited = false;
private static void init() {
if (hasInited) {
return;
}
Security.addProvider(new BouncyCastleProvider());
hasInited = true;
}
@Data
@ToString
public class DecodePhoneDto implements Serializable {
private static final long serialVersionUID = 1L;
private String openId;
private String encryptedData;
private String iv;
private String phone;
private String areaCode;
}
服务端解密后,我们能得到以下信息,主要用到的是手机号和手机号对应的国际区号:
{
"phoneNumber": "13580006666",
"purePhoneNumber": "13580006666",
"countryCode": "86",
"watermark":
{
"appid":"APPID",
"timestamp": TIMESTAMP
}
}
小结:
1、前端发起获取用户手机号弹窗,用户确认后得到加密数据,将加密数据以及openid传给服务端;
2、服务端根据openid从缓存中拿出对应的sessionKey,解密 ( A E S 对 称 加 密 方 式 ) \color{red}{(AES对称加密方式)} (AES对称加密方式)出用户手机号以及手机区号等信息;
3、使用openid和手机号进行账号注册等业务流程。
【1】小程序登录
https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html
【2】获取openId
https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/login/auth.code2Session.html
【3】获取手机号
https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/getPhoneNumber.html
【4】解密算法
https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/signature.html