• SpringSecurity Oauth2系列 - 02 自定义 SpringBoot Starter 远程访问受限资源


    1. 自定义 SpringBoot Starter

    在实际开发中,对于一些通用业务和公共组件,我们可能想将其做成一个Spring Boot Starter便于所有系统使用,这就需要我们定义自己的Spring Boot Starter。一个Spring Boot Starter都需要具备哪些能力:

    ① 提供了统一的dependency版本管理:仅需要导入对应的Starter依赖,相关的library,甚至是中间件,都一次性被引入了,而且要保证各dependency之间是不冲突的。例如当我们引入mybatis-spring-boot-starter依赖,mybatis和mybatis-spring等相关依赖也顺带被导入了。

    ② 提供自动装配的能力:Starter可以自动的向Spring容器中注入需要的Bean,并且完成对应的配置。

    ③ 对外暴露恰当的properties:Starter不可能提前知道全部的配置信息,有些配置信息只有在应用集成这个Starter的时候才能明确。例如对于mybatis,configLocation、mapperLocation这些参数在每个项目中都可能不同,所以只有应用自己知道这些参数的值该是什么。mybatis-spring-boot-starter对外暴露了一组properties,例如如果我们想指定mapper文件的存放位置,只需要在application.properties中添加mybatis.mapperLocations=classpath:mapping/*.xml即可

    1. 统一的dependency管理

    创建 view-spring-boot-starter 项目并导入依赖,利用maven的间接依赖特性,在Starter的maven pom.xml中声明所有需要的dependency,这样在项目工程导入这个Starter时,相关的依赖就都被一起导入了。下面是 view-spring-boot-starter 的 pom.xml 。

    <artifactId>view-spring-boot-starterartifactId>
    
    <dependencies>
        
        <dependency>
            <groupId>org.apache.commonsgroupId>
            <artifactId>commons-lang3artifactId>
        dependency>
        <dependency>
            <groupId>org.springframework.cloudgroupId>
            <artifactId>spring-cloud-netflix-eureka-clientartifactId>
        dependency>
        <dependency>
            <groupId>com.netflix.eurekagroupId>
            <artifactId>eureka-clientartifactId>
        dependency>
    dependencies>
    

    2. 对外暴露 properties

    在包 config 下创建类 ViewProperties。实现属性配置:

    @ConfigurationProperties(prefix = "hh.view")
    @Data
    public class ViewProperties {
        private final static String DEFAULT_VIEW_TEMPLATE_PATH = "classpath:/view-template";
    
        /**
         * 统一化模板保存路径,默认在 classpath:/view-template 目录下
         */
        private String templatePath = DEFAULT_VIEW_TEMPLATE_PATH;
    }
    

    3. 实现自动装配

    在包config下创建类ViewAutoConfiguration。实现自动配置,把服务注入到Spring中

    @Configuration
    @Slf4j
    @ComponentScan(value = "com.hh.view")
    @EnableConfigurationProperties(ViewProperties.class)
    public class ViewAutoConfiguration {
        
        // 向 Spring 容器中注册 ServiceRegisterClient
        @Bean
        public ServiceRegisterClient serviceRegisterClient(ApplicationContext applicationContext) {
            return new FeignClientBuilder(applicationContext).forType(ServiceRegisterClient.class, "view").build();
        }
    
        // 向 Spring 容器中注册 ViewTemplateRegisterClient
        @Bean
        public ViewTemplateRegisterClient viewTemplateRegisterClient(ApplicationContext applicationContext) {
            return new FeignClientBuilder(applicationContext).forType(ViewTemplateRegisterClient.class, "view").build();
        }
    }
    

    @ComponentScan 注解注解时用于配置类上的,一般和 @Configuration 注解一起使用,主要的作用就是定义包扫描的规则,Spring会去自动扫描 base-package 指定的包及其子包下的带有@Service,@Component,@Repository,@Controller注解的类,并将这些类自动装配到Spring容器内,然后交由Spring容器进行统一管理。

    @EnableConfigurationProperties 获取读取配置文件的属性并注入到ViewProperties属性配置类中。

    @Bean 向Spring容器中注入 ServiceRegisterClient 实例。

    4. 指定自动配置类的路径 META-INF\spring.factories

    在资源目录下,创建文件 META-INF\spring.factories,指定自动配置类的路径:

    # AutoConfiguration
    org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
    com.hh.view.config.ViewAutoConfiguration
    

    5. 总结

    创建自定义的 Spring Boot Starter 并不是什么难事,自定义 starter 的步骤:

    ① 确保在 pom.xml 文件中声明了使用该组件所需要的全部 dependency

    ② 利用 @ConfigurationProperties 注解对外暴露恰当的 properties

    ③ 利用条件注解 @ConditionalXXX编写XXXAutoConfiguration 类

    ④ 把写好的 XXXAutoConfiguration 类加到 META-INF/spring.factories 文件的 EnableAutoConfiguration 配置中,这样在应用启动时就会自动加载并执行 XXXAutoConfiguration。

    2. 使用自定义 SpringBoot Starter

    当其他项目需要使用 view-spring-boot-starter 时,就需要使用导入该依赖。

    1. 在新项目中引入自定义Starter依赖配置

    创建一个新的SpringBoot项目 incident ,在项目的pom.xml文件中引入自定义SpringBoot Starter的依赖配置如下:

    <dependency>
        <groupId>com.hhgroupId>
        <artifactId>ngsoc-view-spring-boot-starterartifactId>
        <version>3.0.1version>
    dependency>
    

    2. 编写属性配置文件

        hh:
          view:
            templatePath: view-template
                ngsoc:
          security:
            api-key: 78464adf485cddf6c18615341fd4ebf813f02e06722b913721009134bee3a548
    

    在 resource/view-template 目录下创建:incident.json,incidentTemplate.json、incidentRecordTemplate.json,这些文件就是统一化模板的配置文件,读取这些文件进行统一化模板的配置。

    当 incident 项目启动的时候,会去加载 view-spring-boot-starter 中的自动配置类 ViewAutoConfiguration。

    3. 业务场景

    1. 自定义Starter下的 ViewAutoConfiguration

    ① 当 incident 项目启动的时候,会去加载 view-spring-boot-starter 中的自动配置类 ViewAutoConfiguration:

    @Configuration
    @Slf4j
    @ComponentScan(value = "com.hh.view")
    @EnableConfigurationProperties(ViewProperties.class)
    public class ViewAutoConfiguration {
        
        // 向 Spring 容器中注册 ServiceRegisterClient
        @Bean
        public ServiceRegisterClient serviceRegisterClient(ApplicationContext applicationContext) {
            return new FeignClientBuilder(applicationContext).forType(ServiceRegisterClient.class, "view").build();
        }
    
        // 向 Spring 容器中注册 ViewTemplateRegisterClient
        @Bean
        public ViewTemplateRegisterClient viewTemplateRegisterClient(ApplicationContext applicationContext) {
            return new FeignClientBuilder(applicationContext).forType(ViewTemplateRegisterClient.class, "view").build();
        }
    }
    

    在该配置类上的注解 @ComponentScan(value = “com.hh.view”) 会去扫描 com.hh.view 包及其子包下所有带有@Service,@Component,@Repository,@Controller注解的类,并将这些类自动装配到Spring容器内,然后交由Spring容器进行统一管理。

    2. 自定义Starter下的 ViewTemplateInitializer 类

    ② 在 com.hh.view.inialializer 包存在 ViewTemplateInitializer 类:

    @Service
    @Slf4j
    @ComponentScan(value = "com.hh.view")
    public class ViewTemplateInitializer extends AbstractRegister implements InitializingBean {
    	// ...
    }
    

    可以看到 ViewTemplateInitializer 类加了 @Service 注解,表示该类的生命周期会交给 Spring 容器去管理,Spring容器在启动的时候会完成bean的初始化。ViewTemplateInitializer 类不仅继承了AbstractRegister 而且实现了InitializingBean:

    @Slf4j
    @RequiredArgsConstructor
    @Data
    public abstract class AbstractRegister implements InitializingBean {
    
        // 创建一个线程池,其中核心线程数10个,最大线程数20个,阻塞队列的大小为10,用来执行异步任务
        public static final ExecutorService REGISTER_POOL = new ThreadPoolExecutor(
                10, 20, 200L, TimeUnit.MICROSECONDS,
                new LinkedBlockingDeque<>(10),
                new ThreadFactoryBuilder().setNameFormat("view-register-runner-%d").build()
        );
    
        // 服务是否已经可用
        private Boolean ready = false;
    
        // 已经重试次数
        private int retryTimes = 0;
    
        // 重试等待时间
        private final int waitSeconds;
    
        /**
         *  Spring启动后,初始化Bean时,若该Bean实现 InitializingBean 接口,
         *  会自动调用 afterPropertiesSet()方法,完成一些自定义的初始化操作。
         */
        @Override
        public void afterPropertiesSet() {
            // 向线程池提交任务,当有任务到达线程池后,就会创建创建一个核心工作线程来执行线程池中的任务
            REGISTER_POOL.submit(this::execute);
        }
    
    
        public void execute() {
            while (!this.ready) {
                retryTimes++;
                try {
                    // 注册服务
                    register();
                    onSuccess();
                    this.ready = true;
                } catch (Throwable e) {
                    onFailed(e);
                    try {
                        TimeUnit.SECONDS.sleep(waitSeconds);
                    } catch (InterruptedException interruptedException) {
                        // ignore
                    }
                }
            }
    
        }
    
        @PreDestroy
        public void destroy() {
            this.ready = true;
            // 关闭线程池,该状态下线程池不再接受新任务,但是会将工作队列中的任务执行完毕。
            REGISTER_POOL.shutdown();
        }
    
        /**
         * 注册
         */
        public abstract void register();
    
        /**
         * 成功时 回调
         */
        public abstract void onSuccess();
    
        /**
         * 失败时 回调
         *
         * @param e 异常
         */
        public abstract void onFailed(Throwable e);
    }
    

    我们知道Spring启动后,初始化Bean时,若该Bean实现 InitializingBean 接口,会自动调用 afterPropertiesSet()方法,完成一些自定义的初始化操作。

    ① 创建一个线程池,其中核心线程数10个,最大线程数20个,阻塞队列的大小为10,用来执行异步任务;

    ② 当Spring容器启动后初始化 AbstractRegister 后,会去执行 afterPropertiesSet() 方法,在该方法中向线程池中提交任务;

    ③ 线程池在收到任务后会创建一个工作线程来执行该任务。

    ④ 最终会执行 AbstractRegister 子类 ViewTemplateInitializer 的 registerRemote() 方法。

    @Service
    @Slf4j
    @ComponentScan(value = "com.hh.view")
    public class ViewTemplateInitializer extends AbstractRegister implements InitializingBean {
    
        @Setter(onMethod_ = @Autowired)
        private ViewTemplateRegisterClient viewTemplateRegisterClient;
    
        // 省略。。。
    
        @Override
        public void register() {
            // 注册到view服务上
            registerRemote(viewTemplateProvider.provide());
        }
    
        // 远程注册统一化模板
        private void registerRemote(List<ViewTemplateRegistry> viewTemplates) {
    
            RegisterQo<List<ViewTemplateRegistry>> registerQo = new RegisterQo<>();
            registerQo.setRegisterBody(viewTemplates);
            registerQo.setAppName(this.appName);
            
            // 探讨的重点!!!!!!这里就是我今天引入的入口!!!!!!!!!!!!!!!!
            final ApiResponse<Void> result = viewTemplateRegisterClient.register(registerQo);
    
            log.info("注册统一化模板结果为:{}", result);
            if (result.getCode() != ApiResponse.CODE_OK) {
                throw new DataQueryException("注册统一化模板失败,原因:" + result.getData());
            }
        }
    }
    

    可以看到在该方法中会远程调用 view 服务下的 register() 方法:

    @FeignClient(value = "view")
    public interface ViewTemplateRegisterClient {
        /**
         * 注册服务
         *
         * @param registerQo 注册内容
         * @return 注册结果
         */
        @RequestMapping(value = "/VIEW/api/v1/view/template/register", method = RequestMethod.POST)
        @SystemRequest
        ApiResponse<Void> register(@RequestBody RegisterQo<List<ViewTemplateRegistry>> registerQo);
    }
    
    @RestController
    @RequestMapping("/api/v1/view/template")
    @ResponseResult
    @Api(tags = "页面模板管理")
    @Validated
    public class ViewTemplateController {
        
        @Setter(onMethod_ = @Autowired)
        private ViewTemplateService viewTemplateService;
    
        @ApiOperation("注册页面模板")
        @PostMapping("/register")
        @PreAuthorize("hasAnyAuthority('superAdmin')")
        public void register(@RequestBody @Validated RegisterQo<List<ViewTemplateRegistry>> registerQo) {
            viewTemplateService.register(registerQo.getAppName(), registerQo.getRegisterBody());
        }
    }
    

    那么问题来了,在 incident 项目启动时,需要加载自定义的 Spring Boot Starter,而在我们自定义的 view-spring-boot-starter 项目下的 ViewTemplateInitializer 中远程调用了 view 项目中的统一化模板注册接口,由于项目使用 SpringSecurity Oauth2 搭建了认证服务器和授权服务器,因此访问 view 项目中的受限资源时,需要带着 accessToken,而此时由于系统仍然在后台启动中未处于登录状态,所以访问 view 项目下的请求时并未携带 accessToken 导致无权限访问。

    即当访问 /api/v1/view/template/register 路径下的受限资源时,需要携带 access_token ,此时就需要自定义认证方式来实现获取 accessToken 来访问系统受限资源。

    我们可以看到 ViewTemplateRegisterClient 的 register() 方法上加了一个自定义注解 @SystemRequest,该自定义注解 @SystemRequest 中实现了通过自定义认证方式获取 access_token 并访问受限资源的功能,下面重点来看这个注解吧。

    3. 自定义注解 @SystemRequest

    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface SystemRequest {
        
    }
    
    @ConfigurationProperties("ngsoc.security")
    @Data
    public class FeignRequestProperties {
        /**
         * apikey
         */
        private String apiKey;
    
        /**
         * token类型 默认是bearer
         */
        private String tokenType;
        /**
         * 登录地址
         */
        private String loginUrl;
    }
    
    @Slf4j
    public class SystemRequestHandler {
        
        public final RedisTemplate<String, String> redisTemplate;
    
        public final ReentrantLock lock = new ReentrantLock();
    
        /**
         * 当前登录的token
         */
        public volatile String token;
    
        /**
         * feign请求的属性
         */
        public final FeignRequestProperties properties;
    
        public SystemRequestHandler(RedisTemplate<String, String> redisTemplate, FeignRequestProperties properties) {
            this.redisTemplate = redisTemplate;
            this.properties = properties;
        }
    
        /**
         * 尝试通过ApiKey登录
         *
         * @param requestTemplate 请求模板
         */
        public void tryLoginByApiKey(RequestTemplate requestTemplate) {
            lock.lock();
            try {
                // token 超时则尝试登录
                if (StringUtils.isBlank(this.token) || !redisTokenExists(this.token)) {
                    login();
                }
                requestTemplate.header("Authorization", properties.getTokenType() + " " + this.token);
            } catch (Exception e) {
                log.error("API 登陆失败", e);
            } finally {
                lock.unlock();
            }
        }
    
        /**
         * 校验token是否存在
         *
         * @param token token
         * @return 校验结果
         */
        private boolean redisTokenExists(final String token) {
            return ngsocAuthTokenExists(token) && oAuthTokenExists(token);
        }
    
        /**
         * redis中token是否存在
         *
         * @param token token
         * @return redis中是否存在这个token
         */
        public boolean ngsocAuthTokenExists(final String token) {
            if (StringUtils.isBlank(token)) {
                return false;
            }
            final Boolean isTokenExits = redisTemplate.hasKey(RedisKeyUtil.getAuthAccessTokenKey(token));
            return Objects.nonNull(isTokenExits) && isTokenExits;
        }
    
        /**
         * redis中token是否存在
         *
         * @param token token
         * @return redis中是否存在这个token
         */
        public boolean oAuthTokenExists(String token) {
            if (StringUtils.isBlank(token)) {
                return false;
            }
            final Boolean isTokenExits = redisTemplate.hasKey(RedisKeyUtil.getOauthTokenKey(token));
            return Objects.nonNull(isTokenExits) && isTokenExits;
        }
    
        /**
         * 判断当前feign方法是否需要登录apiKey
         *
         *
         * @param methodMetadata 方法元数据
         * @return 是否需要登录apiKey
         */
        public boolean isSystemRequest(MethodMetadata methodMetadata) {
            final String apiKey = properties.getApiKey();
            final Method method = methodMetadata.method();
            return method.isAnnotationPresent(SystemRequest.class) && Objects.nonNull(apiKey);
        }
    
        /**
         * 尝试登录并初始化
         */
        public void login() throws AuthenticationException {
            RestTemplate restTemplate = new RestTemplate();
            // 构造接口登录参数(body)
            Map<String, String> loginParams = new HashMap<>(1);
            loginParams.put("apiKey", properties.getApiKey());
            // 发送接口请求
            String tokenUrl = properties.getLoginUrl();
            log.info("尝试使用API-KEY登陆 :{}, 参数 :{} ", tokenUrl, loginParams);
            HttpEntity<Map<String, String>> request = new HttpEntity<>(
                    loginParams
            );
            final ParameterizedTypeReference<ApiResponse<ApiKeyToken>> typeReference = new ParameterizedTypeReference<>() {
            };
            
            // 在这里会调用 SpringSecurity Oauth2 的 自定义认证方式
            final ResponseEntity<ApiResponse<ApiKeyToken>> result = restTemplate
                    .exchange(tokenUrl, HttpMethod.POST, request, typeReference);
            log.info("API-KEY登陆结果 :{}", result);
            
            Objects.requireNonNull(result.getBody());
            Objects.requireNonNull(result.getBody().getData());
            if (result.getBody().getCode() != ApiResponse.CODE_OK) {
                throw new AuthenticationException(result.getBody().getMessage());
            }
            this.token = result.getBody().getData().getAccessToken();
            log.info("成功刷新token缓存, token={}", this.token);
        }
    }
    

    具体如何实现自定义认证方式,我们下篇文章再看吧。

  • 相关阅读:
    SpreadsheetGear V9 2023 Crack
    计算机毕业设计之java+javaweb的面向社区健康服务的医疗平台
    LabVIEW使用VI Package Manager(VIPM)下载和管理附加组件
    【教3妹学算法-每日1题】使数组中所有元素都等于零
    图神经网络入门基础
    MTK手机平台充电原理
    网络基本类型
    代码随想录算法训练营Day60 | 单调栈(3/3) LeetCode 84.柱状图中最大的矩形
    Mysql集群及高可用-半同步模式(AFTER_SYNC)4
    【斗破年番】彩鳞换装美翻,雁落天惨死,萧炎暗杀慕兰三老遇险,彩鳞霸气护夫
  • 原文地址:https://blog.csdn.net/qq_42764468/article/details/127112149