前言
本文从实现了微信登录的双token三验证方案。
微信小程序登录业务分析
通过查阅微信官方文档,我们可以知道微信小程序的具体登录业务流程。当然,他的实现与我们传统的不同之处就是在于我们需要调用微信的服务器来实现,下面是官方提供的时序图。
微信官网登录接口文档说明
- 调用 wx.login() 获取 临时登录凭证code ,并回传到开发者服务器。
- 调用 auth.code2Session 接口,换取 用户唯一标识 OpenID 、 用户在微信开放平台账号下的唯一标识UnionID(若当前小程序已绑定到微信开放平台账号) 和 会话密钥 session_key。
之后开发者服务器可以根据用户标识(OpenID
)来生成自定义登录态,用于后续业务逻辑中前后端交互时识别用户身份。
当然,现在微信用户基本都是绑定了手机号码的,所以我们也可以整合官网提供的手机号码接口,来获取用户登录时候的手机号码,保存到我们的数据库中。
需要注意的是,手机号码的获取需要先去获取 access_token
微信登录的单token实现
用户端通过集成微信小程序进行登录,流程图如下:
流程图分析:
- 微信小程序通过http请求访问用户端web服务
- 然后通过OpenFeign远程调用用户微服务
- 用户业务微服务直接连接Mysql数据执行CRUD操作
user表
第一步,流程分析
实现流程步骤:
- 调用微信后台接口,根据临时code获取openid【第三步】
- 通过openid查询用户,判断是否为新用户,新用户则注册,老用户不需要注册
- 调用微信后台接口,获取用户手机号【第四步】
- 如果用户手机号有更新,需要进行修改操作
- 生成token【第五步】
- 封装数据并返回
第二步,实现登录
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
|
@Override public UserLoginVO login(UserLoginRequestVO userLoginRequestVO) throws IOException { JSONObject jsonObject = wechatService.getOpenid(userLoginRequestVO.getCode()); String openid = jsonObject.getStr("openid"); MemberDTO memberDTO = this.getByOpenid(openid); if(ObjectUtil.isEmpty(memberDTO)){ memberDTO=MemberDTO.builder() .openId(openid) .authId(jsonObject.getStr("unionid")) .build(); this.save(memberDTO);
memberDTO = this.getByOpenid(openid); }
String phone = wechatService.getPhone(userLoginRequestVO.getPhoneCode());
if (!StrUtil.equals(phone,memberDTO.getPhone())) { memberDTO.setPhone(phone); memberFeign.update(memberDTO.getId(),memberDTO); }
Map<String, Object> claims=new HashMap<>(); claims.put(Constants.GATEWAY.USER_ID,memberDTO.getId()); String accessToken = tokenService.createAccessToken(claims);
UserLoginVO userLoginVO= UserLoginVO.builder() .accessToken(accessToken) .openid(openid) .binding(StatusEnum.NORMAL.getCode()) .build(); return userLoginVO; }
|
第三步,获取openid
参考小程序登录 | 微信开放文档 (qq.com)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| private static final String URL="https://api.weixin.qq.com/sns/jscode2session";
@Value("${sl.wechat.appid}") private String appid;
@Value("${sl.wechat.secret}") private String secret;
@Override public JSONObject getOpenid(String code) throws IOException { Map<String, Object> paramMap=new HashMap<>(); paramMap.put("appid",appid); paramMap.put("secret",secret); paramMap.put("js_code",code); paramMap.put("grant_type","authorization_code"); HttpResponse response = HttpRequest.get(URL) .form(paramMap) .execute(); if (response.isOk()) { String body = response.body(); JSONObject jsonObject = JSONUtil.parseObj(body); if(jsonObject.containsKey("errcode")){ throw new SLWebException(jsonObject.toString()); } return jsonObject; } String errMsg = StrUtil.format("调用微信登录接口出错! code = {}", code); throw new SLWebException(errMsg); }
|
第四步,获取手机号
参考手机号快速验证 | 微信开放文档 (qq.com)
注意:要想获取用户手机号,需要先获取到微信access_token,然后才能获取到手机号
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| private static final String ACCESS_TOKEN_URL="https://api.weixin.qq.com/cgi-bin/token"; private static final String PHONE_URL="https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=";
@Override public String getPhone(String code) throws IOException { Map<String,Object> paramMap=new HashMap<>(); paramMap.put("code",code); String paramBody=JSONUtil.toJsonStr(paramMap); HttpResponse response = HttpRequest.post(PHONE_URL+this.accessToken()) .body(paramBody) .execute(); if (response.isOk()) { String body = response.body(); JSONObject jsonObject = JSONUtil.parseObj(body);
return jsonObject.getByPath("phone_info.phoneNumber",String.class); } String errMsg = StrUtil.format("调用获取手机号接口出错!"); throw new SLWebException(errMsg); } private String accessToken(){ Map<String,Object> paramMap=new HashMap<>(); paramMap.put("grant_type","client_credential"); paramMap.put("appid",appid); paramMap.put("secret",secret); HttpResponse response = HttpRequest.get(ACCESS_TOKEN_URL) .form(paramMap) .execute(); if (response.isOk()) { String body = response.body(); JSONObject jsonObject = JSONUtil.parseObj(body); return jsonObject.getStr("access_token"); } String errMsg = StrUtil.format("调用获取接口调用凭据接口出错!"); throw new SLWebException(errMsg); }
|
第五步,生成token
1 2 3 4 5 6 7 8 9 10 11 12
| @Resource private JwtProperties jwtProperties; @Override public String createAccessToken(Map<String, Object> claims) { String token = JwtUtils.createToken(claims, jwtProperties.getPrivateKey(), jwtProperties.getAccessTtl(), DateField.MINUTE ); return token; }
|
注意:这里使用的是非对称加密算法的方式,其实就是公钥加密,私钥解密。
但是,上面的实现是存在一定的问题的,大家试想一下:我们的token有效期该怎么去设置呢?
下面我们可以通过双Token三验证的方式来解决上述问题。
双Token三验证的登录实现
为了解决单token模式下存在的问题,所以我们可以通过【双token三验证】的模式进行改进实现,主要解决的两个问题如下:
token有效期长不安全
- 登录成功后,生成2个token,分别是:access_token、refresh_token,前者有效期短(如:5分钟),后者的有效期长(如:24小时)
- 正常请求后端服务时,携带access_token,如果发现access_token失效,就通过refresh_token到后台服务中换取新的access_token和refresh_token,这个可以理解为token的续签
- 以此往复,直至refresh_token过期,需要用户重新登录
token的无状态性
- 为了使token有状态,也就是后端可以控制其提前失效,需要将refresh_token设计成只能使用一次
- 需要将refresh_token存储到redis中,并且要设置过期时间
- 这样的话,服务端如果检测到用户token有安全隐患(如:异地登录),只需要将refresh_token失效即可
详细流程如下:
生成刷新token
注意:目前我们再此之前已经实现了创建access-token了,那么此步是为了实现refresh-token的
实现的步骤:
生成刷新refresh_token的主要逻辑有两点:
- 生成jwt格式的token,有效期时间一般小时为单位
- 将token存入到redis,使token有状态,并且确保只能使用一次
在tokenServiceImpl中添加有以下实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
@Override public String createRefreshToken(Map<String, Object> claims) { Integer refreshTtl = jwtProperties.getRefreshTtl(); String refreshToken = JwtUtils.createToken(claims, jwtProperties.getPrivateKey(),refreshTtl ); String redisKey=REDIS_REFRESH_TOKEN_PREFIX+ SecureUtil.md5(refreshToken); stringRedisTemplate.opsForValue().set(redisKey,refreshToken, Duration.ofHours(refreshTtl)); return refreshToken; }
|
刷新token业务
刷新token的动作是在refresh_token过期之后进行的,主要实现关键点有:
- 校验refresh_token是否被伪造以及是否在有效期内
- 从redis中查询,是否不存在,如果不存在说明已经失效或已经使用过,如果存在,就需要将其删除
- 重新生成一对token,响应结果
refreshToken方法实现以下业务
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
|
@Override public UserLoginVO refreshToken(String refreshToken) { if (StringUtils.isEmpty(refreshToken)) { return null; } Map<String, Object> checkTokenMap = JwtUtils.checkToken(refreshToken, jwtProperties.getPublicKey()); if(checkTokenMap==null || checkTokenMap.size()==0){ return null; } String redisKey=REDIS_REFRESH_TOKEN_PREFIX+ SecureUtil.md5(refreshToken); Boolean flag = stringRedisTemplate.hasKey(redisKey); if(!flag){ return null; } String accessToken = createAccessToken(checkTokenMap); String newRefreshToken = createRefreshToken(checkTokenMap); stringRedisTemplate.delete(redisKey); UserLoginVO userLoginVO = UserLoginVO.builder() .refreshToken(newRefreshToken) .accessToken(accessToken) .build(); return userLoginVO; }
|
注意:该刷新token的业务动作,是由前端主动发起请求的,所以我们要完成对应的userController中的方法填充
1 2 3 4 5 6 7 8 9 10 11
|
@PostMapping("/refresh") @ApiOperation("刷新token") public R<UserLoginVO> refresh(@RequestHeader(Constants.GATEWAY.REFRESH_TOKEN) String refreshToken) { UserLoginVO loginVO = memberService.refresh(refreshToken); return R.success(loginVO); }
|
refresh方法
1 2 3 4 5 6 7 8 9
|
@Override public UserLoginVO refresh(String refreshToken) { return tokenService.refreshToken(refreshToken); }
|