分布式系统认证方案

分布式系统

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

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

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

分布式认证需求

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

统一认证授权

提供独立的认证服务,统一处理认证授权。
无论是不同类型的用户,还是不同种类的客户端(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会自动鉴权。所以我们要做的是增加微服务用户鉴权拦截功能。

image-20210111235901904

添加一些测试资源,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