分布式系统认证方案

分布式系统

随着软件环境和需求的变化 ,软件的架构由单体结构演变为分布式架构,具有分布式架构的系统叫分布式系统,分布式系统的运行通常依赖网络,它将单体结构的系统分为若干服务,服务之间通过网络交互来完成用户的业务处理,当前流行的微服务架构就是分布式系统架构,如下图:

分布式系统具体如下基本特点:

  • 分布性:每个部分都可以独立部署,服务之间交互通过网络进行通信,比如:订单服务、商品服务。
  • 伸缩性:每个部分都可以集群方式部署,并可针对部分结点进行硬件及软件扩容,具有一定的伸缩能力。
  • 共享性:每个部分都可以作为共享资源对外提供服务,多个部分可能有操作共享资源的情况。
  • 开放性:每个部分根据需求都可以对外发布共享资源的访问接口,并可允许第三方系统访问。

分布式认证需求

分布式系统的每个服务都会有认证、授权的需求,如果每个服务都实现一套认证授权逻辑会非常冗余,考虑分布式系统共享性的特点,需要由独立的认证服务处理系统认证授权的请求;考虑分布式系统开放性的特点,不仅对系统内部服务提供认证,对第三方系统也要提供认证。分布式认证的需求总结如下:

统一认证授权

提供独立的认证服务,统一处理认证授权。
无论是不同类型的用户,还是不同种类的客户端(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的认证方式更适合分布式,它的优点是:

  1. 适合统一认证的机制,客户端、一方应用、三方应用都遵循一致的认证机制。
  2. token认证方式对第三方应用接入更适合,因为它更开放,可使用当前有流行的开放协议Oauth2.0、JWT等。
  3. 一般情况服务端无需存储会话信息,减轻了服务端的压力。

分布式系统认证技术方案见下图:

流程描述:

  1. 用户通过接入方(应用)登录,接入方采取OAuth2.0方式在统一认证服务(UAA)中认证。
  2. 认证服务(UAA)调用验证该用户的身份是否合法,并获取用户权限信息。
  3. 认证服务(UAA)获取接入方权限信息,并验证接入方是否合法。
  4. 若登录用户以及接入方都合法,认证服务生成jwt令牌返回给接入方,其中jwt中包含了用户权限及接入方权限。
  5. 后续,接入方携带jwt令牌对API网关内的微服务资源进行访问。
  6. API网关对令牌解析、并验证接入方的权限是否能够访问本次请求的微服务。
  7. 如果接入方的权限没问题,API网关将原请求header中附加解析后的明文Token,并将请求转发至微服务。
  8. 微服务收到请求,明文token中包含登录用户的身份和权限信息。因此后续微服务自己可以干两件事:1.用户授权拦截(看当前用户是否有权访问该资源);2.将用户信息存储进当前线程上下文(有利于后续业务逻辑随时获取当前用户信息)

流程所涉及到UAA服务、API网关这二个组件职责如下:

  • 统一认证服务(UAA):它承载了OAuth2.0接入方认证、登入用户的认证、授权以及生成令牌的职责,完成实际的用户认证、授权功能。
  • API网关:作为系统的唯一入口,API网关为接入方提供定制的API集合,它可能还具有其它职责,如身份验证、监控、负载均衡、缓存等。API网关方式的核心要点是,所有的接入方和消费端都通过统一的网关接入微服务,在网关层处理所有的非业务功能。

具体实现

我们将模拟一个微服务架构的系统,创建四个SpringBoot模块,其中将采用eureka作为微服务注册中心,zuul作为微服务网关,以及基于spring security实现的认证服务和资源服务。项目结构如下:

注册中心

创建distributed-security-discovery模块作为注册中心,由于本文重点关注SpringSecurity分布式,而非SpringCloud微服务架构,所以不作过多解释,其中配置文件application.yml如下:

spring:
  application:
    name: distributed-discovery

server:
  port: 53000 #启动端口

eureka:
  server:
    enable-self-preservation: false    #关闭服务器自我保护,客户端心跳检测15分钟内错误达到80%服务会保护,导致别人还认为是好用的服务
    eviction-interval-timer-in-ms: 10000 #清理间隔(单位毫秒,默认是60*1000)5秒将客户端剔除的服务在服务注册列表中剔除#
    shouldUseReadOnlyResponseCache: true #eureka是CAP理论种基于AP策略,为了保证强一致性关闭此切换CP 默认不关闭 false关闭
  client:
    register-with-eureka: false  #false:不作为一个客户端注册到注册中心
    fetch-registry: false      #为true时,可以启动,但报异常:Cannot execute request on any known server
    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网关在认证授权体系里主要负责两件事:

  1. 作为OAuth2.0的资源服务器角色,实现接入方权限拦截。
  2. 令牌解析并转发当前登录用户信息(明文token)给微服务

微服务拿到明文token(明文token中包含登录用户的身份和权限信息)后也需要做两件事:

  1. 用户授权拦截(看当前用户是否有权访问该资源)
  2. 将用户信息存储进当前线程上下文(有利于后续业务逻辑随时获取当前用户信息)

统一认证服务(UAA)与统一用户服务(Order)都是网关下微服务,需要在网关上新增路由配置:

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配置类:

@Configuration
public class TokenConfig {

    private String SIGNING_KEY = "uaa123";

    @Bean
    public TokenStore tokenStore() {
        //JWT令牌存储方案
        return new JwtTokenStore(accessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey(SIGNING_KEY); //对称秘钥,资源服务器使用该秘钥来验证
        return converter;
    }
}

配置资源服务

创建ResouceServerConfig配置类,在其中定义资源服务配置,主要配置的内容就是定义一些匹配规则,描述某个接入客户端需要什么样的权限才能访问某个微服务,如

@Configuration
public class ResouceServerConfig  {

    public static final String RESOURCE_ID = "res1";

    //uaa资源服务配置
    @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();
        }
    }

    //order资源服务配置
    @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中:

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 {
        /**
        * 1.获取令牌内容 
        */
        RequestContext ctx = RequestContext.getCurrentContext();
        //从安全上下文中拿到用户身份对象
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        //无token访问网关内资源的情况,目前仅有uua服务直接暴露
        if (!(authentication instanceof OAuth2Authentication)) {
            return null;
        }
        OAuth2Authentication oAuth2Authentication = (OAuth2Authentication) authentication;
        Authentication userAuthentication = oAuth2Authentication.getUserAuthentication();
        //取出用户身份信息
        String principal = userAuthentication.getName();
        
        /**
        * 2.组装明文token,转发给微服务,放入header,名称为json‐token 
        */
        //取出用户权限
        List<String> authorities = new ArrayList<>();
        //从userAuthentication取出权限,放在authorities
        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);
        }

        //把身份信息和权限信息放在json中,加入http的header中,转发给微服务
        ctx.addZuulRequestHeader("json-token", EncryptUtil.encodeUTF8StringBase64(JSON.toJSONString(jsonToken)));

        return null;
    }
}

将filter纳入spring 容器,配置ZuulConfig

@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:

    @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() {
        //通过Spring Security API获取当前登录用户
        UserDTO user = (UserDTO) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        return user.getUsername() + "访问资源2";
    }

    @GetMapping(value = "/r3")
    public String r3() {
        //通过Spring Security API获取当前登录用户
        UserDTO user = (UserDTO) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        return user.getUsername() + "访问资源3";
    }

SpringSecurity配置,开启方法保护,并增加Spring配置策略,客户端的scope需要有ROLE_ADMIN权限才能访问资源res1

@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)//资源 id
                //.tokenServices(tokenService())//验证令牌的服务
                .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对象:

@Component
public class TokenAuthenticationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
        //1.解析出头中的token
        String token = httpServletRequest.getHeader("json-token");
        if (token != null) {
            String json = EncryptUtil.decodeUTF8StringBase64(token);
            //将token转成json对象
            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()]);
            //2.新建并填充authentication
            UsernamePasswordAuthenticationToken authenticationToken
                    = new UsernamePasswordAuthenticationToken(userDTO, null, AuthorityUtils.createAuthorityList(authorities));
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
            //3.将authenticationToken填充到安全上下文
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }
        filterChain.doFilter(httpServletRequest, httpServletResponse);

    }
}

经过上边的过滤器,资源服务中就可以方便到的获取用户的身份信息:

UserDTO user = (UserDTO) SecurityContextHolder.getContext().getAuthentication().getPrincipal();

总结

  1. 解析token
  2. 新建并填充authentication
  3. 将authentication保存进安全上下文

认证服务


在认证服务UAA中,要注意loadUserByUsername这个方法,我们将整个数据库查出来的用户信息存放到UserDto对象中,并将这个对象序列化成json字符串,然后赋值给了UserDetails的username字段:

@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){
            //如果用户查不到,返回null,由provider来抛出异常
            return null;
        }
        //根据用户的id查询用户的权限
        List<String> permissions = userDao.findPermissionsByUserId(userDto.getId());
        //将permissions转成数组
        String[] permissionArray = new String[permissions.size()];
        permissions.toArray(permissionArray);
        //将userDto转成json
        String principal = JSON.toJSONString(userDto);
        UserDetails userDetails = User.withUsername(principal).password(userDto.getPassword()).authorities(permissionArray).build();
        return userDetails;
    }
}

因为只有这样,我们才能在网关中通过Authentication的getName获取到整个用户身份信息,而非仅仅是登录名username:

Authentication userAuthentication = oAuth2Authentication.getUserAuthentication();
//取出用户身份信息,UserDto的JSON字符串
String principal = userAuthentication.getName();
...
jsonToken.put("principal", principal);

然后网关将该值封装到明文token中,继而资源服务可以获取到整个用户身份信息。

//用户身份信息
UserDTO userDTO = JSON.parseObject(jsonObject.getString("principal"), UserDTO.class);
//用户权限
JSONArray authoritiesArray = jsonObject.getJSONArray("authorities");
String[] authorities = authoritiesArray.toArray(new String[authoritiesArray.size()]);
//将用户信息和权限填充 到用户身份token对象中
UsernamePasswordAuthenticationToken authenticationToken
                    = new UsernamePasswordAuthenticationToken(userDTO, null, AuthorityUtils.createAuthorityList(authorities));
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));

源码地址

https://github.com/Mcdull0921/distributed-security

链接: https://www.xdull.cn/spring-security-distributed.html
来源: 兜兜转转的博客
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

Logo

开源、云原生的融合云平台

更多推荐