微信小程序(抖音小程序):手机号码解析失败解决方案

在小程序开发中,可能需要用户授权获取用户信息,而用户信息涉及到手机号等敏感数据,一般的小程序开发平台,会将数据进行加密,然后通过对称加密算法进行加密解密。在获取手机号的过程中由于流程的理解错误可能会出现解密手机号失败的问题。本文介绍一种比较适用的解决办法,希望给遇到坑的同学一个参考。

1 问题描述

本文以抖音小程序(微信小程序获取流程和接口一模一样)为例,最近博主在做一个抖音小程序的小项目,前端在获取用户手机号的时候,需要调用tt.login接口进行登录,登录后返回一个code,这个code有3分钟的失效时间,根据这个code可以获取到sessionKey,这个sessionKey类似于对称加密的密钥,会对用户信息进行加密。在获取用户信息的时候,前端
需要将 <button> 组件 open-type 的值设置为 getPhoneNumber。用户点击后会弹出一个授权弹窗让用户确认(若该用户账户未绑定手机号码会执行一次绑定手机号码的流程;授权弹窗每次使用都会弹出)。 用户同意后,开发者可以通过 bindgetphonenumber 事件回调获取到一个加密数据,开发者可以把该数据传回到自己的服务端进行解密获取手机号。

获取到的加密数据需要使用sessionKey进行解密,因此在获取用户信息前,需要登录一次,获取到code,然后根据code获取到sessionKey,再根据sessionKey进行加密数据的解密,解析出手机号。

根据博主猜测,抖音在登录后会生成一个code,和一个对应的sessionKey,在会话期间(session未过期)的时候获取用户信息,会将用户信息使用sessionKey进行数据的加密,进行数据的解密也需要使用到sessionKey。code和sessionKey是对应的,但是它们的失效期是不一样的,code的失效期是3分钟,sessionKey的失效时间是不定的,只要用户活跃在页面上都不会失效。在获取到code的3分钟内调用code-2-session接口,会获取到sessionKey,如果3分钟后根据code获取sessionKey将会获取失败,因此解密也会失败。

1.1 初始实现

因为无法判断用户什么时候开始获取用户信息,所以用户一进入页面,前端就会调用tt.login接口进行登录,然后放到localstorage缓存中,在用户点击按钮时,弹出授权框用户确认后获取到用户信息的加密数据,然后前端将缓存的code和加密数据一并传给后端。后端用code先去调用code-2-session接口获取到sessionKey,然后以sessionKey为密钥进行AES解密,获取到手机号返回给前台。整个流程看起来没什么问题,但是一旦用户在页面停留时间超过3分钟,然后再去获取用户信息会失败,主要是因为code已经失效,获取sessionKey会失败。

image.png

2 解决办法

2.1 缓存sessionKey

目前的问题就是过了code的有效期后,根据code获取sessionKey失败。那么在前端login获取到code后,先缓存到本地,然后立即调用后台接口去获取sessionKey然后缓存到redis里面,key为code,value为sessionKey。失效时间根据自己的业务设置(小程序页面用户不会停留太久,因此缓存失效时间设置为30分钟),用户退出小程序后,会重新login,然后也会存一份新的code和sessionKey的对应值。

用户在授权到用户信息后,前端直接将缓存的code和加密后的用户信息上传到服务到进行解密。服务端根据code从缓存中先获取到sessionKey,然后再用sessionKey进行解密,解析出手机号进行返回。

image.png

2.2 存在问题

以上解决办法每次基本都可以获取手机号成功,但是也会存在一些问题

  • 会存在很多冗余数据:因为缓存是根据code进行缓存的,无法根据用户唯一id进行缓存,如果用户多次进行登录,将会存储多份,因此需要根据自己的业务时间进行设置缓存失效时间
  • 实现更加复杂:因为后端还涉及到redis服务 以及加密解密的过程

3 附上源码

3.1 用户信息controller

UserInfoController主要提供两个接口,一个是解密手机号和code2seesion操作

@Api("用户信息")
@Validated
@RestController
public class UserInfoController {

    @Resource
    TiktokUserInfoSPI tiktokUserInfoSPI;

    @ApiOperation("解密手机号")
    @PostMapping("/api/userinfo/decrypt/phone")
    public Result<PhoneResult> decryptPhone(@Validated @RequestBody TiktokEncryptedParam param) {
        return Result.success(tiktokUserInfoSPI.decryptUserPhone(param));
    }

    @ApiOperation("code2seesion")
    @GetMapping("/api/userinfo/code2session")
    public Result code2Session(@RequestParam("code") @NotEmpty(message = "code不能为空") String code) {
        tiktokUserInfoSPI.code2Session(code);
        return Result.success(null);
    }
}

TiktokEncryptedParam 主要是前端传过来的code和加密后的数据

/**
 * @ClassName : TiktokEncryptedParam
 * @Description : 抖音小程序用户加密参数
 */
@Data
@ApiModel("抖音小程序加密参数")
public class TiktokEncryptedParam {

    @NotEmpty(message = "code不能为空")
    @ApiModelProperty(value="login 接口返回的登录凭证",name="code")
    private String code;

    @ApiModelProperty(value="login 接口返回的匿名登录凭证",name="anonymousCode")
    private String anonymousCode;

    @NotEmpty(message = "加密数据不能为空")
    @ApiModelProperty(value="加密数据",name="encryptedData")
    private String encryptedData;

    @NotEmpty(message = "加密初始向量不能为空")
    @ApiModelProperty(value="加密初始向量",name="iv")
    private String iv;
}
3.2 抖音接口SPI

TiktokUserInfoSPI 主要是对接口的封装

public interface TiktokUserInfoSPI {

    /**
     * 解密敏感数据获取手机号
     * @param param
     * @return
     */
    PhoneResult decryptUserPhone(TiktokEncryptedParam param);

    /**
     * 通过login接口获取到登录凭证后,开发者可以通过服务器发送请求的方式获取 session_key 和 openId。
     * @param code
     * @return
     */
    Code2SessionResult code2Session(String code);
}

TiktokUserInfoSPIAdapter 实现接口


import cn.hutool.core.util.ObjectUtil;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;

@Slf4j
@Service
public class TiktokUserInfoSPIAdapter implements TiktokUserInfoSPI {

    @Value("${tiktok.miniprogram.appid}")
    private String appId;

    @Value("${tiktok.miniprogram.secret}")
    private String secret;

    @Qualifier("getRedisson")
    @Autowired
    RedissonClient redissonClient;

    @Override
    public PhoneResult decryptUserPhone(TiktokEncryptedParam param) {
        if(ObjectUtil.isEmpty(param.getCode())) {
            throw new BusinessException(ToastConstant.ERR_JSCODE);
        }
        PhoneResult phoneResult = new PhoneResult();
        // code2Session
        Code2SessionResult result = getSessionResult(param.getCode());
        if(result.getErrCode() != 0) {
            phoneResult.setErrCode(result.getErrCode());
            phoneResult.setErrMsg(result.getErrMsg());
            return phoneResult;
        }
        log.info("开始进行数据解密------- param = [{}]" , JSONUtil.toJsonStr(param));
        String jsonString = DecryptUtil.decrypt(param.getEncryptedData(), result.getSessionKey(), param.getIv());
        log.info("解密后的数据为-------  jsonString = [{}]" , jsonString);
        PhoneNumberResult phoneNumberResult = JSONUtil.toBean(jsonString, PhoneNumberResult.class);
        phoneResult.setErrCode(0);
        phoneResult.setPhone(phoneNumberResult.getPurePhoneNumber());
        return phoneResult;
    }

    private Code2SessionResult getSessionResult(String code) {
        String cacheKey = String.format(RedisConstant.CACHE_KEY.TIKTOK_SESSION_KEY, code);
        RBucket<Code2SessionResult> bucket = this.redissonClient.getBucket(cacheKey);
        Code2SessionResult result = bucket.get();
        if(ObjectUtil.isNull(result) || ObjectUtil.isEmpty(result.getSessionKey())) {
            result = new Code2SessionResult();
            result.setErrCode(ErrCodeEnum.FAIL.getCode());
            result.setErrMsg(ToastConstant.ERROR_GET_SESSION_KEY);
        }
        return result;
    }

    @Override
    public Code2SessionResult code2Session(String code) {
        // 构造参数
        Map<String, Object> paramMap = new HashMap<>();
        paramMap.put("appid", appId);
        paramMap.put("secret", secret);
        paramMap.put("code", code);
        // 发送请求
        String jsonResult = HttpUtil.get(ApiConstant.BYTEDANCE_TIKTOK.JSCODE_2SESSION, paramMap);
        if(ObjectUtil.isNull(jsonResult)) {
            log.error("获取sessionKey失败, jsonResult 返回为空");
            //throw new BusinessException(ToastConstant.ERROR_GET_SESSION_KEY);
        }
        // 解析结果
        JSONObject jsonObject = JSONUtil.parseObj(jsonResult);
        int error = jsonObject.getInt("error");
        Code2SessionResult result = new Code2SessionResult();
        if(error == ErrCodeEnum.SUCCESS.getCode()) {
            result.setOpenId(jsonObject.getStr("openid"));
            result.setSessionKey(jsonObject.getStr("session_key"));
            result.setAnonymousOpenId(jsonObject.getStr("anonymous_openid"));
            result.setUnionId(jsonObject.getStr("unionid"));
            result.setErrCode(ErrCodeEnum.SUCCESS.getCode());
            //return result;
        } else {
            int errCode = jsonObject.getInt("errcode");
            String errMsg = jsonObject.getStr("errmsg");
            // code错误,可能是登录失效
            if(errCode == Code2SessionEnum.ERROR_40018.getCode() ||
                    errCode == Code2SessionEnum.ERROR_40019.getCode()) {
                result.setErrCode(errCode);
                result.setErrMsg(errMsg);
                //return result;
            }
            log.error("获取sessionKey失败, errCode = [{}], errMsg = [{}]", errCode, errMsg);
            //throw new BusinessException(ToastConstant.ERROR_GET_SESSION_KEY);
        }
        String cacheKey = String.format(RedisConstant.CACHE_KEY.TIKTOK_SESSION_KEY, code);
        RBucket<Code2SessionResult> bucket = this.redissonClient.getBucket(cacheKey);
        bucket.set(result, 30, TimeUnit.MINUTES);
        return result;
    }

}
3.3 加密解密

使用AES对称加密


import cn.hutool.crypto.symmetric.AES;
import com.tiktokminiprogram.exception.BusinessException;
import lombok.extern.slf4j.Slf4j;

import java.util.Base64;

/**
 * @ClassName : DecryptUtil
 * @Description : 解密工具类
 */
@Slf4j
public class DecryptUtil {

    /**
     * 解密敏感数据
     * @param encryptedData
     * @param sessionKey
     * @param iv
     * @return
     */
    public static String decrypt(String encryptedData, String sessionKey, String iv) {
        try {
            Base64.Decoder decoder = Base64.getDecoder();
            byte[] sessionKeyBytes = decoder.decode(sessionKey);
            byte[] ivBytes = decoder.decode(iv);
            byte[] encryptedBytes = decoder.decode(encryptedData);

            AES aes = new AES("CBC", "PKCS7Padding", sessionKeyBytes, ivBytes);
            String res = aes.decryptStr(encryptedBytes);
            log.info("res = [{}]", res);
            return res;
        } catch (Exception e) {
            log.error("解密出现错误", e);
            throw new BusinessException("解密出现错误");
        }
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 200,392评论 5 470
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,258评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 147,417评论 0 332
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,992评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,930评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,199评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,652评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,327评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,463评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,382评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,432评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,118评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,704评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,787评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,999评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,476评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,057评论 2 341

推荐阅读更多精彩内容