快速搭建微服务-服务安全
微服务架构下的服务安全是构建微服务系统的一个重要环节。做好服务鉴权是保障数据不泄漏、不被非法操作的关键。
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网关服务
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。
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鉴权服务
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
注解声明当前应用为鉴权服务端。
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"); } }
|
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) ? "注销成功" : "注销失败"; } }
|
资源服务
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即可。
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(); String url = request.getRequestURI(); if (StringUtils.containsAny(url, AppConsts.PathConsts.getSkipPaths())) { return null; } String token = jwtUtils.getToken(request); boolean tokenExpired = false; Claims claims = null; try { claims = jwtUtils.getClaimsFromToken(token); } catch (ExpiredJwtException | SignatureException e) { tokenExpired = true; } if (tokenExpired) { 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())); redisTemplate.delete(AppConsts.TokenConsts.REFRESH_TTL_KEY + token.hashCode()); return null; } 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; } 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; 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; } }
|