上图是微信开发文档提供的图。
最近开发一款小程序,看了许久的微信文档,这里来记录一下其中的登录与授权过程。
总体流程:
接下来看几个官方的文档:
本接口从基础库版本 2.3.1 起支持在小程序插件中使用
调用接口获取登录凭证(code)。通过凭证进而换取用户登录态信息,包括用户的唯一标识(openid)及本次登录的会话密钥(session_key)等。用户数据的加解密通讯需要依赖会话密钥完成。更多使用方法详见 小程序登录。
在小程序插件中使用时,需要在用户信息功能页中获得用户授权之后调用。否则将返回 fail。详见 用户信息功能页
属性 | 类型 | 默认值 | 必填 | 说明 | 最低版本 |
---|---|---|---|---|---|
timeout | number | 否 | 超时时间,单位ms | 1.9.90 | |
success | function | 否 | 接口调用成功的回调函数 | ||
fail | function | 否 | 接口调用失败的回调函数 | ||
complete | function | 否 | 接口调用结束的回调函数(调用成功、失败都会执行) |
参数
属性 | 类型 | 说明 |
---|---|---|
code | string | 用户登录凭证(有效期五分钟)。开发者需要在开发者服务器后台调用 auth.code2Session,使用 code 换取 openid 和 session_key 等信息 |
wx.login({
success (res) {
if (res.code) {
//发起网络请求
wx.request({
url: 'https://test.com/onLogin',
data: {
code: res.code
}
})
} else {
console.log('登录失败!' + res.errMsg)
}
}
})
本接口应在服务器端调用,详细说明参见服务端API。
登录凭证校验。通过 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 |
返回的 JSON 数据包
属性 | 类型 | 说明 |
---|---|---|
openid | string | 用户唯一标识 |
session_key | string | 会话密钥 |
unionid | string | 用户在开放平台的唯一标识符,在满足 UnionID 下发条件的情况下会返回,详见 UnionID 机制说明。 |
errcode | number | 错误码 |
errmsg | string | 错误信息 |
值 | 说明 | 最低版本 |
---|---|---|
-1 | 系统繁忙,此时请开发者稍候再试 | |
0 | 请求成功 | |
40029 | code 无效 | |
45011 | 频率限制,每个用户每分钟100次 |
检查登录态是否过期。
通过 wx.login 接口获得的用户登录态拥有一定的时效性。用户越久未使用小程序,用户登录态越有可能失效。反之如果用户一直在使用小程序,则用户登录态一直保持有效。具体时效逻辑由微信维护,对开发者透明。开发者只需要调用 wx.checkSession 接口检测当前用户登录态是否有效。
登录态过期后开发者可以再调用 wx.login 获取新的用户登录态。调用成功说明当前 session_key 未过期,调用失败说明 session_key 已过期。更多使用方法详见 小程序登录。
属性 | 类型 | 默认值 | 必填 | 说明 |
---|---|---|---|---|
success | function | 否 | 接口调用成功的回调函数 | |
fail | function | 否 | 接口调用失败的回调函数 | |
complete | function | 否 | 接口调用结束的回调函数(调用成功、失败都会执行) |
wx.checkSession({
success () {
//session_key 未过期,并且在本生命周期一直有效
},
fail () {
// session_key 已经失效,需要重新执行登录流程
wx.login() //重新登录
}
})
开发者如果遇到因为 session_key 不正确而校验签名失败或解密失败,请关注下面几个与 session_key 有关的注意事项。
本接口应在服务器端调用,详细说明参见服务端API。
校验服务器所保存的登录态 session_key
是否合法。为了保持 session_key
私密性,接口不明文传输 session_key
,而是通过校验登录态签名完成。
GET https://api.weixin.qq.com/wxa/checksession?access_token=ACCESS_TOKEN&signature=SIGNATURE&openid=OPENID&sig_method=SIG_METHOD
属性 | 类型 | 默认值 | 必填 | 说明 |
---|---|---|---|---|
access_token | string | 是 | 接口调用凭证 | |
openid | string | 是 | 用户唯一标识符 | |
signature | string | 是 | 用户登录态签名 | |
sig_method | string | 是 | 用户登录态签名的哈希方法,目前只支持 hmac_sha256 |
返回的 JSON 数据包
属性 | 类型 | 说明 |
---|---|---|
errcode | number | 错误码 |
errmsg | string | 错误信息 |
errcode 的合法值
值 | 说明 | 最低版本 |
---|---|---|
0 | ok 请求成功 | |
87009 | invalid signature 签名错误 |
curl -G 'https://api.weixin.qq.com/wxa/checksession?access_token=OsAoOMw4niuuVbfSxxxxxxxxxxxxxxxxxxx&signature=fefce01bfba4670c85b228e6ca2b493c90971e7c442f54fc448662eb7cd72509&openid=oGZUI0egBJY1zhBYw2KhdUfwVJJE&sig_method=hmac_sha256'
正确时的返回JSON数据包如下:
{"errcode": 0, "errmsg": "ok"}
错误时的返回JSON数据包如下(示例为签名错误):
{"errcode": 87009, "errmsg": "invalid signature"}
基础库 1.2.0 开始支持,低版本需做兼容处理。
提前向用户发起授权请求。调用后会立刻弹窗询问用户是否同意授权小程序使用某项功能或获取用户的某些数据,但不会实际调用对应接口。如果用户之前已经同意授权,则不会出现弹窗,直接返回成功。更多用法详见 用户授权。 > 小程序插件可以使用 wx.authorizeForMiniProgram
属性 | 类型 | 默认值 | 必填 | 说明 |
---|---|---|---|---|
scope | string | 是 | 需要获取权限的 scope,详见 scope 列表 | |
success | function | 否 | 接口调用成功的回调函数 | |
fail | function | 否 | 接口调用失败的回调函数 | |
complete | function | 否 | 接口调用结束的回调函数(调用成功、失败都会执行) |
// 可以通过 wx.getSetting 先查询一下用户是否授权了 "scope.record" 这个 scope
wx.getSetting({
success(res) {
if (!res.authSetting['scope.record']) {
wx.authorize({
scope: 'scope.record',
success () {
// 用户已经同意小程序使用录音功能,后续调用 wx.startRecord 接口不会弹窗询问
wx.startRecord()
}
})
}
}
})
部分接口需要经过用户授权同意才能调用。我们把这些接口按使用范围分成多个 scope
,用户选择对 scope
来进行授权,当授权给一个 scope
之后,其对应的所有接口都可以直接使用。
此类接口调用时:
fail
回调。请开发者兼容用户拒绝授权的场景。开发者可以使用 wx.getSetting 获取用户当前的授权状态。
用户可以在小程序设置界面(「右上角」 - 「关于」 - 「右上角」 - 「设置」)中控制对该小程序的授权状态。
开发者可以调用 wx.openSetting 打开设置界面,引导用户开启授权。
开发者可以使用 wx.authorize 在调用需授权 API 之前,提前向用户发起授权请求。
scope | 对应接口 | 描述 |
---|---|---|
scope.userInfo | wx.getUserInfo | 用户信息 |
scope.userLocation | wx.getLocation | 地理位置 |
scope.werun | wx.getWeRunData | 微信运动步数 |
scope.writePhotosAlbum | wx.saveImageToPhotosAlbum | 保存到相册 |
一旦用户明确同意或拒绝过授权,其授权关系会记录在后台,直到用户主动删除小程序。
在真正需要使用授权接口时,才向用户发起授权申请,并在授权申请中说明清楚要使用该功能的理由。
wx.authorize({scope: "scope.userInfo"})
,不会弹出授权窗口,请使用 wx.createUserInfoButtonscope.userLocation
时必须配置地理位置用途说明。我根据对上面文档的理解,重新介绍一下最开始的那幅图,以及我的解决方案:
- 为什么利用openid生产token?
session_key微信官方强烈要求不能返回给前端,如果使用session_key生产token,会有被破译token的危险,openid是用户唯一标识,比起session_key没那么重要,session_key还会用于对微信敏感数据进行解密,所以session_key很重要。- token时效怎么设置?
在生成token时,不设置失效时间,让token与session_key同步,前端每次进入小程序时使用wx.checkSession检验session_key是否失效,失效则重新登陆,生成新的session_key与token,token与session_key以及openid都被我存入了数据库中。- 每次生成的token一样?
前面说到了我是使用openid生成token的,那么如果每次的密文一样,则生成的token就会不变,这是可以考虑使用uuid作为密文,或者使用session_key作为密文。
授权过程:前端授权,存入用户信息进入数据库。(后端使用session_key验证数据一致性,一致则存入数据库,否则返回异常)
@Value("${wechat.appid}")
private String appid;
@Value("${wechat.secret}")
private String secret;
@Override
public String onLogin(String code) throws IOException, BaseException {
String url = "https://api.weixin.qq.com/sns/jscode2session" +
"?appid="+appid+"&secret="+secret+"&js_code="+code+"&grant_type=authorization_code";
String result = "";
BufferedReader in = null;
try {
URL url1 = new URL(url);
URLConnection urlConnection = url1.openConnection();
in = new BufferedReader(new InputStreamReader(urlConnection.getInputStream()));
String line;
while ((line = in.readLine()) != null) {
result += line;
}
} catch (Exception e) {
throw e;
} finally {
try {
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
JSON parse = JSONUtil.parse(result);
Integer errcode = parse.getByPath("errcode", Integer.class);
if (errcode == null){
// 用户唯一标识
String openid = parse.getByPath("openid", String.class);
// 会话密钥
String sessionKey = parse.getByPath("session_key", String.class);
// 用户在开放平台的唯一标识符,在满足 UnionID 下发条件的情况下会返回
String unionid = parse.getByPath("unionid", String.class);
// 通过oppenid与session_key计算token
String token = JwtUtils.getToken(openid, sessionKey);
SignaturePO signaturePO = signatureDAO.queryById(openid);
if (signaturePO != null){
// 该用户以及注册过了
// 更新session_key与token
signaturePO.setSessionKey(sessionKey);
signaturePO.setToken(token);
int update = signatureDAO.update(signaturePO);
if (update != 1){
throw new BaseException(500, "更新session_key与token失败");
}
return token;
}else {
// 该用户未被注册,将该用户的session_key与token添加到数据库
SignaturePO po = new SignaturePO();
po.setOpenid(openid);
po.setSessionKey(sessionKey);
po.setToken(token);
int insert = signatureDAO.insert(po);
if (insert != 1){
throw new BaseException(500, "更新session_key与token失败");
}
return token;
}
}else if (errcode == -1){
throw new BaseException(errcode, "系统繁忙,稍候再试");
}else if (errcode == 40029){
throw new BaseException(errcode, "code无效");
}else if (errcode == 45011){
throw new BaseException(errcode, "频率限制,每个用户每分钟100次");
}else {
throw new BaseException(500, "服务器异常");
}
}
@Component
public class JWTInterceptor implements HandlerInterceptor {
@Resource
private SignatureDAO signatureDAO;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("Authorization");
if (token != null && !"".equals(token)) {
// 查询数据库中是否有该token
SignaturePO signaturePO = signatureDAO.queryByToken(token);
if (signaturePO != null){
// 该token可以正常使用
request.setAttribute(SESSIONKEY, signaturePO.getSessionKey());
return true;
}else {
throw new BaseException(ResultEnum.CHECK_EROR);
}
}
return false;
}
}
注意:这里会出现依赖注入问题。解决办法
public static String getToken(String openId, String session_key) throws UnsupportedEncodingException {
String token = JWT.create()
.withKeyId(openId)
.withIssuer("weixin")
.withIssuedAt(new Date())
.withClaim("openid", openId)
.sign(Algorithm.HMAC256(session_key));
return token;
}
public static String getSha1(String str) {
char hexDigits[] = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'a', 'b', 'c', 'd', 'e', 'f' };
try {
MessageDigest mdTemp = MessageDigest.getInstance("SHA1");
mdTemp.update(str.getBytes("UTF-8"));
byte[] md = mdTemp.digest();
int j = md.length;
char buf[] = new char[j * 2];
int k = 0;
for (int i = 0; i < j; i++) {
byte byte0 = md[i];
buf[k++] = hexDigits[byte0 >>> 4 & 0xf];
buf[k++] = hexDigits[byte0 & 0xf];
}
return new String(buf);
} catch (Exception e) {
return null;
}
}
@Override
public void authorize(ResDTO resDTO, HttpServletRequest request) throws BaseException {
String sessionKey = JwtUtils.getSessionKey(request);
//signature = sha1( rawData + session_key )
String signature = resDTO.getSignature();
String signature2 = Sha1.getSha1(resDTO.getRawData() + sessionKey);
if (signature.equals(signature2)){
// 数据一致
String encryptedData = resDTO.getEncryptedData();
String iv = resDTO.getIv();
// 解密敏感数据
// 格式:
// {'openId': 'oGZUI0egBJY1zhBYw2KhdUfwVJJE',
// 'nickName': 'Band', 'gender': 1, 'language': 'zh_CN',
// 'city': 'Guangzhou', 'province': 'Guangdong', 'country': 'CN',
// 'avatarUrl': 'http://wx.qlogo.cn/mmopen/vi_32/aSKcBBPpibyKNicHNTMM0qJVh8Kjgiak2AHWr8MHM4WgMEm7GFhsf8OYrySdbvAMvTsw3mo8ibKicsnfN5pRjl1p8HQ/0',
// 'unionId': 'ocMvos6NjeKLIBqg5Mr9QjxrP1FA',
// 'watermark': {'timestamp': 1477314187, 'appid': 'wx4f4bc4dec97d474b'}}
JSONObject userInfoByEncryptedData = WXDecryptUtil.getUserInfoByEncryptedData(encryptedData, sessionKey, iv);
String openId = userInfoByEncryptedData.getString("openId");
UserInfoPO userInfoPO = resDTO.getUserInfoPO();
// 设置openid
userInfoPO.setOpenid(openId);
//将用户信息添加到数据库
int insert = userInfoDAO.insert(userInfoPO);
if (insert != 1){
//添加失败
throw new BaseException(500, "添加用户信息失败");
}
}else {
throw new BaseException(500, "数据不一致");
}
}
public class WXDecryptUtil {
public static JSONObject getUserInfoByEncryptedData(String encryptedData, String sessionKey, String iv){
// 被加密的数据
byte[] dataByte = Base64.decode(encryptedData);
// 加密秘钥
byte[] keyByte = Base64.decode(sessionKey);
// 偏移量
byte[] ivByte = Base64.decode(iv);
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("AES/CBC/PKCS7Padding","BC");
SecretKeySpec spec = new SecretKeySpec(keyByte, "AES");
AlgorithmParameters parameters = AlgorithmParameters.getInstance("AES");
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 JSONObject.parseObject(result);
}
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}