在小程序開(kāi)發(fā)中,可能需要用戶(hù)授權(quán)獲取用戶(hù)信息,而用戶(hù)信息涉及到手機(jī)號(hào)等敏感數(shù)據(jù),一般的小程序開(kāi)發(fā)平臺(tái),會(huì)將數(shù)據(jù)進(jìn)行加密,然后通過(guò)對(duì)稱(chēng)加密算法進(jìn)行加密解密。在獲取手機(jī)號(hào)的過(guò)程中由于流程的理解錯(cuò)誤可能會(huì)出現(xiàn)解密手機(jī)號(hào)失敗的問(wèn)題。本文介紹一種比較適用的解決辦法,希望給遇到坑的同學(xué)一個(gè)參考。
1 問(wèn)題描述
本文以抖音小程序(微信小程序獲取流程和接口一模一樣)為例,最近博主在做一個(gè)抖音小程序的小項(xiàng)目,前端在獲取用戶(hù)手機(jī)號(hào)的時(shí)候,需要調(diào)用tt.login接口進(jìn)行登錄,登錄后返回一個(gè)code,這個(gè)code有3分鐘的失效時(shí)間,根據(jù)這個(gè)code可以獲取到sessionKey,這個(gè)sessionKey類(lèi)似于對(duì)稱(chēng)加密的密鑰,會(huì)對(duì)用戶(hù)信息進(jìn)行加密。在獲取用戶(hù)信息的時(shí)候,前端
需要將 <button> 組件 open-type 的值設(shè)置為 getPhoneNumber。用戶(hù)點(diǎn)擊后會(huì)彈出一個(gè)授權(quán)彈窗讓用戶(hù)確認(rèn)(若該用戶(hù)賬戶(hù)未綁定手機(jī)號(hào)碼會(huì)執(zhí)行一次綁定手機(jī)號(hào)碼的流程;授權(quán)彈窗每次使用都會(huì)彈出)。 用戶(hù)同意后,開(kāi)發(fā)者可以通過(guò) bindgetphonenumber 事件回調(diào)獲取到一個(gè)加密數(shù)據(jù),開(kāi)發(fā)者可以把該數(shù)據(jù)傳回到自己的服務(wù)端進(jìn)行解密獲取手機(jī)號(hào)。
獲取到的加密數(shù)據(jù)需要使用sessionKey進(jìn)行解密,因此在獲取用戶(hù)信息前,需要登錄一次,獲取到code,然后根據(jù)code獲取到sessionKey,再根據(jù)sessionKey進(jìn)行加密數(shù)據(jù)的解密,解析出手機(jī)號(hào)。
根據(jù)博主猜測(cè),抖音在登錄后會(huì)生成一個(gè)code,和一個(gè)對(duì)應(yīng)的sessionKey,在會(huì)話期間(session未過(guò)期)的時(shí)候獲取用戶(hù)信息,會(huì)將用戶(hù)信息使用sessionKey進(jìn)行數(shù)據(jù)的加密,進(jìn)行數(shù)據(jù)的解密也需要使用到sessionKey。code和sessionKey是對(duì)應(yīng)的,但是它們的失效期是不一樣的,code的失效期是3分鐘,sessionKey的失效時(shí)間是不定的,只要用戶(hù)活躍在頁(yè)面上都不會(huì)失效。在獲取到code的3分鐘內(nèi)調(diào)用code-2-session接口,會(huì)獲取到sessionKey,如果3分鐘后根據(jù)code獲取sessionKey將會(huì)獲取失敗,因此解密也會(huì)失敗。
1.1 初始實(shí)現(xiàn)
因?yàn)闊o(wú)法判斷用戶(hù)什么時(shí)候開(kāi)始獲取用戶(hù)信息,所以用戶(hù)一進(jìn)入頁(yè)面,前端就會(huì)調(diào)用tt.login接口進(jìn)行登錄,然后放到localstorage緩存中,在用戶(hù)點(diǎn)擊按鈕時(shí),彈出授權(quán)框用戶(hù)確認(rèn)后獲取到用戶(hù)信息的加密數(shù)據(jù),然后前端將緩存的code和加密數(shù)據(jù)一并傳給后端。后端用code先去調(diào)用code-2-session接口獲取到sessionKey,然后以sessionKey為密鑰進(jìn)行AES解密,獲取到手機(jī)號(hào)返回給前臺(tái)。整個(gè)流程看起來(lái)沒(méi)什么問(wèn)題,但是一旦用戶(hù)在頁(yè)面停留時(shí)間超過(guò)3分鐘,然后再去獲取用戶(hù)信息會(huì)失敗,主要是因?yàn)閏ode已經(jīng)失效,獲取sessionKey會(huì)失敗。

2 解決辦法
2.1 緩存sessionKey
目前的問(wèn)題就是過(guò)了code的有效期后,根據(jù)code獲取sessionKey失敗。那么在前端login獲取到code后,先緩存到本地,然后立即調(diào)用后臺(tái)接口去獲取sessionKey然后緩存到redis里面,key為code,value為sessionKey。失效時(shí)間根據(jù)自己的業(yè)務(wù)設(shè)置(小程序頁(yè)面用戶(hù)不會(huì)停留太久,因此緩存失效時(shí)間設(shè)置為30分鐘),用戶(hù)退出小程序后,會(huì)重新login,然后也會(huì)存一份新的code和sessionKey的對(duì)應(yīng)值。
用戶(hù)在授權(quán)到用戶(hù)信息后,前端直接將緩存的code和加密后的用戶(hù)信息上傳到服務(wù)到進(jìn)行解密。服務(wù)端根據(jù)code從緩存中先獲取到sessionKey,然后再用sessionKey進(jìn)行解密,解析出手機(jī)號(hào)進(jìn)行返回。

2.2 存在問(wèn)題
以上解決辦法每次基本都可以獲取手機(jī)號(hào)成功,但是也會(huì)存在一些問(wèn)題
- 會(huì)存在很多冗余數(shù)據(jù):因?yàn)榫彺媸歉鶕?jù)code進(jìn)行緩存的,無(wú)法根據(jù)用戶(hù)唯一id進(jìn)行緩存,如果用戶(hù)多次進(jìn)行登錄,將會(huì)存儲(chǔ)多份,因此需要根據(jù)自己的業(yè)務(wù)時(shí)間進(jìn)行設(shè)置緩存失效時(shí)間
- 實(shí)現(xiàn)更加復(fù)雜:因?yàn)楹蠖诉€涉及到redis服務(wù) 以及加密解密的過(guò)程
3 附上源碼
3.1 用戶(hù)信息controller
UserInfoController主要提供兩個(gè)接口,一個(gè)是解密手機(jī)號(hào)和code2seesion操作
@Api("用戶(hù)信息")
@Validated
@RestController
public class UserInfoController {
@Resource
TiktokUserInfoSPI tiktokUserInfoSPI;
@ApiOperation("解密手機(jī)號(hào)")
@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 主要是前端傳過(guò)來(lái)的code和加密后的數(shù)據(jù)
/**
* @ClassName : TiktokEncryptedParam
* @Description : 抖音小程序用戶(hù)加密參數(shù)
*/
@Data
@ApiModel("抖音小程序加密參數(shù)")
public class TiktokEncryptedParam {
@NotEmpty(message = "code不能為空")
@ApiModelProperty(value="login 接口返回的登錄憑證",name="code")
private String code;
@ApiModelProperty(value="login 接口返回的匿名登錄憑證",name="anonymousCode")
private String anonymousCode;
@NotEmpty(message = "加密數(shù)據(jù)不能為空")
@ApiModelProperty(value="加密數(shù)據(jù)",name="encryptedData")
private String encryptedData;
@NotEmpty(message = "加密初始向量不能為空")
@ApiModelProperty(value="加密初始向量",name="iv")
private String iv;
}
3.2 抖音接口SPI
TiktokUserInfoSPI 主要是對(duì)接口的封裝
public interface TiktokUserInfoSPI {
/**
* 解密敏感數(shù)據(jù)獲取手機(jī)號(hào)
* @param param
* @return
*/
PhoneResult decryptUserPhone(TiktokEncryptedParam param);
/**
* 通過(guò)login接口獲取到登錄憑證后,開(kāi)發(fā)者可以通過(guò)服務(wù)器發(fā)送請(qǐng)求的方式獲取 session_key 和 openId。
* @param code
* @return
*/
Code2SessionResult code2Session(String code);
}
TiktokUserInfoSPIAdapter 實(shí)現(xiàn)接口
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("開(kāi)始進(jìn)行數(shù)據(jù)解密------- param = [{}]" , JSONUtil.toJsonStr(param));
String jsonString = DecryptUtil.decrypt(param.getEncryptedData(), result.getSessionKey(), param.getIv());
log.info("解密后的數(shù)據(jù)為------- 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) {
// 構(gòu)造參數(shù)
Map<String, Object> paramMap = new HashMap<>();
paramMap.put("appid", appId);
paramMap.put("secret", secret);
paramMap.put("code", code);
// 發(fā)送請(qǐng)求
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);
}
// 解析結(jié)果
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錯(cuò)誤,可能是登錄失效
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對(duì)稱(chēng)加密
import cn.hutool.crypto.symmetric.AES;
import com.tiktokminiprogram.exception.BusinessException;
import lombok.extern.slf4j.Slf4j;
import java.util.Base64;
/**
* @ClassName : DecryptUtil
* @Description : 解密工具類(lèi)
*/
@Slf4j
public class DecryptUtil {
/**
* 解密敏感數(shù)據(jù)
* @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("解密出現(xiàn)錯(cuò)誤", e);
throw new BusinessException("解密出現(xiàn)錯(cuò)誤");
}
}
}