快速搭建微服务-服务安全

微服务架构下的服务安全是构建微服务系统的一个重要环节。做好服务鉴权是保障数据不泄漏、不被非法操作的关键。

Spring Cloud架构支持OAuth2 + Spring Security的方式进行服务鉴权,只需简单配置即可。同时我们也可以在网关服务里加入自定义的鉴权Filter实现服务鉴权。

采用OAuth2 + Spring Security的方式进行服务鉴权时,如果同时使用了Hystrix断路器,就会出现后台服务之间进行调用时access_token无法在服务间传递的问题,其根本原因是Hystix的默认隔离策略是Thread(即线程隔离),这样就会导致服务间调用时没有将access_token进行传递,导致鉴权失败。此问题的具体解决办法可以在 实用技巧:Hystrix传播ThreadLocal对象(两种方案) 中找到。

本文对OAuth2 + Spring Security和自定义鉴权Filter都进行说明。

OAuth2 + Spring Security方式

采用OAuth2 + Spring Security方式需要区分鉴权服务和资源服务。

api-gateway网关服务

  • Maven 依赖
1
2
3
4
5
6
7
8
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
  • 配置信息
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
zuul:
routes:
api-order:
path: /api-order/**
service-id: service-order
sensitiveHeaders: Cookie,Set-Cookie
api-goods:
path: /api-goods/**
service-id: service-goods
sensitiveHeaders: Cookie,Set-Cookie
auth:
path: /auth/**
service-id: auth-server
sensitiveHeaders: Cookie,Set-Cookie

spring:
application:
name: api-gateway

security:
oauth2:
client:
access-token-uri: http://localhost:9080/auth/oauth/token
user-authorization-uri: http://localhost:9080/auth/oauth/authorize
client-id: webapp
resource:
user-info-uri: http://localhost:9080/auth/user
prefer-token-info: false

在配置zuul.routes网关路由时,需要注意sensitiveHeaders需要配置Cookie,Set-Cookie,这样才能在请求网关时携带token并在刷新之后返回token。

  • SecurityConfig.java
1
2
3
4
5
6
7
8
9
@Configuration
@EnableOAuth2Sso
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

auth-server鉴权服务

  • Maven 依赖
1
2
3
4
5
6
7
8
9
10
11
12
13
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
<optional>true</optional>
</dependency>
  • 配置信息
1
2
3
4
security:
oauth2:
resource:
filter-order: 3

启动类加上@EnableAuthorizationServer注解声明当前应用为鉴权服务端。

  • SecurityConfig.java
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
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private UserDetailsServiceImpl userDetailsService;

@Bean
public ShaPasswordEncoder passwordEncoder() {
return new ShaPasswordEncoder(256);
}

@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setPasswordEncoder(passwordEncoder());
provider.setUserDetailsService(userDetailsService);
provider.setSaltSource((userDetails -> userDetails.getUsername() + AppConsts.EncryptionConsts.ENCRYPT_EXTRA_SALT));
return provider;
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(authenticationProvider()).userDetailsService(userDetailsService);
}

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
  • AuthorizationServerConfig.java
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
@Configuration
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

@Autowired
private UserDetailsServiceImpl userDetailsService;

@Autowired
private JedisConnectionFactory connectionFactory;

@Autowired
private AuthenticationManager authenticationManager;

@Bean
public RedisTokenStore tokenStore() {
return new RedisTokenStore(connectionFactory);
}

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService)
.tokenStore(tokenStore());
}

@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.tokenKeyAccess("permitAll()").checkTokenAccess("isAuthenticated()");
}

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("android")
.scopes("app")
.secret("android")
.authorizedGrantTypes("password", "authorization_code", "refresh_token")
.and()
.withClient("web")
.scopes("web")
.authorizedGrantTypes("implicit");
}
}
  • RevokeTokenEndpoint.java
1
2
3
4
5
6
7
8
9
10
11
12
13
@FrameworkEndpoint
public class RevokeTokenEndpoint {

@Autowired
@Qualifier("consumerTokenServices")
private ConsumerTokenServices consumerTokenServices;

@DeleteMapping("/oauth/token")
@ResponseBody
public String revokeToken(String accessToken) {
return consumerTokenServices.revokeToken(accessToken) ? "注销成功" : "注销失败";
}
}

资源服务

  • Maven 依赖
1
2
3
4
5
6
7
8
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
  • 配置信息
1
2
3
4
5
6
security:
oauth2:
resource:
id: service-order
user-info-uri: http://localhost:9080/auth/user
prefer-token-info: false
  • ResourceServerConfig.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

@Autowired
private ObjectMapper objectMapper;

@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint((request, response, authException) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
objectMapper.writeValue(response.getWriter(), ResponseEntity.status(HttpStatus.UNAUTHORIZED));
})
.and()
.authorizeRequests()
.anyRequest().authenticated()
.and()
.httpBasic();
}
}

自定义鉴权Filter

采用自定义鉴权Filter的方式只需要在网关服务里写一个Filter即可。

  • AuthFilter.java
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
public class AuthFilter extends ZuulFilter {

@Autowired
private RestTemplate restTemplate;

@Autowired
private RedisTemplate redisTemplate;

@Autowired
private JwtUtils jwtUtils;

@Override
public String filterType() {
return FilterConstants.PRE_TYPE;
}

@Override
public int filterOrder() {
return 4;
}

@Override
public boolean shouldFilter() {
return true;
}

@Override
public Object run() {
RequestContext context = RequestContext.getCurrentContext();
HttpServletRequest request = context.getRequest();
String method = request.getMethod().toUpperCase();
//获取请求的url
String url = request.getRequestURI();
//判断当前url是否需要鉴权
if (StringUtils.containsAny(url, AppConsts.PathConsts.getSkipPaths())) {
return null;
}
String token = jwtUtils.getToken(request);
//判断token是否过期以及是否能被解析
boolean tokenExpired = false;
Claims claims = null;
try {
claims = jwtUtils.getClaimsFromToken(token);
} catch (ExpiredJwtException | SignatureException e) {
tokenExpired = true;
}
if (tokenExpired) {
//当前token已过期并且尚可刷新,刷新token并放行
TokenRefresh tokenRefresh = (TokenRefresh) redisTemplate.opsForValue().get(AppConsts.TokenConsts.REFRESH_TTL_KEY + token.hashCode());
if (tokenRefresh != null && tokenRefresh.getRefreshExpiredTime().compareTo(new Date()) > 0) {
context.getResponse().setHeader(AppConsts.TokenConsts.AUTH_HEADER_NAME, jwtUtils.generateToken(tokenRefresh.getUsername(), tokenRefresh.getClientType()));
//noinspection unchecked
redisTemplate.delete(AppConsts.TokenConsts.REFRESH_TTL_KEY + token.hashCode());
return null;
}
//当前token无可刷新记录或已过可刷新时间,需要重新登录
context.setSendZuulResponse(false);
context.getResponse().setContentType(AppConsts.WebConsts.TEXT_PLAIN_UTF8_VALUE);
context.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
context.setResponseBody("Token已过期且无法刷新,请重新登录");
return null;
}
if (claims == null) {
context.setSendZuulResponse(false);
context.getResponse().setContentType(AppConsts.WebConsts.TEXT_PLAIN_UTF8_VALUE);
context.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
context.setResponseBody("Token丢失或被非法篡改");
return null;
}
//从请求中获取token中的username
String username = jwtUtils.getUsernameFromToken(token);
User user = restTemplate.getForObject("http://auth-server/user/" + username, User.class);
if (user == null) {
context.setSendZuulResponse(false);
context.getResponse().setContentType(AppConsts.WebConsts.TEXT_PLAIN_UTF8_VALUE);
context.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
context.setResponseBody("Token中无有效用户信息");
return null;
}
boolean hasAuthority = false;
//获取当前用户token中的权限信息
Set<Authority> authorities = user.getAuthorities();
for (Authority authority : authorities) {
if (authority.getMethod().name().equals(method) && url.contains(authority.getUrl())) {
hasAuthority = true;
}
}
//判断当前用户是否具备访问权限
if (hasAuthority) {
//拥有权限直接放行
return null;
}
//不具备权限返回禁止访问信息
context.setSendZuulResponse(false);
context.getResponse().setContentType(AppConsts.WebConsts.TEXT_PLAIN_UTF8_VALUE);
context.setResponseStatusCode(HttpStatus.FORBIDDEN.value());
context.setResponseBody("无访问权限");
return null;
}
}