物流项目第二期(用户端登录与双token三验证)

第一期内容:

物流项目第一期(登录业务)-CSDN博客

用户端登录

实现分析

登录功能 

@Data
public class UserLoginRequestVO {

    @ApiModelProperty("登录临时凭证")
    private String code;

    @ApiModelProperty("手机号临时凭证")
    private String phoneCode;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserLoginVO {

    @ApiModelProperty("微信唯一标识符")
    private String openid;
    @ApiModelProperty("短令牌,有效期较短")
    private String accessToken;
    @ApiModelProperty("长令牌,有效期较长")
    private String refreshToken;
    @ApiModelProperty("是否绑定手机号 0否 1是")
    private Integer binding;

}

小程序登录

    @Value("${sl.wechat.appid}")
    private String appid;
    @Value("${sl.wechat.secret}")
    private String secret;

    public static final String LOGIN_URL = "https://api.weixin.qq.com/sns/jscode2session";
    private static final int TIMEOUT = 20000;

    @Override
    public JSONObject getOpenid(String code) throws IOException {
        //文档:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html
        //1. 封装参数
        Map<String, Object> requestParam = MapUtil.<String, Object>builder().put("appid", this.appid) //小程序 appId
                .put("secret", this.secret) //小程序 appSecret
                .put("js_code", code) //	登录时获取的 code,可通过wx.login获取
                .put("grant_type", "authorization_code") //授权类型
                .build();

        //2. 发送get请求
        HttpResponse response = HttpRequest.get(LOGIN_URL) //设置get请求url
                .form(requestParam) //设置表单参数
                .timeout(TIMEOUT) //设置超时时间,20s
                .execute();//执行请求
        if (response.isOk()) {
            // 3. 解析响应的结果,如果出现错误抛出异常
            JSONObject jsonObject = JSONUtil.parseObj(response.body());
            if (jsonObject.containsKey("errcode")) {
                throw new SLWebException(jsonObject.toString());
            }
            return jsonObject;
        }
        String errMsg = StrUtil.format("调用微信登录接口出错! code = {}", code);
        throw new SLWebException(errMsg);
    }

获取手机号

    public static final String TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token";
    public static final String PHONE_URL = "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=";

	@Override
    public String getPhone(String code) throws IOException {
        //接口文档:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-info/phone-number/getPhoneNumber.html

        //1. 获取手机号,需要先获取微信access_token
        String accessToken = this.getToken();
        //2. 封装参数
        Map<String, Object> requestParam = MapUtil.<String, Object>builder()
                .put("code", code) //手机号获取凭证
                .build();
        //3. 发送post请求
        HttpResponse response = HttpRequest.post(PHONE_URL + accessToken) //设置post请求url
                .body(JSONUtil.toJsonStr(requestParam)) //设置请求体参数
                .timeout(TIMEOUT) //设置超时时间,20s
                .execute();//执行请求

        if (response.isOk()) {
            // 4. 解析响应的结果,如果errcode不等于0抛出异常
            JSONObject jsonObject = JSONUtil.parseObj(response.body());
            if (ObjectUtil.notEqual(jsonObject.getInt("errcode"), 0)) {
                throw new SLWebException(jsonObject.toString());
            }
            return jsonObject.getByPath("phone_info.purePhoneNumber", String.class);
        }
        String errMsg = StrUtil.format("调用获取手机号接口出错!");
        throw new SLWebException(errMsg);
    }

    private String getToken() {
        //接口文档:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/mp-access-token/getAccessToken.html
        //1. 封装参数
        Map<String, Object> requestParam = MapUtil.<String, Object>builder().put("appid", this.appid) //小程序 appId
                .put("secret", this.secret) //小程序 appSecret
                .put("grant_type", "client_credential") //授权类型
                .build();
        //2. 发送get请求
        HttpResponse response = HttpRequest.get(TOKEN_URL) //设置get请求url
                .form(requestParam) //设置表单参数
                .timeout(TIMEOUT) //设置超时时间,20s
                .execute();//执行请求

        if (response.isOk()) {
            // 3. 解析响应的结果,如果出现错误抛出异常
            JSONObject jsonObject = JSONUtil.parseObj(response.body());
            if (jsonObject.containsKey("errcode")) {
                throw new SLWebException(jsonObject.toString());
            }
            //TODO 缓存token到redis,不应该每次都获取token
            return jsonObject.getStr("access_token");
        }
        String errMsg = StrUtil.format("调用获取接口调用凭据接口出错!");
        throw new SLWebException(errMsg);
    }

实现登录

    /**
     * 登录
     *
     * @param userLoginRequestVO 登录code
     * @return 用户信息
     */
    @Override
    public UserLoginVO login(UserLoginRequestVO userLoginRequestVO) throws IOException {
        //1. 调用微信开发平台的接口,根据临时登录code获取openid等信息
        JSONObject jsonObject = this.wechatService.getOpenid(userLoginRequestVO.getCode());
        String openid = jsonObject.getStr("openid");
        //2. 根据openid来确认是否为新用户,新用户进行注册,老用户无需直接注册
        MemberDTO memberDTO = this.getByOpenid(openid);
        if (ObjectUtil.isEmpty(memberDTO)) {
            //新用户
            MemberDTO newMember = MemberDTO.builder().openId(openid) //设置openid
                    .authId(jsonObject.getStr("unionid")) //设置平台唯一id,若当前小程序已绑定到微信开放平台帐号下会返回
                    .build();
            //注册用户
            this.save(newMember);
            //再次查询用户信息
            memberDTO = this.getByOpenid(openid);
        }

        //3. 调用微信开发平台的接口,获取用户手机号,如果用户手机号有更新,需要进行更新操作
        String phone = this.wechatService.getPhone(userLoginRequestVO.getPhoneCode());
        if (ObjectUtil.notEqual(phone, memberDTO.getPhone())) {
            //更新手机号
            memberDTO.setPhone(phone);
            this.memberFeign.update(memberDTO.getId(), memberDTO);
        }

        //4. 生成token,将用户id存储到token中
        Map<String, Object> claims = MapUtil.<String, Object>builder()
                .put(Constants.GATEWAY.USER_ID, memberDTO.getId()) //将id存入token
                .build();
        String accessToken = this.tokenService.createAccessToken(claims);

        //5. 返回封装响应数据
        return UserLoginVO
                .builder()
                .openid(openid)
                .accessToken(accessToken)
                .binding(StatusEnum.NORMAL.getCode())
                .build();
    }
    public String createAccessToken(Map<String, Object> claims) {
        //生成短令牌的有效期时间单位为:分钟
        return JwtUtils.createToken(claims, jwtProperties.getPrivateKey(), jwtProperties.getAccessTtl(),
                DateField.MINUTE);
    }

登录流程总结

+-----------------------+
|      小程序端         |
+-----------------------+
          |
          v
+-----------------------+
|   调用微信登录接口     |
|   获取 openid         |
+-----------------------+
          |
          v
+-----------------------+
| 根据 openid 注册/查询用户 |
+-----------------------+
          |
          v
+-----------------------+
| 调用微信获取手机号接口   |
| 需要先获取 access_token |
+-----------------------+
          |
          v
+-----------------------+
| 更新用户手机号(如有变化)|
+-----------------------+
          |
          v
+-----------------------+
| 生成你自己的 JWT Token   |
| 返回给前端              |
+-----------------------+

双token三验证

单token存在的问题

在司机端、快递员端和管理管,登录成功后会生成jwt的token,前端将此token保存起来,当请求后端服务时,在请求头中携带此token,服务端需要对token进行校验以及鉴权操作,这种模式就是【单token模式】。

该模式存在什么问题吗?

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

另外一方面,token是无状态的,也就是说,服务端一旦颁发了token就无法让其失效(除非过了有效期),这样的话,如果我们检测到token异常也无法使其失效,所以这也是无状态token存在的问题。

为了解决此问题,我们将采用【双token三验证】的解决方案来解决此问题。

方案原理 

代码实现 

生成刷新token

    public static final String REDIS_REFRESH_TOKEN_PREFIX = "SL_CUSTOMER_REFRESH_TOKEN_";

	@Override
    public String createRefreshToken(Map<String, Object> claims) {
        //生成长令牌的有效期时间单位为:小时
        Integer ttl = jwtProperties.getRefreshTtl();
        String refreshToken = JwtUtils.createToken(claims, jwtProperties.getPrivateKey(), ttl);

        //长令牌只能使用一次,需要将其存储到redis中,变成有状态的
        String redisKey = this.getRedisRefreshToken(refreshToken);
        this.stringRedisTemplate.opsForValue().set(redisKey, refreshToken, Duration.ofHours(ttl));

        return refreshToken;
    }

    private String getRedisRefreshToken(String refreshToken) {
        //md5是为了缩短key的长度
        return REDIS_REFRESH_TOKEN_PREFIX + SecureUtil.md5(refreshToken);
    }

刷新token

刷新token的动作是在refresh_token过期之后进行的,主要实现关键点有:

  • 校验refresh_token是否被伪造以及是否在有效期内
  • 从redis中查询,是否不存在,如果不存在说明已经失效或已经使用过,如果存在,就需要将其删除
  • 重新生成一对token,响应结果
    @Override
    public UserLoginVO refreshToken(String refreshToken) {
        if (StrUtil.isEmpty(refreshToken)) {
            return null;
        }

        Map<String, Object> originClaims = JwtUtils.checkToken(refreshToken, this.jwtProperties.getPublicKey());
        if (ObjectUtil.isEmpty(originClaims)) {
            //token无效
            return null;
        }

        //通过redis校验,原token是否使用过,来确保token只能使用一次
        String redisKey = this.getRedisRefreshToken(refreshToken);
        Boolean bool = this.stringRedisTemplate.hasKey(redisKey);
        if (ObjectUtil.notEqual(bool, Boolean.TRUE)) {
            //原token过期或已经使用过
            return null;
        }
        //删除原token
        this.stringRedisTemplate.delete(redisKey);

        //重新生成长短令牌
        String newRefreshToken = this.createRefreshToken(originClaims);
        String accessToken = this.createAccessToken(originClaims);

        return UserLoginVO.builder()
                .accessToken(accessToken)
                .refreshToken(newRefreshToken)
                .build();
    }
    /**
     * 刷新token,校验请求头中的长令牌,生成新的长短令牌
     *
     * @param refreshToken 原令牌
     * @return 登录结果
     */
    @PostMapping("/refresh")
    @ApiOperation("刷新token")
    public R<UserLoginVO> refresh(@RequestHeader(Constants.GATEWAY.REFRESH_TOKEN) String refreshToken) {
        return R.success(this.memberService.refresh(refreshToken));
    }
    @Override
    public UserLoginVO refresh(String refreshToken) {
        return this.tokenService.refreshToken(refreshToken);
    }

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Auc23

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值