【Spring Cloud Gateway】基于 URL 的鉴权实现

4,853 阅读4分钟

前言

一般的资源服务器鉴权的实现都比较简单,在JWT作为access_token的情况下,只需要配置JwtTokenStore即可使用@PreAuthorize和@PostAuthorize等注解进行基于角色权限的鉴权。

然而在Spring Cloud Gateway中,使用得是基于Web Flux的鉴权,在配置和实现上与之前有所区别。且我们需要在网关实现基于URL的鉴权,因此需要进行自定义实现。

整体结构

网关对请求的主要处理流程包括ReactiveAuthenticationManager->ReactiveAuthorizationManager->Gateway Filters。其中ReactiveAuthenticationManager用于封装JWT为OAuth2Authentication并判断Token的有效性;ReactiveAuthorizationManager用于基于URL的鉴权;Gateway Filters是网关的过滤流程。

ReactiveAuthorizationManager实现

实现该接口主要是为了鉴权,我们在这里使用URL进行鉴权,通过获取用户Authentication里的Authorities,判断是否允许访问该路径。

AccessManager实现

这里只是简单的判断是否是跨域的检验请求,是的话直接放行。具体的鉴权实现在UrlAuthorityChecker里。

/**
 * 描述:鉴权管理器
 *
 * @author xhsf
 * @create 2020/11/26 11:26
 */
@Component
public class AccessManager implements ReactiveAuthorizationManager<AuthorizationContext> {

    private final UrlAuthorityChecker urlAuthorityChecker;

    public AccessManager(UrlAuthorityChecker urlAuthorityChecker){
        this.urlAuthorityChecker = urlAuthorityChecker;
    }

    /**
     * 实现权限验证判断
     */
    @Override
    public Mono<AuthorizationDecision> check(Mono<Authentication> authenticationMono,
                                             AuthorizationContext authorizationContext) {
        ServerHttpRequest request = authorizationContext.getExchange().getRequest();
        // 对应跨域的预检请求直接放行
        if (request.getMethod() == HttpMethod.OPTIONS) {
            return Mono.just(new AuthorizationDecision(true));
        }

        // 进行鉴权
        String url = request.getURI().getPath();
        return authenticationMono
                .map(auth -> new AuthorizationDecision(urlAuthorityChecker.check(auth.getAuthorities(), url)))
                .defaultIfEmpty(new AuthorizationDecision(false));
    }

}

UrlAuthorityChecker实现

这里有两个重点,一个是权限名与授权路径的对应关系,比如get_user权限可以访问/users/*路径。另外一个是白名单,比如/oauth/**为白名单里面的路径。但是我们并不想直接在代码里面把这两个配置写死,因为有可能会需要修改权限名与授权路径的对应关系和白名单列表,因此我们添加定时任务,定时的更新这两个列表,同时把白名单作为一个服务,可以通过相应的接口(或者图形界面)进行修改。

/**
 * 描述:基于Url的权限检查器
 *
 * @author xhsf
 * @create 2020/11/26 15:10
 */
@Component
@EnableScheduling
public class UrlAuthorityChecker {

    @Reference
    private PermissionService permissionService;

    @Reference
    private WhiteListService whiteListService;

    /**
     * 刷新permissionNameAuthorizationUrlMap的间隔
     */
    private static final int REFRESH_DELAY = 60000;

    /**
     * 权限名和授权URL的对应关系
     */
    private Map<String, String> permissionNameAuthorizationUrlMap;

    /**
     * 路径白名单
     */
    private List<String> whiteList;

    private final AntPathMatcher antPathMatcher;

    public UrlAuthorityChecker(AntPathMatcher antPathMatcher) {
        this.antPathMatcher = antPathMatcher;
    }

    /**
     * 通过用户拥有的权限进行鉴权
     *
     * @param authorities 权限列表
     * @param url 请求的Url
     * @return 是否通过鉴权
     */
    public boolean check(Collection<? extends GrantedAuthority> authorities, String url) {
        // 判断该路径是否在白名单里,如果是直接放行
        for (String permittedUrl : whiteList) {
            if (antPathMatcher.match(permittedUrl, url)) {
                return true;
            }
        }

        // 判断用户的权限里有没有满足获取该路径资源的
        for (GrantedAuthority authority : authorities) {
            String authorizationUrl = permissionNameAuthorizationUrlMap.get(authority.getAuthority());
            if (authorizationUrl != null && antPathMatcher.match(authorizationUrl, url)) {
                return true;
            }
        }
        return false;
    }

    /**
     * 创建newPermissionNameAuthorizationUrlMap并替换旧的
     * 这里添加定时任务,会每REFRESH_DELAY毫秒刷新一次permissionNameAuthorizationUrlMap
     */
    @Scheduled(initialDelay = 0, fixedDelay = REFRESH_DELAY)
    private void updatePermissionNameAuthorizationUrlMap() {
        List<PermissionDTO> permissionDTOList = permissionService.getAllPermission().getData();
        Map<String, String> newPermissionNameAuthorizationUrlMap = new ConcurrentHashMap<>();
        for (PermissionDTO permissionDTO : permissionDTOList) {
            newPermissionNameAuthorizationUrlMap.put(
                    ResourceServerConstant.AUTHORITY_PREFIX + permissionDTO.getPermissionName(),
                    permissionDTO.getAuthorizationUrl());
        }
        permissionNameAuthorizationUrlMap = newPermissionNameAuthorizationUrlMap;
    }

    /**
     * 创建newWhiteList并替换旧的
     * 这里添加定时任务,会每REFRESH_DELAY毫秒刷新一次whiteList
     */
    @Scheduled(initialDelay = 0, fixedDelay = REFRESH_DELAY)
    private void updateWhiteList() {
        Result<List<String>> getWhiteListResult = whiteListService.getWhiteList();
        whiteList = getWhiteListResult.getData();
    }
}

跨域配置

这里简单的添加了CorsConfigurationSource,该Bean会被Spring Boot自动识别,并构造一个CorsWebFilter进行跨域过滤,只要在ServerHttpSecurity里配置了cors()。

/**
 * 描述:跨域配置
 *
 * @author xhsf
 * @create 2020/11/28 0:34
 */
@Configuration
public class CorsConfig {

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedOrigin("*");
        corsConfiguration.addAllowedHeader("*");
        corsConfiguration.addAllowedMethod("*");
        corsConfiguration.setAllowCredentials(true);
        source.registerCorsConfiguration("/**", corsConfiguration);
        return source;
    }

}

ResourceServerConfig配置

这里把网关作为资源服务器,进行鉴权操作。该类是用来配置各种过滤器,以实现具体的鉴权逻辑。具体如下代码注释。

/**
 * 描述:资源服务器配置
 *
 * @author xhsf
 * @create 2020/11/27 15:19
 */
@EnableWebFluxSecurity
public class ResourceServerConfig {

    private final AccessManager authorizationManager;
    private final CustomServerAccessDeniedHandler customServerAccessDeniedHandler;
    private final CustomServerAuthenticationEntryPoint customServerAuthenticationEntryPoint;

    public ResourceServerConfig(AccessManager authorizationManager,
                                CustomServerAccessDeniedHandler customServerAccessDeniedHandler,
                                CustomServerAuthenticationEntryPoint customServerAuthenticationEntryPoint) {
        this.authorizationManager = authorizationManager;
        this.customServerAccessDeniedHandler = customServerAccessDeniedHandler;
        this.customServerAuthenticationEntryPoint = customServerAuthenticationEntryPoint;
    }

    /**
     * Web Security过滤器链配置
     * @param http ServerHttpSecurity
     * @return SecurityWebFilterChain
     */
    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
        // JWT处理
        http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(jwtAuthenticationConverter());

        // 自定义处理JWT请求头过期或签名错误的结果
        http.oauth2ResourceServer().authenticationEntryPoint(customServerAuthenticationEntryPoint);

        // 添加鉴权过滤器
        http.authorizeExchange()
                    .anyExchange().access(authorizationManager)
                .and()
                    // 鉴权异常处理
                    .exceptionHandling()
                        .accessDeniedHandler(customServerAccessDeniedHandler) // 处理未授权
                        .authenticationEntryPoint(customServerAuthenticationEntryPoint) //处理未认证
                .and()
                    .csrf().disable()
                    .cors();
        return http.build();
    }

    /**
     * ServerHttpSecurity没有将jwt中authorities的负载部分当做Authentication
     * 需要把jwt的Claim中的authorities加入
     * 重新定义ReactiveAuthenticationManager权限管理器,添加默认转换器JwtGrantedAuthoritiesConverter
     * @return ReactiveJwtAuthenticationConverterAdapter
     */
    @Bean
    public Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        jwtGrantedAuthoritiesConverter.setAuthorityPrefix(ResourceServerConstant.AUTHORITY_PREFIX);
        jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(ResourceServerConstant.AUTHORITIES_CLAIM_NAME);

        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
        return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
    }

}

其他代码

CustomServerAccessDeniedHandler

捕获AccessDeniedException并返回自定义响应结果。

/**
 * 描述:无权访问自定义响应
 *
 * @author xhsf
 * @create 2020/11/26 19:32
 */
@Component
public class CustomServerAccessDeniedHandler implements ServerAccessDeniedHandler {

    @Override
    public Mono<Void> handle(ServerWebExchange exchange, AccessDeniedException e) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.FORBIDDEN);
        response.getHeaders().set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
        response.getHeaders().set("Access-Control-Allow-Origin", "*");
        response.getHeaders().set("Cache-Control", "no-cache");
        String body = JSON.toJSONString(Result.fail(ErrorCode.FORBIDDEN, e.getMessage()));
        DataBuffer buffer =  response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
        return response.writeWith(Mono.just(buffer));
    }
}

CustomServerAuthenticationEntryPoint

捕获AuthenticationException并返回自定义响应结果。

/**
 * 描述:无效token/token过期 自定义响应
 *
 * @author xhsf
 * @create 2020/11/26 19:34
 */
@Component
public class CustomServerAuthenticationEntryPoint implements ServerAuthenticationEntryPoint {

    @Override
    public Mono<Void> commence(ServerWebExchange exchange, AuthenticationException e) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.UNAUTHORIZED);
        response.getHeaders().set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
        response.getHeaders().set("Access-Control-Allow-Origin", "*");
        response.getHeaders().set("Cache-Control", "no-cache");
        String body = JSON.toJSONString(Result.fail(ErrorCode.UNAUTHORIZED, e.getMessage()));
        DataBuffer buffer =  response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
        return response.writeWith(Mono.just(buffer));
    }

}