知我者,谓我心忧,不知我者,谓我何求。
前言
在使用微信小程序有许多时候需要使用到申请用户手机号码的功能。我们可以通过微信小程序的getPhoneNumber组件快速获取用户手机号。
官方文档
以下为根据官方文档的简要介绍,想要查看完整内容可点击完整官方文档。
获取手机号
- 获取微信用户绑定的手机号,需先调用wx.login接口。
- 因为需要用户主动触发才能发起获取手机号接口,所以该功能不由API来调用,需用 button组件的点击来触发。
- 目前该接口针对非个人开发者,且完成了认证的小程序开放(不包含海外主体)。需谨慎使用,若用户举报较多或被发现在不必要场景下使用,微信有权永久回收该小程序的该接口权限。
使用方法
需要将 button 组件 open-type 的值设置为 getPhoneNumber,当用户点击并同意之后,可以通过 bindgetphonenumber 事件回调获取到微信服务器返回的加密数据(encryptedData、iv), 然后在第三方服务端结合 session_key 以及 app_id 进行解密获取手机号。
小程序登录
小程序可以通过微信官方提供的登录能力方便地获取微信提供的用户身份标识,快速建立小程序内的用户体系。
登录流程时序图
说明:
- 调用 wx.login() 获取 临时登录凭证code ,并回传到开发者服务器(即自己的后台服务器)。
- 调用 auth.code2Session 接口,换取用户唯一标识OpenID和会话密钥session_key等信息。
- 之后开发者服务器可以根据用户标识来生成自定义登录态,用于后续业务逻辑中前后端交互时识别用户身份。
auth.code2Session 接口请求地址:
GET https://api.weixin.qq.com/sns/jscode2session?appid=APPID&secret=SECRET&js_code=JSCODE&grant_type=authorization_code
auth.code2Session 接口请求参数:
auth.code2Session 接口返回值:
注意:
会话密钥session_key是对用户数据进行加密签名的密钥。为了应用自身的数据安全,开发者服务器不应该把会话密钥下发到小程序,也不应该对外提供这个密钥。临时登录凭证 code 只能使用一次。
服务端获取开放数据
开发者后台校验与解密开放数据
微信会对这些开放数据(即微信用户私人信息)做签名和加密处理。开发者后台拿到开放数据后可以对数据进行校验签名和解密,来保证数据不被篡改。
签名校验以及数据加解密涉及用户的会话密钥 session_key。 开发者应该事先通过 wx.login 登录流程获取会话密钥 session_key 并保存在服务器。为了数据不被篡改,开发者不应该把 session_key 传到小程序客户端等服务器外的环境。
数据签名校验
- 为了确保开放接口返回用户数据的安全性,微信会对明文数据进行签名。开发者可以根据业务需要对数据包进行签名校验,确保数据的完整性。
- 通过调用接口(如 wx.getUserInfo)获取数据时,接口会同时返回 rawData、signature,其中 signature
= sha1( rawData + session_key )开发者将 signature、rawData 发送到开发者服务器进行校验。服务器利用用户对应的 session_key 使用相同的算法计算出签名 signature2 ,比对signature 与 signature2 即可校验数据的完整性。
加密数据解密算法
接口如果涉及敏感数据(如wx.getUserInfo当中的 openId 和 unionId),接口的明文内容将不包含这些敏感数据。开发者如需要获取敏感数据,需要对接口返回的加密数据(encryptedData) 进行对称解密。 解密算法如下:
- 对称解密使用的算法为 AES-128-CBC,数据采用PKCS#7填充。
- 对称解密的目标密文为 Base64_Decode(encryptedData)。
- 对称解密秘钥 aeskey =Base64_Decode(session_key), aeskey 是16字节。
- 对称解密算法初始向量为Base64_Decode(iv),其中iv由数据接口返回。
- 另外,为了应用能校验数据的有效性,会在敏感数据加上数据水印( watermark )
会话密钥 session_key 有效性
开发者如果遇到因为 session_key 不正确而校验签名失败或解密失败,请关注下面几个与 session_key 有关的注意事项。
- wx.login 调用时,用户的 session_key 可能会被更新而致使旧 session_key失效(刷新机制存在最短周期,如果同一个用户短时间内多次调用 wx.login,并非每次调用都导致 session_key刷新)。开发者应该在明确需要重新登录时才调用 wx.login,及时通过 auth.code2Session 接口更新服务器存储session_key。
- 微信不会把 session_key 的有效期告知开发者。我们会根据用户使用小程序的行为对 session_key进行续期。用户越频繁使用小程序,session_key 有效期越长。
- 开发者在 session_key失效时,可以通过重新执行登录流程获取有效的 session_key。使用接口 wx.checkSession可以校验session_key 是否有效,从而避免小程序反复执行登录流程。
- 当开发者在实现自定义登录态时,可以考虑以 session_key有效期作为自身登录态有效期,也可以实现自定义的时效性策略
大致思路
- 小程序前端按照官方文档给出的规则,获取code、iv(加密算法的初始向量)、encryptedData(用户信息的加密数据)。
- 前端发送请求至开发者服务器,请求体中包含以上三个参数。
- 后端接收到数据后,首先拿到code。根据code和配置文件中的appid、appsecret三个参数构建发往微信服务器(即 auth.code2Session 接口)的URL(https://api.weixin.qq.com/sns/jscode2session?appid=APPID&secret=SECRET&js_code=JSCODE&grant_type=authorization_code)。
- 发送请求后得到sessionKey(微信会话密钥)。而后在通过sessionKey及前端发送来的iv、encryptedData进行对用户信息(encryptedData)进行解密。
代码实现
项目结构
Controller
package demowechatgetphonenumber.controller;
import demowechatgetphonenumber.controller.dto.WeChatLoginDTO;
import demowechatgetphonenumber.entity.WeChatEntity;
import demowechatgetphonenumber.util.WechatApiProxy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;
@RestController
@CrossOrigin
public class DemoGetPhoneController {
private static final Logger logger= LoggerFactory.getLogger(DemoGetPhoneController.class);
/**
* 获取当前微信用户电话号码。
* @param wxLoginDTO
*
*/
@PostMapping("get-phonenumber")
public void getPhoneNumber(@RequestBody WeChatLoginDTO wxLoginDTO) {
//获取用户登录凭证code(有效期五分钟)。
String code = wxLoginDTO.getCode();
//通过code等条件请求得到sessionKey。
WeChatEntity entity = WechatApiProxy.getWXEntityByCode(code);
logger.info("WxEntity is {}", entity.toString());
//通过sessionKey、iv解密encryptedData得到电话号码。
WechatApiProxy.WxEncryptedPhoneNumber info =
WechatApiProxy.decrypt(
wxLoginDTO.getEncryptedData(), entity.getSessionKey(), wxLoginDTO.
getIv(), WechatApiProxy.WxEncryptedPhoneNumber.class);
logger.info("PhoneNumber is {}", info.getPhoneNumber());
}
}
WeChatLoginDTO
package demowechatgetphonenumber.controller.dto;
/**
* @author Chained1001
* @date 2020-07-02 13:25
*/
public class WeChatLoginDTO {
//encryptedData:用户信息的加密数据(如果用户没有同意授权同样返回undefined)。
private String encryptedData;
//iv:加密算法的初始向量(如果用户没有同意授权则为undefined)。
private String iv;
//code:用户登录凭证(有效期五分钟)。
private String code;
public String getEncryptedData() {
return encryptedData;
}
public void setEncryptedData(String encryptedData) {
this.encryptedData = encryptedData;
}
public String getIv() {
return iv;
}
public void setIv(String iv) {
this.iv = iv;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
}
WeChatEntity
package demowechatgetphonenumber.entity;
import com.alibaba.fastjson.JSON;
/**
* 微信的一些信息,事例中主要使用sessionKey。
* @author Chained1001
*/
public class WeChatEntity {
/**
* 微信会话密钥。
*/
private String sessionKey;
/**
* 微信用户在小程序内的唯一标志。
*/
private String openId;
/**
* 用户在开放平台的唯一标识符。
*/
private String unionId;
private String errcode;
private String errmsg;
public String getErrcode() {
return errcode;
}
public void setErrcode(String errcode) {
this.errcode = errcode;
}
public String getErrmsg() {
return errmsg;
}
public void setErrmsg(String errmsg) {
this.errmsg = errmsg;
}
public String getSessionKey() {
return sessionKey;
}
public void setSessionKey(String sessionKey) {
this.sessionKey = sessionKey;
}
public String getOpenId() {
return openId;
}
public void setOpenId(String openId) {
this.openId = openId;
}
public String getUnionId() {
return unionId;
}
public void setUnionId(String unionId) {
this.unionId = unionId;
}
@Override
public String toString() {
return JSON.toJSONString(this);
}
}
WechatApiProxy
package demowechatgetphonenumber.util;
import com.alibaba.fastjson.JSON;
import demowechatgetphonenumber.entity.WeChatEntity;
import org.apache.xmlbeans.impl.util.Base64;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.AlgorithmParameters;
import java.security.Security;
import java.util.Arrays;
import java.util.Date;
/**
* 与微信交互的一些api。
* @author Chained1001
*/
@Component
@Import(value = {WeChatConfig.class})
public class WechatApiProxy {
// 算法名。
private static final String KEY_NAME = "AES";
// 加解密算法/模式/填充方式。
// ECB模式只用密钥即可对数据进行加密解密,CBC模式需要添加一个iv。
private static final String CIPHER_ALGORITHM = "AES/CBC/PKCS7Padding";
private static WechatApiProxy wechatApiProxy;
private static final Integer INVALID_ACCESS_TOEKN = 40001;
@Autowired
private WeChatConfig weChatConfig;
@PostConstruct
public void init() {
wechatApiProxy = this;
wechatApiProxy.weChatConfig = this.weChatConfig;
}
private static Logger logger = LoggerFactory.getLogger(WechatApiProxy.class);
/**
*
*
* @param code
*/
public static WeChatEntity getWXEntityByCode(String code){
//通过code、appid、appsecret构建访问微信服务器的地址。
String url=getJscode2sessionUrl(code);
//发送请求到微信服务器,得到sessionKey。
String str = HttpRequester.doGet(url, null);
//将sessionKey放入到WeChatEntity对象中。
WeChatEntity entity = JSON.parseObject(str, WeChatEntity.class);
return entity;
}
/**
* 构建实际访问的微信服务器url。
* @param code
* @return url
*/
private static String getJscode2sessionUrl(String code) {
WeChatConfig config = wechatApiProxy.weChatConfig;
if (null == config.getJscode2sessionUrl()) {
return null;
}
//url=profile.wechat.jscode2session=
// https://api.weixin.qq.com/sns/jscode2session?
// appid=APPID&secret=SECRET&js_code=JSCODE
// &grant_type=authorization_code
String url = config.getJscode2sessionUrl()
.replace("APPID", config.getAppid())
.replace("SECRET", config.getAppsecret())
.replace("JSCODE", code);
logger.info("wx jscode2session url is {}", url);
return url;
}
/**
* 解密方法。
* @param encryptedData
* @param sessionKey
* @param iv
* @param t
* @param <T>
* @return
*/
public static <T> T decrypt(String encryptedData, String sessionKey, String iv, Class<T> t) {
byte[] dataByte = Base64.decode(encryptedData.getBytes(StandardCharsets.UTF_8));
// 加密秘钥
byte[] keyByte = Base64.decode(sessionKey.getBytes(StandardCharsets.UTF_8));
// 偏移量
byte[] ivByte = Base64.decode(iv.getBytes(StandardCharsets.UTF_8));
try {
// 如果密钥不足16位,那么就补足,这个if 中的内容很重要。
int base = 16;
if (keyByte.length % base != 0) {
int groups = keyByte.length / base + 1;
byte[] temp = new byte[groups * base];
Arrays.fill(temp, (byte) 0);
System.arraycopy(keyByte, 0, temp, 0, keyByte.length);
keyByte = temp;
}
// 初始化
Security.addProvider(new BouncyCastleProvider());
Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
SecretKeySpec spec = new SecretKeySpec(keyByte, KEY_NAME);
AlgorithmParameters parameters = AlgorithmParameters.getInstance(KEY_NAME);
parameters.init(new IvParameterSpec(ivByte));
// 初始化
cipher.init(Cipher.DECRYPT_MODE, spec, parameters);
byte[] resultByte = cipher.doFinal(dataByte);
if (null != resultByte && resultByte.length > 0) {
String result = new String(resultByte, StandardCharsets.UTF_8);
return JSON.parseObject(result, t);
}
} catch (Exception e) {
logger.error("cipher error: {}, {}, {}", encryptedData, sessionKey, iv, e);
}
return null;
}
public static class WxEncryptedPhoneNumber {
private String phoneNumber;
private String purePhoneNumber;
private String countryCode;
public String getPhoneNumber() {
return phoneNumber;
}
public void setPhoneNumber(String phoneNumber) {
this.phoneNumber = phoneNumber;
}
public String getPurePhoneNumber() {
return purePhoneNumber;
}
public void setPurePhoneNumber(String purePhoneNumber) {
this.purePhoneNumber = purePhoneNumber;
}
public String getCountryCode() {
return countryCode;
}
public void setCountryCode(String countryCode) {
this.countryCode = countryCode;
}
}
}
MapUtils
package demowechatgetphonenumber.util;
import com.alibaba.fastjson.JSON;
import java.text.NumberFormat;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class MapUtils {
public static <K, V> Map<K, V> of(K k1, V v1) {
Map<K, V> map = newHashMap();
map.put(k1, v1);
return map;
}
public static <K, V> Map<K, V> of(K k1, V v1, K k2, V v2) {
Map<K, V> map = of(k1, v1);
map.put(k2, v2);
return map;
}
public static <K, V> Map<K, V> of(K k1, V v1, K k2, V v2, K k3, V v3) {
Map<K, V> map = of(k1, v1, k2, v2);
map.put(k3, v3);
return map;
}
public static <K, V> Map<K, V> of(K k1, V v1, K k2, V v2, K k3, V v3, K k4, V v4) {
Map<K, V> map = of(k1, v1, k2, v2, k3, v3);
map.put(k4, v4);
return map;
}
public static <K, V> Map<K, V> of(K k1, V v1, K k2, V v2, K k3, V v3, K k4, V v4, K k5, V v5) {
Map<K, V> map = of(k1, v1, k2, v2, k3, v3, k4, v4);
map.put(k5, v5);
return map;
}
public static <T> Map<T, T> of(T... keyAndValues) {
Map<T, T> map = newHashMap();
for (int i = 0; i < keyAndValues.length; i += 2) {
T key = keyAndValues[i];
T value = i + 1 < keyAndValues.length ? keyAndValues[i + 1] : null;
map.put(key, value);
}
return map;
}
public static Map<Object, Object> asMap(Object... keyAndValues) {
Map<Object, Object> map = newHashMap();
for (int i = 0; i < keyAndValues.length; i += 2) {
Object key = keyAndValues[i];
Object value = i + 1 < keyAndValues.length ? keyAndValues[i + 1] : null;
map.put(key, value);
}
return map;
}
public static <K, V> Map<K, V> newHashMap() {
return new HashMap<K, V>();
}
public static boolean isEmpty(Map<?, ?> map) {
return map == null || map.isEmpty();
}
public static String getStr(Map m, Object key) {
return getStr(m, key, null);
}
public static String getStr(Map m, Object key, String defaultValue) {
if (m == null) return defaultValue;
Object value = m.get(key);
if (value == null) return defaultValue;
return value.toString();
}
public static Number getNum(Map m, Object key) {
if (m == null) return null;
Object value = m.get(key);
if (value == null) return null;
if (value instanceof Number) return (Number) value;
if (!(value instanceof String)) return null;
try {
return NumberFormat.getInstance().parse((String) value);
} catch (ParseException e) {
throw new RuntimeException(e);
}
}
public static Integer getInt(Map m, Object key) {
Number value = getNum(m, key);
if (value == null) return null;
return value instanceof Integer ? (Integer) value : new Integer(value.intValue());
}
public static Map toMap(Object bean) {
if (bean == null) {
return of();
}
return JSON.parseObject(JSON.toJSONString(bean), Map.class);
}
public static <K> List<K> getListForce(Map m, Object key) {
if (m == null) return null;
Object value = m.get(key);
if (value == null) {
List<K> list = new ArrayList<K>();
m.put(key, list);
return list;
}
if (value instanceof List)
return (List<K>) value;
if (!(value instanceof List))
throw new RuntimeException("Cannot Parse Object To List");
return null;
}
public static <K, V> Map<K, V> getMapForce(Map m, Object key) {
if (m == null) return null;
Object value = m.get(key);
if (value == null) {
Map<K, V> ret = new HashMap<K, V>();
m.put(key, ret);
return ret;
}
if (value instanceof Map)
return (Map<K, V>) value;
if (!(value instanceof Map))
throw new RuntimeException("Cannot Parse Object To Map");
return null;
}
}
HttpRequester
package demowechatgetphonenumber.util;
import org.apache.commons.io.IOUtils;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.Map;
/**
* @author Chained1001
*/
public class HttpRequester {
private static Logger logger = LoggerFactory.getLogger(HttpRequester.class);
public static String doGet(String url, Map<String, String> getParams) {
CloseableHttpClient client = null;
try {
StringBuilder sb = new StringBuilder(url);
if (!MapUtils.isEmpty(getParams)) {
for (Map.Entry<String, String> entry : getParams.entrySet()) {
sb.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
}
}
logger.info("doGet() params is {}", sb.toString());
client = HttpClients.createDefault();
HttpGet httpGet = new HttpGet(sb.toString());
CloseableHttpResponse response = client.execute(httpGet);
String result = EntityUtils.toString(response.getEntity(), Charset.forName("UTF-8"));
return result;
} catch (IOException e) {
e.printStackTrace();
return null;
} finally {
IOUtils.closeQuietly(client);
}
}
}
WeChatConfig
package demowechatgetphonenumber.util;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
/**
* @author Chained1001
*/
@Configuration
public class WeChatConfig {
@Value("${profile.wechat.appid}")
private String appid;
@Value("${profile.wechat.appsecret}")
private String appsecret;
@Value("${profile.wechat.jscode2session}")
private String jscode2sessionUrl;
@Value("${profile.wechat.accessToken}")
private String accessTokenUrl;
public String getAppid() {
return appid;
}
public void setAppid(String appid) {
this.appid = appid;
}
public String getAppsecret() {
return appsecret;
}
public void setAppsecret(String appsecret) {
this.appsecret = appsecret;
}
public String getJscode2sessionUrl() {
return jscode2sessionUrl;
}
public void setJscode2sessionUrl(String getJscode2sessionUrl) {
this.jscode2sessionUrl = getJscode2sessionUrl;
}
public String getAccessTokenUrl() {
return accessTokenUrl;
}
public void setAccessTokenUrl(String accessTokenUrl) {
this.accessTokenUrl = accessTokenUrl;
}
}