小蔡学Java

项目一总结:(二)用户登录鉴权流程小结

2024-01-07 15:17 1530 0 项目 单点登录鉴权多线程

前言

我的项目一用户登录时基于JWT登录的,如果有对JWT不了解的同学可以看我之前总结的这篇文章!

JWT - 令牌认证解析流程,及如何使用

下面我来结合项目中的代码总结一下项目中登录流程的实现

登录

login入口

生成token

private String generateToken(LoginUserDTO detail) {
        // 2.2.生成access-token
        String token = jwtTool.createToken(detail);
        // 2.3.生成refresh-token,将refresh-token的JTI 保存到Redis
        String refreshToken = jwtTool.createRefreshToken(detail);
        // 2.4.将refresh-token写入用户cookie,并设置HttpOnly为true
        int maxAge = BooleanUtils.isTrue(detail.getRememberMe()) ?
                (int) JwtConstants.JWT_REMEMBER_ME_TTL.toSeconds() : -1;
        WebUtils.cookieBuilder()
                .name(detail.getRoleId() == 2 ? JwtConstants.REFRESH_HEADER : JwtConstants.ADMIN_REFRESH_HEADER)
                .value(refreshToken)
                .maxAge(maxAge)
                .httpOnly(true)
                .build();
        return token;
    }

为什么是双token

单token的局限性

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

该模式存在什么问题吗?

  • 其实是有问题的,主要是token有效期设置长短的问题,如果设置的比较短,用户会频繁的登录

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

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

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

双token三验证

实现这个方案主要解决的问题如下:

token有效期长不安全

  • 登录成功后,生成2个token,分别是:access_tokenrefresh_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
     *
     * @param userDTO 用户信息
     * @return access-token
     */
    public String createToken(LoginUserDTO userDTO) {
        // 1.生成jws
        return JWT.create()
                .setPayload(JwtConstants.PAYLOAD_USER_KEY, userDTO)
                .setExpiresAt(new Date(System.currentTimeMillis() + JWT_TOKEN_TTL.toMillis()))
                .setSigner(jwtSigner)
                .sign();
    }

    /**
     * 创建刷新token,并将token的JTI记录到Redis中
     *
     * @param userDetail 用户信息
     * @return 刷新token
     */
    public String createRefreshToken(LoginUserDTO userDetail) {
        // 1.生成 JTI
        String jti = UUID.randomUUID().toString(true);
        // 2.生成jwt
        // 2.1.如果是记住我,则有效期7天,否则30分钟
        Duration ttl = BooleanUtils.isTrue(userDetail.getRememberMe()) ?
                JwtConstants.JWT_REMEMBER_ME_TTL : JWT_REFRESH_TTL;
        // 2.2.生成token
        String token = JWT.create()
                .setJWTId(jti)
                .setPayload(JwtConstants.PAYLOAD_USER_KEY, userDetail)
                .setExpiresAt(new Date(System.currentTimeMillis() + ttl.toMillis()))
                .setSigner(jwtSigner)
                .sign();
        // 3.缓存jti,有效期与token一致,过期或删除JTI后,对应的refresh-token失效
        stringRedisTemplate.opsForValue()
                .set(JwtConstants.JWT_REDIS_KEY_PREFIX + userDetail.getUserId(), jti, ttl);
        return token;
    }

生成好双token并且设置好过期时间

刷新token的代码实现

/**
     * 解析刷新token
     *
     * @param refreshToken 刷新token
     * @return 解析刷新token得到的用户信息
     */
    public LoginUserDTO parseRefreshToken(String refreshToken) {
        // 1.校验token是否为空
        AssertUtils.isNotNull(refreshToken, AuthErrorInfo.Msg.INVALID_TOKEN);
        // 2.校验并解析jwt
        JWT jwt;
        try {
            jwt = JWT.of(refreshToken).setSigner(jwtSigner);
        } catch (Exception e) {
            throw new BadRequestException(400, AuthErrorInfo.Msg.INVALID_TOKEN, e);
        }
        // 2.校验jwt是否有效
        if (!jwt.verify()) {
            // 验证失败
            throw new BadRequestException(400, AuthErrorInfo.Msg.INVALID_TOKEN);
        }
        // 3.校验是否过期
        try {
            JWTValidator.of(jwt).validateDate();
        } catch (ValidateException e) {
            throw new BadRequestException(400, AuthErrorInfo.Msg.EXPIRED_TOKEN);
        }
        // 4.数据格式校验
        Object userPayload = jwt.getPayload(JwtConstants.PAYLOAD_USER_KEY);
        Object jtiPayload = jwt.getPayload(JwtConstants.PAYLOAD_JTI_KEY);
        if (jtiPayload == null || userPayload == null) {
            // 数据为空
            throw new BadRequestException(400, AuthErrorInfo.Msg.INVALID_TOKEN);
        }

        // 5.数据解析
        LoginUserDTO userDTO;
        try {
            userDTO = ((JSONObject) userPayload).toBean(LoginUserDTO.class);
        } catch (RuntimeException e) {
            // 数据格式有误
            throw new BadRequestException(400, AuthErrorInfo.Msg.INVALID_TOKEN);
        }

        // 6.JTI校验
        String jti = stringRedisTemplate.opsForValue().get(JwtConstants.JWT_REDIS_KEY_PREFIX + userDTO.getUserId());
        if (!StringUtils.equals(jti, jtiPayload.toString())) {
            // jti不一致
            throw new BadRequestException(400, AuthErrorInfo.Msg.INVALID_TOKEN);
        }
        return userDTO;
    }

    /**
     * 清理刷新refresh-token的jti,本质是refresh-token作废
     */
    public void cleanJtiCache() {
        stringRedisTemplate.delete(JwtConstants.JWT_REDIS_KEY_PREFIX + UserContext.getUser());
    }
refreshToken存储到Redis中

token解析

public R<LoginUserDTO> parseToken(String token) {
        // 1.校验token是否为空
        if(StringUtils.isBlank(token)){
            return R.error(INVALID_TOKEN_CODE, INVALID_TOKEN);
        }
        JWT jwt = null;
        try {
            jwt = JWT.of(token).setSigner(jwtSignerHolder.getJwtSigner());
        } catch (Exception e) {
            return R.error(INVALID_TOKEN_CODE, INVALID_TOKEN);
        }
        // 2.校验jwt是否有效
        if (!jwt.verify()) {
            // 验证失败,返回空
            return R.error(INVALID_TOKEN_CODE, INVALID_TOKEN);
        }
        // 3.校验是否过期
        try {
            JWTValidator.of(jwt).validateDate();
        } catch (ValidateException e) {
            return R.error(EXPIRED_TOKEN_CODE, EXPIRED_TOKEN);
        }
        // 4.数据格式校验
        Object userPayload = jwt.getPayload(PAYLOAD_USER_KEY);
        if (userPayload == null) {
            // 数据为空
            return R.error(INVALID_TOKEN_CODE, INVALID_TOKEN_PAYLOAD);
        }

        // 5.数据解析
        LoginUserDTO userDTO;
        try {
            userDTO = ((JSONObject)userPayload).toBean(LoginUserDTO.class);
        } catch (RuntimeException e) {
            // token格式有误
            return R.error(INVALID_TOKEN_CODE, INVALID_TOKEN_PAYLOAD);
        }

        // 6.返回
        return R.ok(userDTO);
    }

公钥私钥

@Data
@Slf4j
public class JwtSignerHolder {

    private volatile JWTSigner jwtSigner;

    private DiscoveryClient discoveryClient;

    public JwtSignerHolder(DiscoveryClient discoveryClient) {
        this.discoveryClient = discoveryClient;
    }

    private final ExecutorService ses = new ThreadPoolExecutor(
            1,
            1,
            10,
            TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(1),
            r -> new Thread(r, "AuthFetchJwkThread")
    );

    @PostConstruct
    public void init(){
        // 尝试获取jwk秘钥
        ses.submit(new MarkedRunnable(new JwkTask(discoveryClient)));
    }

    public void shutdown(){
        ses.shutdown();
        log.debug("销毁加载秘钥线程 AuthFetchJwkThread");
    }
    public static void sleep(long time){
        try {
            Thread.sleep(time);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    class JwkTask implements Runnable{
        private final DiscoveryClient discoveryClient;

        public JwkTask(DiscoveryClient discoveryClient) {
            this.discoveryClient = discoveryClient;
        }

        @Override
        public void run() {
            while (jwtSigner == null) {
                try {
                    log.info("尝试加载auth服务地址");
                    List<ServiceInstance> instances = discoveryClient.getInstances("auth-service");
                    if(CollUtils.isEmpty(instances)){
                        log.error("加载auth服务地址失败,原因:数据为空");
                        sleep(10000);
                        continue;
                    }
                    ServiceInstance instance = instances.get(0);
                    String jwkUri = String.format("http://%s:%d/jwks", instance.getHost(), instance.getPort());
                    log.info("加载auth服务地址成功,{}", jwkUri);

                    log.info("尝试加载jwk秘钥");
                    // 请求获取jwk
                    String result = HttpUtil.get(jwkUri, StandardCharsets.UTF_8);
                    if(result == null){
                        log.error("加载jwk秘钥失败,原因:数据为空");
                        sleep(10000);
                        continue;
                    }
                    // 解析
                    PublicKey publicKey = KeyUtil.generatePublicKey(
                            AsymmetricAlgorithm.RSA_ECB_PKCS1.getValue(),
                            SecureUtil.decode(result)
                    );
                    jwtSigner = JWTSignerUtil.createSigner(JwtConstants.JWT_ALGORITHM, publicKey);
                    log.info("加载jwk秘钥成功!");
                } catch (Exception e) {
                    log.error("加载jwk秘钥失败,原因:{}", e.getMessage());
                    sleep(10000);
                }
            }
            // 关闭线程池
            shutdown();
        }
    }
}

代码解析

这段代码定义了一个用于处理 JWT 签名的类 `JwtSignerHolder`,它在初始化时从服务中获取 JWK(JSON Web Key)秘钥,并且提供了关闭线程池和休眠的方法。以下是对代码中每个变量和方法的详细解释:

1. 变量和字段

  • private volatile JWTSigner jwtSigner;

    • jwtSigner 是一个 JWTSigner 对象,用于签署 JWT。使用 volatile 关键字确保线程间的可见性和及时更新。
  • private DiscoveryClient discoveryClient;

    • discoveryClient 是一个用于服务发现的客户端,通常用于从服务注册中心(如 Eureka)获取服务实例信息。
  • private final ExecutorService ses

    • ses 是一个 ExecutorService 实例,用于异步执行任务。这里使用的是 ThreadPoolExecutor,配置了线程池的参数。

2. 方法

  • public JwtSignerHolder(DiscoveryClient discoveryClient)

    • 构造函数,接收一个 DiscoveryClient 对象,并初始化类中的 discoveryClient 字段。
  • @PostConstruct public void init()

    • @PostConstruct 注解的方法在构造函数之后被调用。这里的 init 方法提交了一个 JwkTask 任务到线程池中,开始加载 JWK 秘钥。
  • public void shutdown()

    • 关闭线程池 ses。调用 shutdown 方法将停止接受新任务,并关闭现有的任务。使用 log.debug 记录关闭线程池的日志。
  • public static void sleep(long time)

    • 使当前线程休眠指定的时间(以毫秒为单位)。这是一个静态方法,用于在任务执行中暂停一定时间,以避免过于频繁地请求服务。

3. 内部类和方法

  • class JwkTask implements Runnable
    • JwkTask 是一个实现了 Runnable 接口的内部类,负责从服务中获取 JWK 秘钥的任务。

    • private final DiscoveryClient discoveryClient

      • 这是 JwkTask 类中的字段,用于保存 DiscoveryClient 实例。
    • public JwkTask(DiscoveryClient discoveryClient)

      • 构造函数,接收 DiscoveryClient 实例并初始化内部字段。
    • @Override public void run()

      • 实现 Runnable 接口的 run 方法,主要逻辑是:
        1. 不断尝试获取 auth-service 服务实例。
        2. 从服务实例中构建 JWK URI。
        3. 请求 JWK 数据并解析。
        4. 将 JWK 转换为 PublicKey 对象。
        5. 创建 JWTSigner 实例。
        6. 记录日志并处理异常。
        7. 成功加载秘钥后,关闭线程池。

4. 其他工具和库

  • log 是一个 @Slf4j 注解生成的日志记录器,使用 log.infolog.errorlog.debug 等方法记录日志。
  • HttpUtil.get(String url, Charset charset) 是一个用于发送 HTTP GET 请求的工具方法,返回响应结果。
  • KeyUtil.generatePublicKey(...)JWTSignerUtil.createSigner(...) 是工具方法,用于生成公钥和创建 JWT 签名器。
  • SecureUtil.decode(String data) 是一个解码工具方法,将编码的数据转换为字节数组。

总结来说,这段代码的核心功能是从服务中动态获取 JWK 秘钥并创建 JWT 签名器,同时处理可能发生的异常并记录相关日志。

扩展一:在这个类中,JwkTask类是如何在获取到正确的JWK秘钥后决定关闭线程池的?

JwtSignerHolder 类中,JwkTask 类的 run 方法负责从服务中获取 JWK 秘钥并创建 JWTSigner 实例。以下是详细解释如何在获取到正确的 JWK 秘钥后决定关闭线程池:

1. JwkTask 类的 run 方法

@Override
public void run() {
    while (jwtSigner == null) {
        try {
            log.info("尝试加载auth服务地址");
            List<ServiceInstance> instances = discoveryClient.getInstances("auth-service");
            if(CollUtils.isEmpty(instances)){
                log.error("加载auth服务地址失败,原因:数据为空");
                sleep(10000);
                continue;
            }
            ServiceInstance instance = instances.get(0);
            String jwkUri = String.format("http://%s:%d/jwks", instance.getHost(), instance.getPort());
            log.info("加载auth服务地址成功,{}", jwkUri);

            log.info("尝试加载jwk秘钥");
            // 请求获取jwk
            String result = HttpUtil.get(jwkUri, StandardCharsets.UTF_8);
            if(result == null){
                log.error("加载jwk秘钥失败,原因:数据为空");
                sleep(10000);
                continue;
            }
            // 解析
            PublicKey publicKey = KeyUtil.generatePublicKey(
                    AsymmetricAlgorithm.RSA_ECB_PKCS1.getValue(),
                    SecureUtil.decode(result)
            );
            jwtSigner = JWTSignerUtil.createSigner(JwtConstants.JWT_ALGORITHM, publicKey);
            log.info("加载jwk秘钥成功!");
        } catch (Exception e) {
            log.error("加载jwk秘钥失败,原因:{}", e.getMessage());
            sleep(10000);
        }
    }
    // 关闭线程池
    shutdown();
}

2. 详细解释

  • 循环直到 jwtSigner 不为 null

    • while (jwtSigner == null):这个循环会一直运行,直到 jwtSigner 被成功初始化(即不为 null)。这是为了确保在 JWK 秘钥成功加载之前,线程不会退出。
  • 获取服务实例

    • List<ServiceInstance> instances = discoveryClient.getInstances("auth-service"):尝试从 DiscoveryClient 获取 auth-service 服务实例列表。
    • 如果服务实例为空,记录错误日志并休眠 10 秒,然后继续尝试获取服务实例。
  • 构建 JWK URI

    • String jwkUri = String.format("http://%s:%d/jwks", instance.getHost(), instance.getPort()):使用服务实例的主机和端口构建 JWK 的 URI。
  • 请求 JWK 秘钥

    • String result = HttpUtil.get(jwkUri, StandardCharsets.UTF_8):发送 HTTP GET 请求以获取 JWK 数据。
    • 如果获取的数据为空,记录错误日志并休眠 10 秒,然后继续尝试获取 JWK 秘钥。
  • 解析 JWK 秘钥

    • PublicKey publicKey = KeyUtil.generatePublicKey(...):将 JWK 数据解析为 PublicKey 对象。
    • jwtSigner = JWTSignerUtil.createSigner(JwtConstants.JWT_ALGORITHM, publicKey):使用解析出的公钥创建 JWTSigner 实例,并将其赋值给 jwtSigner
  • 成功加载 JWK 秘钥

    • 一旦 jwtSigner 被成功初始化,记录成功日志,并退出循环。
  • 关闭线程池

    • shutdown():在成功初始化 jwtSigner 后,调用 shutdown 方法来关闭线程池。这是因为任务已经完成,不再需要继续运行线程池中的线程。

3. shutdown 方法

public void shutdown(){
    ses.shutdown();
    log.debug("销毁加载秘钥线程 AuthFetchJwkThread");
}
  • 关闭线程池

    • ses.shutdown():调用 shutdown 方法来停止线程池接受新任务,并尝试完成当前正在执行的任务。这里的线程池是单线程的,因此它会在 JwkTask 任务完成后关闭。
  • 记录日志

    • log.debug("销毁加载秘钥线程 AuthFetchJwkThread"):记录关闭线程池的调试信息。

总结

JwkTask 类的 run 方法中,当 jwtSigner 成功初始化后,线程会退出循环。此时,调用 shutdown 方法关闭线程池,释放资源。这个设计确保了当 JWK 秘钥加载成功后,不再需要继续运行线程池,从而提高了资源的利用效率。

扩展(二):在JwtSignerHolder类中,使用线程池进行异步任务处理的主要目的是什么?

异步加载 JWK 秘钥

非阻塞操作:使用线程池来异步加载 JWK 秘钥可以避免在主线程中进行阻塞操作。加载秘钥的过程可能涉及网络请求、解析等耗时操作,如果在主线程中进行这些操作,会影响整个应用的响应性和性能。

后台处理:通过将 JWK 秘钥加载任务放在一个单独的线程中,可以使主线程继续进行其他操作,而不被加载秘钥的过程阻塞。这确保了应用的其他部分(如处理用户请求)能够继续正常工作。

以上就是我对我项目中登录模块的鉴权总结。其中也有用户权限设计模块。稍后继续总结先参考之前的一篇文章; 后台管理系统的权限控制设计

谢谢大家!

评论( 0 )

  • 博主 Mr Cai
  • 坐标 河南 信阳
  • 标签 Java、SpringBoot、消息中间件、Web、Code爱好者

文章目录