小程序授权登录
目录
一、获取unionId
- 通过 wx.login 接口获得临时登录凭证 code 后传到开发者服务器调用此接口完成登录流程
请求地址
GET https://api.weixin.qq.com/sns/jscode2session?appid=APPID&secret=SECRET&js_code=JSCODE&grant_type=authorization_code
请求参数
属性 类型 默认值 必填 说明 appid string 是 小程序 appId secret string 是 小程序 appSecret js_code string 是 登录时获取的 code grant_type string 是 授权类型,此处只需填写 authorization_code 返回值
Object
返回的 JSON 数据包
属性 类型 说明 openid string 用户唯一标识 session_key string 会话密钥 unionid string 用户在开放平台的唯一标识符,在满足 UnionID 下发条件的情况下会返回,详见 UnionID 机制说明。 errcode number 错误码 errmsg string 错误信息
2.用户未关注公众号等相关主体账号时,通过code获取不到unionId时,则通过session_key、encryptedData、iv获取unionId
encryptedData | string | 包括敏感数据在内的完整用户信息的加密数据 |
iv | string | 加密算法的初始向量 |
接口如果涉及敏感数据(如wx.getUserInfo当中的 openId 和 unionId),接口的明文内容将不包含这些敏感数据。开发者如需要获取敏感数据,需要对接口返回的加密数据(encryptedData) 进行对称解密。 解密算法如下:
- 对称解密使用的算法为 AES-128-CBC,数据采用PKCS#7填充。
- 对称解密的目标密文为 Base64_Decode(encryptedData)。
- 对称解密秘钥 aeskey = Base64_Decode(session_key), aeskey 是16字节。
- 对称解密算法初始向量 为Base64_Decode(iv),其中iv由数据接口返回。
如接口 wx.getUserInfo 敏感数据当中的 watermark:
{
"openId": "OPENID",
"nickName": "NICKNAME",
"gender": GENDER,
"city": "CITY",
"province": "PROVINCE",
"country": "COUNTRY",
"avatarUrl": "AVATARURL",
"unionId": "UNIONID",
"watermark":
{
"appid":"APPID",
"timestamp":TIMESTAMP
}
}
a.首先通过code获取到session_key
b.通过session_key解密,从解密后的接送中提取unionId
小程序前端传递对象
@Data
@ApiModel(value = "MiniProgramRequest",description = "小程序加密数据")
public class MiniProgramRequest {
@ApiModelProperty(value = "前端code")
private String code;
@ApiModelProperty(value = "加密数据")
private String encryptedData;
@ApiModelProperty(value = "加密向量")
private String iv;
@ApiModelProperty(value = "openId")
private String openId;
}
/**
* 获取unionId
* @param code
* @return unionId
*/
public static String getUnionId(String code,String encryptedData,String iv){
String result = WebUtils.get(ACCESS_TOKEN_REQUEST_URL +"?appid=" + WX_LITE_APP_ID + "&secret=" + WX_LITE_APP_SECRET + "&js_code="+code+"&grant_type=authorization_code" ,null);
JsonObject jsonObject = GsonUtil.fromObject(result);
String unionId = "";
//用户授权直接获取unionId
if(result.contains("unionid")){
unionId = jsonObject.get("unionid").getAsString();
}
//用户未关注公众号等主体账号时,通过session_key解密获取unionId
if (result.contains("session_key")){
String sessionKey = jsonObject.get("session_key").getAsString();
String s = AESDescryptUtil.decrypt(encryptedData, sessionKey, iv);
JsonObject userInfoJson = GsonUtil.fromObject(s);
unionId = userInfoJson.get("unionId").getAsString();
}
return unionId;
}
解密代码
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Base64;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import javax.crypto.Cipher;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.security.NoSuchAlgorithmException;
import java.security.Security;
import java.util.Arrays;
/**
* @Author: sean.xu
* @Date: 2019/12/4
*/
@Slf4j
public class AESDescryptUtil {
// 算法名称
private static final String KEY_ALGORITHM = "AES";
/**
* 加解密算法/模式/填充方式
*/
private static final String algorithmStr = "AES/CBC/PKCS7Padding";
private static Key key;
private static Cipher cipher;
public static String encrypt(byte[] originalContent, byte[] encryptKey, byte[] ivByte) {
try {
encryptKey = Base64.decodeBase64(encryptKey);
ivByte = Base64.decodeBase64(ivByte);
init(encryptKey);
cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(ivByte));
byte[] encrypted = cipher.doFinal(originalContent);
return new String(Base64.encodeBase64(encrypted), StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static void init(byte[] keyBytes) {
// 如果密钥不足16位,那么就补足. 这个if 中的内容很重要
int base = 16;
if (keyBytes.length % base != 0) {
int groups = keyBytes.length / base + (keyBytes.length % base != 0 ? 1 : 0);
byte[] temp = new byte[groups * base];
Arrays.fill(temp, (byte) 0);
System.arraycopy(keyBytes, 0, temp, 0, keyBytes.length);
keyBytes = temp;
}
// 初始化
Security.addProvider(new BouncyCastleProvider());
// 转化成JAVA的密钥格式
key = new SecretKeySpec(keyBytes, KEY_ALGORITHM);
try {
// 初始化cipher
cipher = Cipher.getInstance(algorithmStr);
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
log.error("AES加密算法出错",e);
}
}
/**
* 解密方法
* @param encryptedDataStr
* @param keyBytesStr
* @param ivStr
* @return
*/
public static String decrypt(String encryptedDataStr, String keyBytesStr, String ivStr) {
byte[] encryptedText = null;
byte[] encryptedData;
byte[] sessionkey;
byte[] iv;
try {
sessionkey = Base64.decodeBase64(keyBytesStr);
encryptedData = Base64.decodeBase64(encryptedDataStr);
iv = Base64.decodeBase64(ivStr);
if (StringUtils.isNotEmpty(sessionkey)){
init(sessionkey);
cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv));
encryptedText = cipher.doFinal(encryptedData);
}
} catch (Exception e) {
log.error("AES解密算法出错",e);
}
assert encryptedText != null;
return new String(encryptedText,StandardCharsets.UTF_8);
}
}
二、获取session_key
- 小程序点击个人中心,首先会调getSessionKey,后端通过传递的code获取到session_key保存到redis,用于后续获取手机号
- session_key的过期时间通过小程序的checkSession检查,不需要后端做任何操作,获取到的session_key也不需要返还给小程序,只需自己保存即可
public static String getSessionKey(MiniProgramRequest miniProgram){
String result = WebUtils.get(ACCESS_TOKEN_REQUEST_URL +"?appid=" + WX_LITE_APP_ID + "&secret=" + WX_LITE_APP_SECRET + "&js_code="+miniProgram.getCode()+"&grant_type=authorization_code" ,null);
JsonObject jsonObject = GsonUtil.fromObject(result);
String sessionKey =null;
if(result.contains("session_key")) {
sessionKey = jsonObject.get("session_key").getAsString();
RedisUtils.set(SESSION_KEY + miniProgram.getOpenId(),sessionKey);
}
return sessionKey;
}
三、获取手机号
文档:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/getPhoneNumber.html
- 小程序调getPhoneNumber,传给后端encryptedData,iv。后端通过之前redis保存的session_key解密数据
-
解密坑很多(前端必须将传递的数据通过encode处理,防止数据传递丢失,同时encryptedData,iv必须保证16个字节长度)
-
解密成功返给前端手机号,前端拿到手机号后通过手机号授权快速登录
获取得到的开放数据为以下 json 结构:
{
"phoneNumber": "13580006666",
"purePhoneNumber": "13580006666",
"countryCode": "86",
"watermark":
{
"appid":"APPID",
"timestamp": TIMESTAMP
}
}
参数 | 类型 | 说明 |
---|---|---|
phoneNumber | String | 用户绑定的手机号(国外手机号会有区号) |
purePhoneNumber | String | 没有区号的手机号 |
countryCode | String | 区号 |
/**
* 获取手机号
* 如果前端传code,则通过code请求小程序接口获取新的session_key
* 如果前端没有传code,则获取之前redis保存的session_key
* @return
*/
public static String getPhoneNumber(MiniProgramRequest miniProgram){
String sessionKey = (String)RedisUtils.get(SESSION_KEY + miniProgram.getOpenId());
String phoneNumber = null;
if (StringUtils.isNotEmpty(sessionKey)){
String s = AESDescryptUtil.decrypt(miniProgram.getEncryptedData(), sessionKey, miniProgram.getIv());
JsonObject userInfoJson = GsonUtil.fromObject(s);
phoneNumber = userInfoJson.get("phoneNumber").getAsString();
}
return phoneNumber;
}
四、小程序通过手机号快速授权登录
- 通过手机号查询会员(手机号为之前解密传给前端的手机号)
- 会员存在,直接将会员数据返回给小程序
- 会员不存在,直接注册新会员,并将数据返回给小程序(小程序快捷登录的不绑定第三方)
五、开发中的坑
- 前端传过来的code,通过后台请求微信接口的时候,code只能用一次,重复使用小程序报错msg:code been used, hints
- 获取手机号的时候,需要用到session_key,在刚开始通过code获取到session_key的时候,可以放到redis里,等下次用的时候直接在redis拿,切忌不可将session_key返回前端然后在获取手机号的时候通过前端传递
- 保存session_key的到redis时候,一定要记得redis的key要唯一,最开始我就是直接保存的,导致多个用户同时登录时候,只有一个能登录成功,其实是因为他们共用了同一个session_key。一定要加以区分,可以把openId传过来放入key中作为唯一约束
- 也许前端生成的加密数据和加密向量是正常的,直接复制给我们调微信接口可以获取到数据,但是在传递的时候可能会丢失字符,比如%等字符会解析成其他字符,可通过base64处理