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

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

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 依赖
<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
  • 配置信息
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
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网关路由时,需要注意sensitiveHeaders需要配置Cookie,Set-Cookie,这样才能在请求网关时携带token并在刷新之后返回token。

  • SecurityConfig.java
@Configuration
@EnableOAuth2Sso
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
    }
}
1
2
3
4
5
6
7
8
9

# auth-server鉴权服务

  • Maven 依赖
<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
5
6
7
8
9
10
11
12
13
  • 配置信息
security:
  oauth2:
    resource:
      filter-order: 3
1
2
3
4

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

  • SecurityConfig.java
@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();
    }
}
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
  • AuthorizationServerConfig.java
@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");
    }
}
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
  • RevokeTokenEndpoint.java
@FrameworkEndpoint
public class RevokeTokenEndpoint {

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

    @DeleteMapping("/oauth/token")
    @ResponseBody
    public String revokeToken(String accessToken) {
        return consumerTokenServices.revokeToken(accessToken) ? "注销成功" : "注销失败";
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 资源服务

  • Maven 依赖
<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
  • 配置信息
security:
  oauth2:
    resource:
      id: service-order
      user-info-uri: http://localhost:9080/auth/user
      prefer-token-info: false
1
2
3
4
5
6
  • ResourceServerConfig.java
@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();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 自定义鉴权Filter

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

  • AuthFilter.java
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;
    }
}
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