微信小程序Token登录验证

img

上图是微信开发文档提供的图。
最近开发一款小程序,看了许久的微信文档,这里来记录一下其中的登录与授权过程。
总体流程:

  1. 前端执行wx.login()获取code传给后端
  2. 后端通过微信官方的登录凭证校验接口获取到session_key与openid,将session_key与openid保存下来。然后自定义登录状态(一开始我也先不明白这里该怎么做,后面我会介绍我的做法,欢迎大佬指正)并返回给前端。
  3. 前端以后每次请求都会携带该自定义登录状态,后端进行登录状态的判断,正常就返回业务数据,否则重新登陆,获取新的登录状态。

接下来看几个官方的文档:

一、理论

1、前端:wx.login(Object object)

本接口从基础库版本 2.3.1 起支持在小程序插件中使用

调用接口获取登录凭证(code)。通过凭证进而换取用户登录态信息,包括用户的唯一标识(openid)及本次登录的会话密钥(session_key)等。用户数据的加解密通讯需要依赖会话密钥完成。更多使用方法详见 小程序登录。

在小程序插件中使用时,需要在用户信息功能页中获得用户授权之后调用。否则将返回 fail。详见 用户信息功能页

参数
Object object
属性 类型 默认值 必填 说明 最低版本
timeout number 超时时间,单位ms 1.9.90
success function 接口调用成功的回调函数
fail function 接口调用失败的回调函数
complete function 接口调用结束的回调函数(调用成功、失败都会执行)
object.success 回调函数

参数

Object res
属性 类型 说明
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)
    }
  }
})

2、后端:auth.code2Session

本接口应在服务器端调用,详细说明参见服务端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
返回值
Object

返回的 JSON 数据包

属性 类型 说明
openid string 用户唯一标识
session_key string 会话密钥
unionid string 用户在开放平台的唯一标识符,在满足 UnionID 下发条件的情况下会返回,详见 UnionID 机制说明。
errcode number 错误码
errmsg string 错误信息
errcode 的合法值
说明 最低版本
-1 系统繁忙,此时请开发者稍候再试
0 请求成功
40029 code 无效
45011 频率限制,每个用户每分钟100次

3、前端:wx.checkSession(Object object)

检查登录态是否过期。

通过 wx.login 接口获得的用户登录态拥有一定的时效性。用户越久未使用小程序,用户登录态越有可能失效。反之如果用户一直在使用小程序,则用户登录态一直保持有效。具体时效逻辑由微信维护,对开发者透明。开发者只需要调用 wx.checkSession 接口检测当前用户登录态是否有效。

登录态过期后开发者可以再调用 wx.login 获取新的用户登录态。调用成功说明当前 session_key 未过期,调用失败说明 session_key 已过期。更多使用方法详见 小程序登录。

参数
Object object
属性 类型 默认值 必填 说明
success function 接口调用成功的回调函数
fail function 接口调用失败的回调函数
complete function 接口调用结束的回调函数(调用成功、失败都会执行)
示例代码
wx.checkSession({
  success () {
    //session_key 未过期,并且在本生命周期一直有效
  },
  fail () {
    // session_key 已经失效,需要重新执行登录流程
    wx.login() //重新登录
  }
})
会话密钥 session_key 有效性

开发者如果遇到因为 session_key 不正确而校验签名失败或解密失败,请关注下面几个与 session_key 有关的注意事项。

  1. wx.login 调用时,用户的 session_key 可能会被更新而致使旧 session_key 失效(刷新机制存在最短周期,如果同一个用户短时间内多次调用 wx.login,并非每次调用都导致 session_key 刷新)。开发者应该在明确需要重新登录时才调用 wx.login,及时通过 auth.code2Session 接口更新服务器存储的 session_key。
  2. 微信不会把 session_key 的有效期告知开发者。我们会根据用户使用小程序的行为对 session_key 进行续期。用户越频繁使用小程序,session_key 有效期越长。
  3. 开发者在 session_key 失效时,可以通过重新执行登录流程获取有效的 session_key。使用接口 wx.checkSession可以校验 session_key 是否有效,从而避免小程序反复执行登录流程。
  4. 当开发者在实现自定义登录态时,可以考虑以 session_key 有效期作为自身登录态有效期,也可以实现自定义的时效性策略。

4、后端:auth.checkSessionKey

本接口应在服务器端调用,详细说明参见服务端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
返回值
Object

返回的 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"}

5、前端:wx.authorize(Object object)

基础库 1.2.0 开始支持,低版本需做兼容处理。

提前向用户发起授权请求。调用后会立刻弹窗询问用户是否同意授权小程序使用某项功能或获取用户的某些数据,但不会实际调用对应接口。如果用户之前已经同意授权,则不会出现弹窗,直接返回成功。更多用法详见 用户授权。 > 小程序插件可以使用 wx.authorizeForMiniProgram

参数
Object object
属性 类型 默认值 必填 说明
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()
        }
      })
    }
  }
})

6、权限

部分接口需要经过用户授权同意才能调用。我们把这些接口按使用范围分成多个 scope ,用户选择对 scope 来进行授权,当授权给一个 scope 之后,其对应的所有接口都可以直接使用。

此类接口调用时:

  • 如果用户未接受或拒绝过此权限,会弹窗询问用户,用户点击同意后方可调用接口;
  • 如果用户已授权,可以直接调用接口;
  • 如果用户已拒绝授权,则不会出现弹窗,而是直接进入接口 fail 回调。请开发者兼容用户拒绝授权的场景。
获取用户授权设置

开发者可以使用 wx.getSetting 获取用户当前的授权状态。

打开设置界面

用户可以在小程序设置界面(「右上角」 - 「关于」 - 「右上角」 - 「设置」)中控制对该小程序的授权状态。

开发者可以调用 wx.openSetting 打开设置界面,引导用户开启授权。

提前发起授权请求

开发者可以使用 wx.authorize 在调用需授权 API 之前,提前向用户发起授权请求。

scope 列表
scope 对应接口 描述
scope.userInfo wx.getUserInfo 用户信息
scope.userLocation wx.getLocation 地理位置
scope.werun wx.getWeRunData 微信运动步数
scope.writePhotosAlbum wx.saveImageToPhotosAlbum 保存到相册
授权有效期

一旦用户明确同意或拒绝过授权,其授权关系会记录在后台,直到用户主动删除小程序。

最佳实践

在真正需要使用授权接口时,才向用户发起授权申请,并在授权申请中说明清楚要使用该功能的理由。

注意事项
  1. wx.authorize({scope: "scope.userInfo"}),不会弹出授权窗口,请使用 wx.createUserInfoButton
  2. 需要授权 scope.userLocation 时必须配置地理位置用途说明。

我根据对上面文档的理解,重新介绍一下最开始的那幅图,以及我的解决方案:

  1. 前端发起wx.login请求获得code(5分钟内有效)
  2. 前端携带code请求后端登录接口,后端拿到code去请求登录品质校验接口,校验code,正常则返回session_key与openid。
  3. 根据openid生成token返回给前端(这里的token就是我定义的登录状态)
  4. 前端以后每次请求都要携带上我的token
  5. session_key失效这需要重新登录
  1. 为什么利用openid生产token?
    session_key微信官方强烈要求不能返回给前端,如果使用session_key生产token,会有被破译token的危险,openid是用户唯一标识,比起session_key没那么重要,session_key还会用于对微信敏感数据进行解密,所以session_key很重要。
  2. token时效怎么设置?
    在生成token时,不设置失效时间,让token与session_key同步,前端每次进入小程序时使用wx.checkSession检验session_key是否失效,失效则重新登陆,生成新的session_key与token,token与session_key以及openid都被我存入了数据库中。
  3. 每次生成的token一样?
    前面说到了我是使用openid生成token的,那么如果每次的密文一样,则生成的token就会不变,这是可以考虑使用uuid作为密文,或者使用session_key作为密文。

授权过程:前端授权,存入用户信息进入数据库。(后端使用session_key验证数据一致性,一致则存入数据库,否则返回异常)

二、代码

1、登录请求处理:


@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, "服务器异常");
    }
}

2、拦截器拦截

@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;
    }
}

注意:这里会出现依赖注入问题。解决办法

3、生产token

 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;
 }

4、sha1计算签名

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;
        }
    }

5、授权校验数据一致性

@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, "数据不一致");
        }
    }

6、微信实现解密敏感数据

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;
    }
}

你可能感兴趣的:(日常问题,java,java,小程序)