分布式系统认证方案
分布式系统
随着软件环境和需求的变化 ,软件的架构由单体结构演变为分布式架构,具有分布式架构的系统叫分布式系统,分布式系统的运行通常依赖网络,它将单体结构的系统分为若干服务,服务之间通过网络交互来完成用户的业务处理,当前流行的微服务架构就是分布式系统架构,如下图:
分布式系统具体如下基本特点:
- 分布性:每个部分都可以独立部署,服务之间交互通过网络进行通信,比如:订单服务、商品服务。
- 伸缩性:每个部分都可以集群方式部署,并可针对部分结点进行硬件及软件扩容,具有一定的伸缩能力。
- 共享性:每个部分都可以作为共享资源对外提供服务,多个部分可能有操作共享资源的情况。
- 开放性:每个部分根据需求都可以对外发布共享资源的访问接口,并可允许第三方系统访问。
分布式认证需求
分布式系统的每个服务都会有认证、授权的需求,如果每个服务都实现一套认证授权逻辑会非常冗余,考虑分布式系统共享性的特点,需要由独立的认证服务处理系统认证授权的请求;考虑分布式系统开放性的特点,不仅对系统内部服务提供认证,对第三方系统也要提供认证。分布式认证的需求总结如下:
统一认证授权
提供独立的认证服务,统一处理认证授权。
无论是不同类型的用户,还是不同种类的客户端(web端,H5、APP),均采用一致的认证、权限、会话机制,实现统一认证授权。
要实现统一则认证方式必须可扩展,支持各种认证需求,比如:用户名密码认证、短信验证码、二维码、人脸识别等认证方式,并可以非常灵活的切换。
应用接入认证
应提供扩展和开放能力,提供安全的系统对接机制,并可开放部分API给接入第三方使用,一方应用(内部系统服务)和第三方应用均采用统一机制接入。
分布式认证方案
基于session的认证方式
在分布式的环境下,基于session的认证会出现一个问题,每个应用服务都需要在session中存储用户身份信息,通过负载均衡将本地的请求分配到另一个应用服务需要将session信息带过去,否则会重新认证。
这个时候,通常的做法有下面几种:
- Session复制:多台应用服务器之间同步session,使session保持一致,对外透明。
- Session黏贴:当用户访问集群中某台服务器后,强制指定后续所有请求均落到此机器上。
- Session集中存储:将Session存入分布式缓存中,所有服务器应用实例统一从分布式缓存中存取Session。
总体来讲,基于session认证的认证方式,可以更好的在服务端对会话进行控制,且安全性较高。但是,session机制方式基于cookie,在复杂多样的移动客户端上不能有效的使用,并且无法跨域,另外随着系统的扩展需提高session的复制、黏贴及存储的容错性。
基于token的认证方式
基于token的认证方式,服务端不用存储认证数据,易维护扩展性强, 客户端可以把token存在任意地方,并且可以实现web和app统一认证机制。其缺点也很明显,token由于自包含信息,因此一般数据量较大,而且每次请求都需要传递,因此比较占带宽。另外,token的签名验签操作也会给cpu带来额外的处理负担。
通过比较2种方式,我们认为基于token的认证方式更适合分布式,它的优点是:
- 适合统一认证的机制,客户端、一方应用、三方应用都遵循一致的认证机制。
- token认证方式对第三方应用接入更适合,因为它更开放,可使用当前有流行的开放协议Oauth2.0、JWT等。
- 一般情况服务端无需存储会话信息,减轻了服务端的压力。
分布式系统认证技术方案见下图:
流程描述:
- 用户通过接入方(应用)登录,接入方采取OAuth2.0方式在统一认证服务(UAA)中认证。
- 认证服务(UAA)调用验证该用户的身份是否合法,并获取用户权限信息。
- 认证服务(UAA)获取接入方权限信息,并验证接入方是否合法。
- 若登录用户以及接入方都合法,认证服务生成jwt令牌返回给接入方,其中jwt中包含了用户权限及接入方权限。
- 后续,接入方携带jwt令牌对API网关内的微服务资源进行访问。
- API网关对令牌解析、并验证接入方的权限是否能够访问本次请求的微服务。
- 如果接入方的权限没问题,API网关将原请求header中附加解析后的明文Token,并将请求转发至微服务。
- 微服务收到请求,明文token中包含登录用户的身份和权限信息。因此后续微服务自己可以干两件事:1.用户授权拦截(看当前用户是否有权访问该资源);2.将用户信息存储进当前线程上下文(有利于后续业务逻辑随时获取当前用户信息)
流程所涉及到UAA服务、API网关这二个组件职责如下:
- 统一认证服务(UAA):它承载了OAuth2.0接入方认证、登入用户的认证、授权以及生成令牌的职责,完成实际的用户认证、授权功能。
- API网关:作为系统的唯一入口,API网关为接入方提供定制的API集合,它可能还具有其它职责,如身份验证、监控、负载均衡、缓存等。API网关方式的核心要点是,所有的接入方和消费端都通过统一的网关接入微服务,在网关层处理所有的非业务功能。
具体实现
我们将模拟一个微服务架构的系统,创建四个SpringBoot模块,其中将采用eureka
作为微服务注册中心,zuul
作为微服务网关,以及基于spring security
实现的认证服务和资源服务。项目结构如下:
注册中心
创建distributed-security-discovery
模块作为注册中心,由于本文重点关注SpringSecurity分布式,而非SpringCloud微服务架构,所以不作过多解释,其中配置文件application.yml
如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| spring: application: name: distributed-discovery
server: port: 53000
eureka: server: enable-self-preservation: false eviction-interval-timer-in-ms: 10000 shouldUseReadOnlyResponseCache: true client: register-with-eureka: false fetch-registry: false instance-info-replication-interval-seconds: 10 serviceUrl: defaultZone: http://localhost:${server.port}/eureka/ instance: hostname: ${spring.cloud.client.ip-address} prefer-ip-address: true instance-id: ${spring.application.name}:${spring.cloud.client.ip-address}:${spring.application.instance_id:${server.port}}
|
网关
网关整合 OAuth2.0 有两种思路,一种是认证服务器生成jwt令牌, 所有请求统一在网关层验证,判断权限等操作;另一种是由各资源服务处理,网关只做请求转发。
我们选用第一种,把API网关作为OAuth2.0的资源服务器角色,实现接入客户端权限拦截、令牌解析并转发当前登录用户信息(jsonToken)给微服务,这样下游微服务就不需要关心令牌格式解析以及OAuth2.0相关机制了。
API网关在认证授权体系里主要负责两件事:
- 作为OAuth2.0的资源服务器角色,实现接入方权限拦截。
- 令牌解析并转发当前登录用户信息(明文token)给微服务
微服务拿到明文token(明文token中包含登录用户的身份和权限信息)后也需要做两件事:
- 用户授权拦截(看当前用户是否有权访问该资源)
- 将用户信息存储进当前线程上下文(有利于后续业务逻辑随时获取当前用户信息)
统一认证服务(UAA)与统一用户服务(Order)都是网关下微服务,需要在网关上新增路由配置:
1 2 3 4 5
| zuul.routes.uaa-service.stripPrefix = false zuul.routes.uaa-service.path = /uaa/**
zuul.routes.order-service.stripPrefix = false zuul.routes.order-service.path = /order/**
|
上面配置了网关接收的请求url若符合/order/**
表达式,将被被转发至order-service(统一用户服务)。
完整目录结构如下:
配置Token
资源服务器由于需要验证并解析令牌,往往可以通过在授权服务器暴露check_token的Endpoint来完成,而我们在授权服务器使用的是对称加密的jwt,因此知道密钥即可,资源服务与授权服务本就是对称设计,创建一个TokenConfig
配置类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @Configuration public class TokenConfig {
private String SIGNING_KEY = "uaa123";
@Bean public TokenStore tokenStore() { return new JwtTokenStore(accessTokenConverter()); }
@Bean public JwtAccessTokenConverter accessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setSigningKey(SIGNING_KEY); return converter; } }
|
配置资源服务
创建ResouceServerConfig
配置类,在其中定义资源服务配置,主要配置的内容就是定义一些匹配规则,描述某个接入客户端需要什么样的权限才能访问某个微服务,如
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
| @Configuration public class ResouceServerConfig {
public static final String RESOURCE_ID = "res1";
@Configuration @EnableResourceServer public class UAAServerConfig extends ResourceServerConfigurerAdapter { @Autowired private TokenStore tokenStore;
@Override public void configure(ResourceServerSecurityConfigurer resources){ resources.tokenStore(tokenStore).resourceId(RESOURCE_ID) .stateless(true); }
@Override public void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/uaa/**").permitAll(); } }
@Configuration @EnableResourceServer public class OrderServerConfig extends ResourceServerConfigurerAdapter { @Autowired private TokenStore tokenStore;
@Override public void configure(ResourceServerSecurityConfigurer resources){ resources.tokenStore(tokenStore).resourceId(RESOURCE_ID) .stateless(true); }
@Override public void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/order/**").access("#oauth2.hasScope('ROLE_API')"); } }
}
|
上面定义了两个微服务的资源,其中:UAAServerConfig指定了若请求匹配/uaa/**
网关不进行拦截。 OrderServerConfig指定了若请求匹配/order/**
,也就是访问统一用户服务,接入客户端需要有scope中包含ROLE_API权限。
转发明文token给微服务
通过Zuul过滤器的方式实现,目的是让下游微服务能够很方便的获取到当前的登录用户信息(明文token)。实现Zuul前置过滤器,完成当前登录用户信息提取,并放入转发微服务的request中:
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
| public class AuthFilter extends ZuulFilter {
@Override public boolean shouldFilter() { return true; }
@Override public String filterType() { return "pre"; }
@Override public int filterOrder() { return 0; }
@Override public Object run() throws ZuulException {
RequestContext ctx = RequestContext.getCurrentContext(); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (!(authentication instanceof OAuth2Authentication)) { return null; } OAuth2Authentication oAuth2Authentication = (OAuth2Authentication) authentication; Authentication userAuthentication = oAuth2Authentication.getUserAuthentication(); String principal = userAuthentication.getName();
List<String> authorities = new ArrayList<>(); userAuthentication.getAuthorities().stream().forEach(c -> authorities.add(((GrantedAuthority) c).getAuthority()));
OAuth2Request oAuth2Request = oAuth2Authentication.getOAuth2Request(); Map<String, String> requestParameters = oAuth2Request.getRequestParameters(); Map<String, Object> jsonToken = new HashMap<>(requestParameters); if (userAuthentication != null) { jsonToken.put("principal", principal); jsonToken.put("authorities", authorities); }
ctx.addZuulRequestHeader("json-token", EncryptUtil.encodeUTF8StringBase64(JSON.toJSONString(jsonToken)));
return null; } }
|
将filter纳入spring 容器,配置ZuulConfig
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| @Configuration public class ZuulConfig {
@Bean public AuthFilter preFilter() { return new AuthFilter(); }
@Bean public FilterRegistrationBean corsFilter() { final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); final CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOrigin("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setMaxAge(18000L); source.registerCorsConfiguration("/**", config); CorsFilter corsFilter = new CorsFilter(source); FilterRegistrationBean bean = new FilterRegistrationBean(corsFilter); bean.setOrder(Ordered.HIGHEST_PRECEDENCE); return bean; } }
|
资源服务
资源服务Order依然采用SpringSecurity的机制进行认证,不同的是资源服务并不需要解析token,因为已经在网关中解析了,并且将明文token放到了请求头中。现在我们只需要取出请求头中的json-token
并封装到authentication中即可,后续SpringSecurity会自动鉴权。所以我们要做的是增加微服务用户鉴权拦截功能。
添加一些测试资源,OrderController增加以下endpoint:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| @PreAuthorize("hasAuthority('p1')") @GetMapping(value = "/r1") public String r1() { UserDTO user = (UserDTO) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); return user.getUsername() + "访问资源1"; }
@PreAuthorize("hasAuthority('p2')") @GetMapping(value = "/r2") public String r2() { UserDTO user = (UserDTO) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); return user.getUsername() + "访问资源2"; }
@GetMapping(value = "/r3") public String r3() { UserDTO user = (UserDTO) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); return user.getUsername() + "访问资源3"; }
|
SpringSecurity配置,开启方法保护,并增加Spring配置策略,客户端的scope需要有ROLE_ADMIN
权限才能访问资源res1
。
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
| @Configuration @EnableResourceServer public class ResouceServerConfig extends ResourceServerConfigurerAdapter {
public static final String RESOURCE_ID = "res1";
@Autowired TokenStore tokenStore;
@Override public void configure(ResourceServerSecurityConfigurer resources) { resources.resourceId(RESOURCE_ID) .tokenStore(tokenStore) .stateless(true); resources.authenticationEntryPoint(new SimpleAuthenticationEntryPoint()); resources.accessDeniedHandler(new SimpleAccessDeniedHandler()); }
@Override public void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/**").access("#oauth2.hasScope('ROLE_ADMIN')") .and().csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); } }
|
客户端oauth_client_details
表数据,c1客户端拥有res1
资源权限,同时它的scope范围有ROLE_ADMIN,ROLE_USER,ROLE_API,如果采用c2客户端获取token,并用该token访问Order方法将会提示拒绝访问。
综合上面的配置,咱们共定义了三个资源了,拥有p1权限可以访问r1资源,拥有p2权限可以访问r2资源,只要认证通过就能访问r3资源。 接下来定义filter拦截token,并形成Spring Security的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
| @Component public class TokenAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException { String token = httpServletRequest.getHeader("json-token"); if (token != null) { String json = EncryptUtil.decodeUTF8StringBase64(token); JSONObject jsonObject = JSON.parseObject(json); UserDTO userDTO = JSON.parseObject(jsonObject.getString("principal"), UserDTO.class); JSONArray authoritiesArray = jsonObject.getJSONArray("authorities"); String[] authorities = authoritiesArray.toArray(new String[authoritiesArray.size()]); UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDTO, null, AuthorityUtils.createAuthorityList(authorities)); authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest)); SecurityContextHolder.getContext().setAuthentication(authenticationToken); } filterChain.doFilter(httpServletRequest, httpServletResponse);
} }
|
经过上边的过滤器,资源服务中就可以方便到的获取用户的身份信息:
1
| UserDTO user = (UserDTO) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
总结:
- 解析token
- 新建并填充authentication
- 将authentication保存进安全上下文
认证服务
在认证服务UAA中,要注意loadUserByUsername
这个方法,我们将整个数据库查出来的用户信息存放到UserDto
对象中,并将这个对象序列化成json字符串,然后赋值给了UserDetails的username字段:
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
| @Service public class SpringDataUserDetailsService implements UserDetailsService {
@Autowired UserDao userDao;
@Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { UserDto userDto = userDao.getUserByUsername(username); if(userDto == null){ return null; } List<String> permissions = userDao.findPermissionsByUserId(userDto.getId()); String[] permissionArray = new String[permissions.size()]; permissions.toArray(permissionArray); String principal = JSON.toJSONString(userDto); UserDetails userDetails = User.withUsername(principal).password(userDto.getPassword()).authorities(permissionArray).build(); return userDetails; } }
|
因为只有这样,我们才能在网关中通过Authentication的getName
获取到整个用户身份信息,而非仅仅是登录名username:
1 2 3 4 5
| Authentication userAuthentication = oAuth2Authentication.getUserAuthentication();
String principal = userAuthentication.getName(); ... jsonToken.put("principal", principal);
|
然后网关将该值封装到明文token中,继而资源服务可以获取到整个用户身份信息。
1 2 3 4 5 6 7 8 9
| UserDTO userDTO = JSON.parseObject(jsonObject.getString("principal"), UserDTO.class);
JSONArray authoritiesArray = jsonObject.getJSONArray("authorities"); String[] authorities = authoritiesArray.toArray(new String[authoritiesArray.size()]);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDTO, null, AuthorityUtils.createAuthorityList(authorities)); authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
|
源码地址
https://github.com/Mcdull0921/distributed-security