基于SpringCloudGateway+SpringSecurity的微服务鉴权方案

1.Spring Cloud Gateway集成Spring Security#

网关只进行服务级别的认证,不做授权逻辑判断

1.1 Spring Secuty核心配置#

核心代码如下:

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
@EnableWebFluxSecurity
public class WebSecurityConfiguration {

@Autowired
private JwtTokenAuthenticationManager jwtTokenAuthenticationManager;

@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
// 鉴权配置
AuthenticationWebFilter authenticationWebFilter = new AuthenticationWebFilter(jwtTokenAuthenticationManager);
authenticationWebFilter.setServerAuthenticationConverter(new JwtTokenAuthenticationConverter());
authenticationWebFilter.setSecurityContextRepository(new WebSessionServerSecurityContextRepository());
http.addFilterAt(authenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION);

// 清除一次请求后的SecurityContext
http.addFilterBefore(new ClearExchangeSecurityContextWebFilter(), SecurityWebFiltersOrder.REACTOR_CONTEXT);

// 跨域配置
http.cors().configurationSource(corsConfigurationSource());

http.csrf().disable();

http.authorizeExchange()
.pathMatchers("/**/login").permitAll()
.pathMatchers("/**/logout").authenticated()
.pathMatchers("/upms/**").authenticated()
.pathMatchers("/product/**").authenticated()
.anyExchange().denyAll();

return http.build();
}

@Bean
public CorsConfigurationSource corsConfigurationSource() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();

config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.addAllowedOrigin("*");
config.setAllowCredentials(true);
config.setMaxAge(3600L);

source.registerCorsConfiguration("/**", config);
return source;
}
}

上述鉴权配置使用Spring Security自带的AuthenticationWebFilter类,对其进行定制

1.2 定制ServerAuthenticationConverter,用于匹配并生成所需Authentication(即JwtTokenAuthenticationToken)#

自定义JwtTokenAuthenticationConverter核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class JwtTokenAuthenticationConverter implements ServerAuthenticationConverter {

@Override
public Mono<Authentication> convert(ServerWebExchange exchange) {
return apply(exchange);
}

public Mono<Authentication> apply(ServerWebExchange exchange) {
ServerHttpRequest request = exchange.getRequest();

String token = request.getHeaders().getFirst(HeaderNames.X_TOKEN);
if (StringUtils.isBlank(token)) {
return Mono.empty();
}

return Mono.just(new JwtTokenAuthenticationToken(token));
}
}

自定义JwtTokenAuthenticationToken核心代码如下:

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
public class JwtTokenAuthenticationToken extends AbstractAuthenticationToken {

private String token;
private Long userId;

public JwtTokenAuthenticationToken(Collection<? extends GrantedAuthority> authorities) {
super(authorities);
}

public JwtTokenAuthenticationToken(String token) {
super(null);
this.token = token;
}

public void setUserId(Long userId) {
this.userId = userId;
}

public Long getUserId() {
return this.userId;
}

@Override
public Object getCredentials() {
return token;
}

@Override
public Object getPrincipal() {
return null;
}

@Override
public boolean implies(Subject subject) {
return false;
}

1.3 定制ReactiveAuthenticationManager,用于执行鉴权逻辑#

自定义JwtTokenAuthenticationManager核心代码如下:

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
@Slf4j
@Component
public class JwtTokenAuthenticationManager implements ReactiveAuthenticationManager {

@Autowired
private AuthFeignService authFeignService;

@Override
public Mono<Authentication> authenticate(Authentication authentication) throws AuthenticationException {
JwtTokenAuthenticationToken requestToken = (JwtTokenAuthenticationToken) authentication;
String token = (String) requestToken.getCredentials();

AuthDto authDto = new AuthDto();
authDto.setToken(token);
// 服务级别只认证,不做授权逻辑判断
authDto.setOnlyAuthenticate(true);
ResultVo<AuthVo> resultVo = authFeignService.auth(authDto);
if (resultVo.getCode() != ErrorCodeEnum.SUCCESS.getCode() || !resultVo.getData().getAuthenticated()) {
log.error("调用auth服务鉴权失败!");
return Mono.error(new BadCredentialsException("调用auth服务鉴权失败!"));
}

// set authorities
List<GrantedAuthority> authorities = Collections.emptyList();
JwtTokenAuthenticationToken resultToken = new JwtTokenAuthenticationToken(authorities);
resultToken.setAuthenticated(true);
resultToken.setUserId(resultVo.getData().getUserId());

return Mono.just(resultToken);
}
}

1.4 定制ServerSecurityContextRepository,用于存取SecurityContext(安全上下文)#

由于Spring Cloud Gateway基于WebFlux构建,有别于javax servlet模型,不可使用基于ThreadLocal的SecurityContextHolder存取SecurityContext
可以使用Spring Security的基于session的WebSessionServerSecurityContextRepository类
代码见源码:WebSessionServerSecurityContextRepository

1.5 清除安全上下文#

由于访问凭证使用的是jwt token方案,而Spring Security的WebSessionServerSecurityContextRepository会使用到session
出现一个问题,当执行一次请求,触发鉴权操作,会生成一个session,即使jwt token失效后或者不带上jwt token,浏览器会自动带上sessionid,而服务端的session可能还未失效,这时该次请求会从session中获取缓存的已认证通过的Authentication
解决方案之一:使session和jwt token的过期时间保持一致,配置Spring Security的logout以使调用登出接口时清除相应SecurityContext
解决方案之二:自定义ServerSecurityContextRepository,避开session
解决方案之三:每次请求结束前清除相应的SecurityContext

此处使用方案三,核心代码如下:

1
2
3
4
5
6
7
8
9
public class ClearExchangeSecurityContextWebFilter implements WebFilter {

private ServerSecurityContextRepository securityContextRepository = new WebSessionServerSecurityContextRepository();

@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
return chain.filter(exchange).then(securityContextRepository.save(exchange, null));
}
}

1.6 网关向下游服务传递Header参数#

网关做完鉴权操作后,要向下游服务传递所需的(Header)参数,比如userId
使用自定义的Spring Cloud Gateway的GlobalFilter

核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class GatewayWebContextGlobalFilter implements GlobalFilter {

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.filter(authentication -> authentication instanceof JwtTokenAuthenticationToken)
.map(authentication -> (JwtTokenAuthenticationToken) authentication)
.flatMap(authentication -> {
String headerName = HeaderNames.X_USER_ID;
String headerValue = String.valueOf(authentication.getUserId());

ServerHttpRequest request = exchange.getRequest().mutate().header(headerName, headerValue).build();

return chain.filter(exchange.mutate().request(request).build());
})
.switchIfEmpty(chain.filter(exchange));
}
}

配置自定义GlobalFilter,核心代码如下:

1
2
3
4
5
6
7
8
@Configuration
public class GatewayConfiguration {

@Bean
public GatewayWebContextGlobalFilter gatewayWebContextGlobalFilter() {
return new GatewayWebContextGlobalFilter();
}
}

2.下游服务集成Spring Security#

下游服务做接口方法级别的鉴权
需要开启@EnableGlobalMethodSecurity
需要鉴权的接口方法添加@PreAuthorize注解

2.1 Spring Security核心配置#

核心代码如下:

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
@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableWebSecurity
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {

@Autowired
private JwtTokenAuthenticationProvider jwtTokenAuthenticationProvider;

@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http.cors().disable();

http.addFilterAfter(new JwtTokenAuthenticationFilter(authenticationManagerBean()), LogoutFilter.class);
}

/**
* 如果重写了authenticationManagerBean(),需要同时重写该方法
*
* @see WebSecurityConfigurerAdapter#authenticationManagerBean()
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) {
auth.authenticationProvider(jwtTokenAuthenticationProvider);
}

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}

2.2 自定义Authentication#

核心代码如下:

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
public class JwtTokenAuthenticationToken extends AbstractAuthenticationToken {

private String token;

public JwtTokenAuthenticationToken(Collection<? extends GrantedAuthority> authorities) {
super(authorities);
}

public JwtTokenAuthenticationToken(String token) {
super(null);
this.token = token;
}

@Override
public Object getCredentials() {
return token;
}

@Override
public Object getPrincipal() {
return null;
}

@Override
public boolean implies(Subject subject) {
return false;
}
}

2.3 自定义Filter#

核心代码如下:

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
@Slf4j
public class JwtTokenAuthenticationFilter extends OncePerRequestFilter {

private AuthenticationManager authenticationManager;

public JwtTokenAuthenticationFilter(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {

logger.debug("doFilterInternal()");

// If required, authenticate and set web context
authenticateIfRequired(request);

filterChain.doFilter(request, response);

// Clear web context
WebContextUtils.clear();
}

/**
* Authenticate if required
*/
private void authenticateIfRequired(HttpServletRequest request) {
String token = request.getHeader(Common.TOKEN_HEADER_KEY);
if (StringUtils.isBlank(token)) {
log.debug("no token, as anonymous in later AnonymousAuthenticationFilter");
// // 在过滤链AnonymousAuthenticationFilter中设置为匿名用户
// // 这里要先clear,否则在匿名Filter中会存在JwtTokenAuthenticationToken;具体原因暂时未知
SecurityContextHolder.clearContext();
return;
}

// wrap request authentication
Authentication authRequest = new JwtTokenAuthenticationToken(token);
// authenticate for result (delegate by ProviderManager)
Authentication authResult = authenticationManager.authenticate(authRequest);
// set to security context
SecurityContextHolder.getContext().setAuthentication(authResult);
}
}

2.4 自定义AuthenticationProvider#

在网关已经做了认证,此处主要是做授权

核心代码如下:

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
@Slf4j
@Component
public class JwtTokenAuthenticationProvider implements AuthenticationProvider {

@Autowired
private AuthFeignService authFeignService;

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
JwtTokenAuthenticationToken requestToken = (JwtTokenAuthenticationToken) authentication;
String token = (String) requestToken.getCredentials();

AuthDto authDto = new AuthDto();
authDto.setToken(token);
ResultVo<AuthVo> resultVo = authFeignService.auth(authDto);
if (resultVo.getCode() != ErrorCodeEnum.SUCCESS.getCode() || !resultVo.getData().getAuthenticated()) {
log.error("调用auth服务鉴权失败!");
throw new BaseException(ErrorCodeEnum.AUTH_FAIL, "调用auth服务鉴权失败!");
}

// set web context
WebContextUtils.setToken(token);
WebContextUtils.setUserId(resultVo.getData().getUserId());

AuthVo authVo = resultVo.getData();
// role
List<String> listAuthority = authVo.getRoles().stream().map(s -> "ROLE_" + s).collect(Collectors.toList());
// add resource
listAuthority.addAll(authVo.getResources());
String commaSeparatedRoles = String.join(",", listAuthority);

// set authorities
List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList(commaSeparatedRoles);
// new authentication
JwtTokenAuthenticationToken resultToken = new JwtTokenAuthenticationToken(authorities);
resultToken.setAuthenticated(true);

return resultToken;
}

@Override
public boolean supports(Class<?> authentication) {
return JwtTokenAuthenticationToken.class.isAssignableFrom(authentication);
}
}

2.5 网关向下游服务传递Header参数,此处做提取并放置到WebContext(Utils)中#

核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Slf4j
public class ServiceWebContextFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {

String token = request.getHeader(HeaderNames.X_TOKEN);
String userId = request.getHeader(HeaderNames.X_USER_ID);

log.debug("WebContext.token: {}", token);
log.debug("WebContext.userId: {}", userId);

if (token != null) {
WebContextUtils.setToken(token);
}
if (userId != null) {
WebContextUtils.setUserId(Long.parseLong(userId));
}

filterChain.doFilter(request, response);
}
}

配置提取Header参数的Filter,核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class ContextConfiguration {

@Bean
public FilterRegistrationBean<ServiceWebContextFilter> serviceWebContextFilter() {
FilterRegistrationBean<ServiceWebContextFilter> registrationBean = new FilterRegistrationBean<>();

registrationBean.setFilter(new ServiceWebContextFilter());
registrationBean.setOrder(Ordered.LOWEST_PRECEDENCE);

return registrationBean;
}
}

3.网关跨域的问题#

Spring Cloud Gateway集成Spring Security后出现跨域(Gateway配置中已经开启跨域配置并允许所有)

解决方案:
Spring Security也添加跨域配置

1
2
3
4
5
6
7
8
9
10
11
12
@Bean
public CorsConfigurationSource corsConfigurationSource() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.addAllowedOrigin("*");
config.setAllowCredentials(true);
config.setMaxAge(3600L);
source.registerCorsConfiguration("/**", config);
return source;
}

仅添加以上配置即可(Spring Security会获取到),也可以额外显式设置一下,在 SecurityWebFilterChain 配置中:
http.cors().configurationSource(corsConfigurationSource());

以上解决方案的原因:
按照网上的说法,Gateway的跨域处理较Spring Security靠后,Spring Security发现跨域问题后直接响应了
但是对Spring Security的cors进行关闭也无效:http.cors().disable();
具体细节暂不清楚

4.网关OpenFeign调用错误的问题#

错误信息:feign.codec.EncodeException: No qualifying bean of type ‘org.springframework.boot.autoconfigure.http.HttpMessageConverters’ available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)}
原因:OpenFeign与基于WebFlux的Gateway不能完美适配导致报错
解决方案:

1
2
3
4
5
@Bean
@ConditionalOnMissingBean
public HttpMessageConverters messageConverters(ObjectProvider<HttpMessageConverter<?>> converters) {
return new HttpMessageConverters(converters.orderedStream().collect(Collectors.toList()));
}

按照官方说法,以上解决方案有缺陷,因为HttpMessageConvert会阻塞,可能会造成WebFlux应用中断
https://github.com/spring-cloud/spring-cloud-openfeign/issues/235

Spring官方目前推荐的做法是使用第三方的替代方案(feign-reactive https://github.com/Playtika/feign-reactive)
https://cloud.spring.io/spring-cloud-openfeign/reference/html/#reactive-support

由于不熟悉后者的reactive编程,所以暂时仍然使用前者

5.版本#

  • Spring Cloud Gateway 2.2.5
  • Spring Security 5.3.5