微信登录的双token三验证方案

前言

本文从实现了微信登录的双token三验证方案。

微信小程序登录业务分析

通过查阅微信官方文档,我们可以知道微信小程序的具体登录业务流程。当然,他的实现与我们传统的不同之处就是在于我们需要调用微信的服务器来实现,下面是官方提供的时序图。

api-login

微信官网登录接口文档说明

  1. 调用 wx.login() 获取 临时登录凭证code ,并回传到开发者服务器。
  2. 调用 auth.code2Session 接口,换取 用户唯一标识 OpenID 、 用户在微信开放平台账号下的唯一标识UnionID(若当前小程序已绑定到微信开放平台账号) 和 会话密钥 session_key

之后开发者服务器可以根据用户标识(OpenID)来生成自定义登录态,用于后续业务逻辑中前后端交互时识别用户身份。

当然,现在微信用户基本都是绑定了手机号码的,所以我们也可以整合官网提供的手机号码接口,来获取用户登录时候的手机号码,保存到我们的数据库中。

需要注意的是,手机号码的获取需要先去获取 access_token

微信登录的单token实现

用户端通过集成微信小程序进行登录,流程图如下:

image-20230904203819067

流程图分析:

  • 微信小程序通过http请求访问用户端web服务
  • 然后通过OpenFeign远程调用用户微服务
  • 用户业务微服务直接连接Mysql数据执行CRUD操作

user表

image-20230904203942639

第一步,流程分析

image-20230904204426417

实现流程步骤:

  1. 调用微信后台接口,根据临时code获取openid【第三步】
  2. 通过openid查询用户,判断是否为新用户,新用户则注册,老用户不需要注册
  3. 调用微信后台接口,获取用户手机号【第四步】
  4. 如果用户手机号有更新,需要进行修改操作
  5. 生成token【第五步】
  6. 封装数据并返回

第二步,实现登录

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
/**
* 登录
*
* @param userLoginRequestVO 登录code
* @return 用户信息
*/
@Override
public UserLoginVO login(UserLoginRequestVO userLoginRequestVO) throws IOException {
//1、调用微信后台接口,根据临时code获取openid
JSONObject jsonObject = wechatService.getOpenid(userLoginRequestVO.getCode());
String openid = jsonObject.getStr("openid");
//2.通过openid查询用户,判断是否为新用户,新用户则注册,老用户不需要注册
MemberDTO memberDTO = this.getByOpenid(openid);
//新用户
if(ObjectUtil.isEmpty(memberDTO)){
memberDTO=MemberDTO.builder()
.openId(openid)//设置微信openid
.authId(jsonObject.getStr("unionid"))//如果有值,则复制,没有值则为空
.build();
this.save(memberDTO);

/**
* 再次查询数据库表,这样就就可以获取对应的主键和其他属性了,方便后面使用
*/
memberDTO = this.getByOpenid(openid);
}

//3.调用微信后台接口,获取用户手机号
String phone = wechatService.getPhone(userLoginRequestVO.getPhoneCode());

//4.如果用户手机号有更新,需要进行修改操作
if (!StrUtil.equals(phone,memberDTO.getPhone())) {
memberDTO.setPhone(phone);
memberFeign.update(memberDTO.getId(),memberDTO);
}

//5.生成token
Map<String, Object> claims=new HashMap<>();
claims.put(Constants.GATEWAY.USER_ID,memberDTO.getId());
String accessToken = tokenService.createAccessToken(claims);

//6.封装数据并返回
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
//获取openid的访问路径地址
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;
/**
* 获取openid
* @param code 登录凭证
* @return 唯一标识
* @throws IOException IO异常
*/
@Override
public JSONObject getOpenid(String code) throws IOException {
//2.封装参数
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");
//1.发起http请求
HttpResponse response = HttpRequest.get(URL)
.form(paramMap)//设置表单参数
.execute();
//3.解析结果
//3.1 如果响应成功
if (response.isOk()) {
//获取到响应主体内容json字符串
String body = response.body();
//解析成json对象
JSONObject jsonObject = JSONUtil.parseObj(body);
//如果包含errcode说明报错了
if(jsonObject.containsKey("errcode")){
throw new SLWebException(jsonObject.toString());
}
return jsonObject;
}
//3.2 响应失败
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
//获取token的接口地址
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=";
/**
* 获取手机号
* @param code 手机号凭证
* @return 唯一标识
* @throws IOException IO异常
*/
@Override
public String getPhone(String code) throws IOException {
//2.封装数据
Map<String,Object> paramMap=new HashMap<>();
paramMap.put("code",code);
String paramBody=JSONUtil.toJsonStr(paramMap);
//1.调获取手机号的接口
HttpResponse response = HttpRequest.post(PHONE_URL+this.accessToken())
.body(paramBody)//设置请求参数,json字符串类型
.execute();
//3.解析结果
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);
}
//获取token值
private String accessToken(){
//2、封装数据
Map<String,Object> paramMap=new HashMap<>();
paramMap.put("grant_type","client_credential");
paramMap.put("appid",appid);
paramMap.put("secret",secret);
//1.获取token
HttpResponse response = HttpRequest.get(ACCESS_TOKEN_URL)
.form(paramMap)
.execute();
//3.解析数据
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(),//加入RSA私钥
jwtProperties.getAccessTtl(),//设置过期时间
DateField.MINUTE//设置时间单位,分钟
);
return token;
}

注意:这里使用的是非对称加密算法的方式,其实就是公钥加密,私钥解密。

但是,上面的实现是存在一定的问题的,大家试想一下:我们的token有效期该怎么去设置呢?

  • 如果设置的比较短,用户会频繁的登录,如果设置的比较长,会不太安全,因为token一旦被黑客截取的话,就可以通过此token与服务端进行交互了。

  • 另外一方面,token是无状态的,也就是说,服务端一旦颁发了token就无法让其失效(除非过了有效期),这样的话,如果我们检测到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失效即可

详细流程如下:

1687431489005

生成刷新token

注意:目前我们再此之前已经实现了创建access-token了,那么此步是为了实现refresh-token的

实现的步骤:

1687443488956

生成刷新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
/**
* 生成长令牌
* @param claims token中存储的数据
* @return 长令牌
*/
@Override
public String createRefreshToken(Map<String, Object> claims) {
//生成长令牌的有效期时间单位为:小时
//1、生成刷新token
Integer refreshTtl = jwtProperties.getRefreshTtl();
String refreshToken = JwtUtils.createToken(claims, jwtProperties.getPrivateKey(),refreshTtl );
//2.长令牌只能使用一次,需要将其存储到redis中,变成有状态的
String redisKey=REDIS_REFRESH_TOKEN_PREFIX+ SecureUtil.md5(refreshToken);
stringRedisTemplate.opsForValue().set(redisKey,refreshToken, Duration.ofHours(refreshTtl));
//3.返回刷新token
return refreshToken;
}

刷新token业务

1687443593191

刷新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
/**
* 刷新token动作
* @param refreshToken 原长令牌,需要否则校验其合法性以及可用性
* @return 登录对象,包含长短令牌
*/
@Override
public UserLoginVO refreshToken(String refreshToken) {
//1.非空判断
if (StringUtils.isEmpty(refreshToken)) {
return null;
}
//2.校验token是否存在,第二次校验
Map<String, Object> checkTokenMap = JwtUtils.checkToken(refreshToken, jwtProperties.getPublicKey());
//token无效
if(checkTokenMap==null || checkTokenMap.size()==0){
return null;
}
//3.否则有效,则校验redis中的token是否使用过,第三次校验
String redisKey=REDIS_REFRESH_TOKEN_PREFIX+ SecureUtil.md5(refreshToken);
Boolean flag = stringRedisTemplate.hasKey(redisKey);
//如果等于false,表示过期或者使用过
if(!flag){
return null;
}
//4.重新生成长短令牌
String accessToken = createAccessToken(checkTokenMap);
//生成新的长令牌,并重新存储新的令牌到redis中
String newRefreshToken = createRefreshToken(checkTokenMap);
//5.删除redis中的原token,只能使用一次
stringRedisTemplate.delete(redisKey);
//6.封装数据并返回
UserLoginVO userLoginVO = UserLoginVO.builder()
.refreshToken(newRefreshToken)
.accessToken(accessToken)
.build();
return userLoginVO;
}

注意:该刷新token的业务动作,是由前端主动发起请求的,所以我们要完成对应的userController中的方法填充

1
2
3
4
5
6
7
8
9
10
11
/**
* 刷新token,校验请求头中的长令牌,生成新的长短令牌
* @param refreshToken 原令牌
* @return 登录结果
*/
@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
/**
* 刷新token
* @param refreshToken 原长令牌
* @return 长/短令牌
*/
@Override
public UserLoginVO refresh(String refreshToken) {
return tokenService.refreshToken(refreshToken);
}