Skip to content

第25讲:如何基于SpringSecurity和JWT实现访问控制

从本课时开始,我们将进入到本课程的第六部分,也就是满足微服务架构应用的非功能性需求(Non-functional Requirements,NFR),非功能性需求与功能性需求相对应。功能性需求描述系统的行为或功能,而非功能性需求则与功能无关,用来描述系统的质量。系统的质量是一个很宽泛的概念,可以从很多不同的角度来描述,包括性能、安全性、可用性、可伸缩性、可靠性和可维护性等。实现相同功能的系统,它们在质量上的差别可能很大,或者说各有不同的侧重点。

由于非功能性需求与应用本身无关,在项目开发中积累的与非功能性需求相关的经验,可以更容易地在不同的项目中复用。不过非功能性需求所涵盖的内容很多,一个应用不可能满足全部的需求,而且有些非功能性需求也存在冲突。一个应用应该根据实际的需求,选择需要满足的非功能性需求。本课时将介绍与安全性相关的内容,使用 Spring Security 框架和 JWT。

认证

认证指的是验证用户的身份。在访问每个用户的私有资源时,访问者需要认证自己的身份,也就是证明你是你所声称的身份,这就需要提供用户的私有凭证,典型的凭证是密码。如果访问时提供的用户名和密码,与该用户注册时的记录相匹配,则完成了身份认证。由于 HTTP 请求和响应是无状态的,每次请求都需要带上认证信息,一般放在 HTTP 头 Authorization 中。

使用 HTTP 头来发送认证信息的做法,对 Web 应用来说不太适用。用户希望的体验是在输入用户名和密码登录了之后,在一段时间内都不需要再次登录,这是通过会话(Session)来实现的。每次登录之后,服务器端会为当前用户创建一个新的会话,并把会话的标识符以 Cookie 的形式返回并保存在浏览器中。浏览器在发送 HTTP 请求时会自动带上关联的 Cookie,其中就包括会话的标识符。如果该标识符对应的会话在服务器端仍然存在,那么该用户的身份认证成功,会话的标识符就等同于用户的身份。

除了 Web 应用之外,移动客户端和其他服务在发送 HTTP 请求时,一般通过 HTTP 头来发送认证信息。在最简单的 HTTP Basic 认证中,用户名和密码直接以 Base64 编码的形式来发送。Basic 认证实现起来最简单,但是安全系数比较低,如果 HTTP 请求被拦截,攻击者可以轻易获取到用户的密码。为了避免密码泄露,更好的做法是使用不透明的令牌(Token)来作为身份标识。从作用上来说,令牌与密码都是身份标识,如果令牌泄露,攻击者可以同样获取到一些数据。但是令牌可以提供更多的功能,令牌可以设置有效期,还可以被回收,每个令牌可以对应不同的权限。如果令牌泄露,可以把影响降到最低。

有很多认证方式都采用令牌,包括 OAuth 2 和 OpenID Connect。这些认证方式都需要对令牌进行管理,包括令牌的创建、刷新、回收和验证。客户端的使用则简单很多,只需要把令牌放在 HTTP 头中即可。通常使用的是 HTTP 头 Authorization,并在令牌前面添加前缀 Bearer。本课时介绍的是 JWT,也是目前非常流行的认证方式。

JWT

JWT(JSON Web Token)的作用是在不同的参与者之间安全的传递申明(Claim)。JWT 本身是一系列申明的紧凑的、URL 安全的表示形式,JWT 中的申明以 JSON 对象的方式来进行编码。JWT 可以与JSON Web Signature(JWS)和 JSON Web Encryption(JWE)一块使用,使得申明可以添加数字签名、通过消息认证码(Message Authentication Code,MAC)来保证完整性,以及进行加密。JWT 和相关的规范都是 IETF 的标准。

申明

申明是关于一个特定的参与者或对象的定义或主张,JWT 规范中定义了一些常用的申明,也可以使用公开或私有的申明。JWT 中的申明分成三种,分别是已注册的申明、公开申明和私有申明。

已注册的申明是 JWT 规范中直接定义的,目前一共有 7 种,如下表所示。为了内容紧凑,这些申明的名称都由 3 个字符组成。

JSON 属性名称名称说明
iss签发者签发 JWT 的实体
sub主体JWT 的主体,主体的值要么全局唯一,要么在其签发者的范围内是唯一的
aud接收者JWT 所预期的接收者
exp过期时间超过该时间之后,JWT 不能被处理
nbf生效时间在该时间之前,JWT 不能被处理
iat签发时间JWT 的签发时间
jti标识符JWT 的唯一标识符,用来防止 JWT 被重复处理

除了已注册的申明之外,JWT 对于申明的名称并没有限制。为了避免名称冲突,公开的申明需要在 IANA 注册,或是使用带名称空间的形式。私有申明的名称则比较随意,有可能产生冲突。

格式

JWT 的格式非常简单,只是以英文句号分隔的多个部分所组成的字符串,每个部分都是 Base64-URL 编码的格式。Base64-URL 是 Base64 编码的一种变体,把 Base64 编码结果中的"+"替换成了"-",而"/"替换成了"_",也去掉了末尾的"="。

根据 JWT 的不同类型,所组成的部分也不相同,如下表所示。

JWT 类型组成部分
不安全 JWT头、载荷
已签名 JWT头、载荷、签名
加密 JWT头、已加密的密钥、初始化向量、加密数据、签名

JWT 头中包含的是关于 JWT 本身的申明,根据 JWT 的类型有不同的申明可以使用。载荷中包含的是需要传递的数据,头和载荷都是 JSON 对象,不安全的 JWT 在实际开发中不推荐使用。因此下面主要介绍已签名 JWT 和加密 JWT。

已签名 JWT

已签名的 JWT 通过数字签名来防止数据被篡改,已签名 JWT 的头中需要包含申明 alg 来指定签名的算法。JWT 头和载荷都需要以 Base64-URL 进行编码,得到的结果以英文句点连接在一起之后,作为数字签名算法的输入,算法的输出结果也同样以 Base64-URL 编码,就得到了签名,最后把签名添加在末尾。JSON Web Algorithms 规范中定义了常用的算法,不同的 JWT 库所支持的算法不尽相同。只有 HS256,也就是 HMAC 加上 SHA-256,是必须支持的。

下面的代码中给出了已签名 JWT 的一个示例。

yaml
eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VyIiwiaWF0IjoxNTkxNjAwMTAxLCJleHAiOjE1OTIwMzIxMDF9.mDgR0dIx2kYxR3Wq3CXI-u7bw5AE54JfIeQqnR7VTRC6m9POGMPG5LpOQbljP4Kt2GLbT1JYD7xD7JvtBFev-g

第一个部分的 JWT 头在解析之后的内容如下所示,声明了使用的签名算法是 HS512。

json
{
  "alg": "HS512"
}

第二个部分的载荷在解析之后的内容如下所示,其中声明了 JWT 中的主体、签发时间和过期时间。

json
{
  "sub": "user",
  "iat": 1591600101,
  "exp": 1592032101
}

加密 JWT

加密 JWT 可以防止数据被读取。加密有两种模式:使用共享密钥或使用公钥私钥。共享密钥指的是所有参与者都拥有密钥,都可以进行加密和解密操作;在公钥私钥模式中,公钥的持有者可以加密数据,只有私钥的持有者可以解密和加密数据。

在加密 JWT 中,需要区分两种不同的密钥:内容加密密钥(Content Encryption Key,CEK)和 JWE 加密密钥。内容加密密钥是对 JWT 中的载荷进行加密时使用的密钥,而 JWE 加密密钥则是对内容加密密钥进行加密的密钥。在共享密钥模式中,JWE 加密密钥是不需要的,内容加密密钥就是共享密钥。

在公钥私钥模式中,由于这种不对称的加密方式在输入过大时,其性能不太好,一般的做法是首先随机产生一个密钥作为内容加密密钥,以对称加密的方式对 JWT 载荷进行加密,再用公钥把内容加密密钥进行加密,并作为 JWT 的一部分。JWT 的接收者首先使用私钥解密之后,得到内容加密密钥,再用得到的内容加密密钥来对载荷进行解密。这种做法兼顾了安全性和性能。

这里需要注意的是签名 JWT 和加密 JWT 的区别。签名 JWT 只能保证 JWT 的内容不被篡改,其中包含的内容可以被任何人读取。所以签名 JWT 中不能包含敏感的数据,可以把它与 Cookie 做类比。加密 JWT 中的内容无法读取,安全性更高,但是加密和解密的成本比较高。大部分情况下,签名 JWT 已经可以满足需求。

JWT 使用

JWT 有很多实际的应用场景:一种场景是作为客户端数据的格式,另外一种是在联合身份管理(Federated Identity Management)中作为令牌,如 OpenID Connect 和 OAuth 2。本课时主要介绍第一种应用场景。

当需要在浏览器端保存数据时,最常用的方式是 Cookie。服务器端在 HTTP 响应的 Set-Cookie 头中设置指定 Cookie 的值。浏览器在每次发送 HTTP 请求时,会自动以 HTTP 头 Cookie 来发送当前域名和路径所对应的 Cookie 值。Cookie 一般用来保存用户的当前会话的标识符,如 Java 应用常用的 JSESSIONID。

由于会话标识符是很难猜测的随机字符串,不用担心被轻易伪造,可以作为用户的标识。如果应用直接使用用户名作为 Cookie 的值,那么只需要以暴力尝试的方式猜测出用户名,就可以冒名顶替成其他用户。

HTTP 会话是目前解决用户身份认证的常用做法,既方便了用户的使用,又保证了安全性。HTTP 会话也可以保存一些与会话相关的数据,Java 的 Servlet API 中就提供了相关的方法来在会话中保存数据。但是会话有一个最大的问题是可扩展性比较不好,这也是有状态服务的通病。在一个集群中,需要使用分布式会话实现,或是会话复制,或是黏性会话(Sticky Session)。这些做法都会增加实现和管理的复杂度。更好的做法是使用无状态的会话,这样可以更简单地实现水平扩展。

JWT 很好地解决了这个问题。在经过数字签名之后,可以保证 JWT 中的内容不被篡改,这样就避免了安全性的问题。除了用户标识符之外,其他会话相关的数据也可以保存在 JWT 中。这样就把状态相关的信息迁移到了客户端,免去了服务器端维护状态信息所带来的复杂度。

Spring Security

在 Java 平台上,Spring Security 是流行的实现安全相关的功能框架。下面介绍示例应用中的乘客管理服务如何使用 Spring Security 和 JWT 进行身份认证,使用的 JWT 库是 Java JWT

用户登录

目前流行的 Web 应用一般以 REST API 的方式来进行登录,传统的基于表单的登录方式使用较少。这主要是因为表单登录需要进行页面跳转,而目前很多应用都是单页面应用,跳转式登录影响用户体验。很多的应用都允许以匿名的方式浏览,只有当需要进行用户相关的操作时,才需要进行登录。在单页面应用中,当需要登录时,可以用路由切换或对话框的形式弹出登录界面,JavaScript 代码以 XHR 的方式发送登录请求。XHR 也支持以 HTTP 头来发送认证信息。

下面代码中的 JWTAuthenticationFilter 类是处理登录请求的 Spring Security 中的过滤器。在登录时,把用户名和密码以 JSON 格式发送到路径为 /login 的 API。在 attemptAuthentication 方法中,从请求内容中获取到用户登录信息,并使用 AuthenticationManager 的 authenticate 方法来认证用户。如果身份认证通过,那么 successfulAuthentication 方法会被调用。在这个方法中,创建了一个新的 JWT 令牌,其中的主体设置为当前用户,并设置了签发日期和过期日期。JWT 令牌同时添加在 HTTP 响应的头和 Cookie 中。

java
public class JWTAuthenticationFilter extends
    UsernamePasswordAuthenticationFilter {
  private final AuthenticationManager authenticationManager;
  private final ObjectMapper objectMapper = new ObjectMapper();
  public JWTAuthenticationFilter(
      final AuthenticationManager authenticationManager) {
    this.authenticationManager = authenticationManager;
  }
  @Override
  public Authentication attemptAuthentication(final HttpServletRequest request,
      final HttpServletResponse response) throws AuthenticationException {
    try {
      final LoginRequest loginRequest = this.objectMapper
          .readValue(request.getInputStream(), LoginRequest.class);
      return this.authenticationManager.authenticate(
          new UsernamePasswordAuthenticationToken(
              loginRequest.getUsername(),
              loginRequest.getPassword()));
    } catch (final IOException e) {
      throw new AuthenticationServiceException(e.getMessage(), e);
    }
  }
  @Override
  protected void successfulAuthentication(final HttpServletRequest request,
      final HttpServletResponse response, final FilterChain chain,
      final Authentication authResult) {
    final String token = Jwts.builder()
        .setSubject(((User) authResult.getPrincipal()).getUsername())
        .setIssuedAt(new Date())
        .setExpiration(Date.from(OffsetDateTime.now().plusDays(5).toInstant()))
        .signWith(JWTKeyHolder.KEY, SignatureAlgorithm.HS512)
        .compact();
    response.addHeader(AUTHORIZATION_HEADER, TOKEN_PREFIX + token);
    response.addCookie(new Cookie(SecurityConstants.AUTH_COOKIE, token));
  }
}

下面代码中的 JWTFilter 是处理访问请求的过滤器。对于每个请求,getToken 方法尝试从 Cookie 和 HTTP 头中获取到 JWT 令牌,对于 JWT 令牌,通过 JWT 库进行解析并验证。如果验证通过,则从令牌中获取到用户名,再使用 UserDetailsService 来检查用户是否存在,最后把用户信息设置到 Spring Security 的上下文对象中,使得当前用户通过身份认证。

java
public class JWTFilter extends BasicAuthenticationFilter {
  private final UserDetailsService userDetailsService;
  public JWTFilter(final AuthenticationManager authenticationManager,
      final UserDetailsService userDetailsService) {
    super(authenticationManager);
    this.userDetailsService = userDetailsService;
  }
  @Override
  protected void doFilterInternal(final HttpServletRequest request,
      final HttpServletResponse response, final FilterChain filterChain)
      throws ServletException, IOException {
    final String token = this.getToken(request);
    if (!StringUtils.hasText(token)) {
      filterChain.doFilter(request, response);
      return;
    }
    final Claims claims = Jwts.parserBuilder()
        .setSigningKey(JWTKeyHolder.KEY)
        .build()
        .parseClaimsJws(token)
        .getBody();
    final UserDetails userDetails = this.userDetailsService
        .loadUserByUsername(claims.getSubject());
    SecurityContextHolder.getContext()
        .setAuthentication(new UsernamePasswordAuthenticationToken(
            userDetails.getUsername(), null, userDetails.getAuthorities()
        ));
    filterChain.doFilter(request, response);
  }
  private String getToken(final HttpServletRequest request) {
    if (request.getCookies() != null) {
      for (final Cookie cookie : request.getCookies()) {
        if (Objects.equals(cookie.getName(), SecurityConstants.AUTH_COOKIE)) {
          if (StringUtils.hasText(cookie.getValue())) {
            return cookie.getValue();
          }
        }
      }
    }
    final String header = request.getHeader(AUTHORIZATION_HEADER);
    if (header != null && header.startsWith(TOKEN_PREFIX)) {
      return header.substring(TOKEN_PREFIX.length());
    }
    return null;
  }
}

下面代码中的 SecurityConfig 是 Spring Security 的配置类。这里需要注意的是 JWT 相关的两个过滤器的添加方式,以及把会话的创建策略设置为无状态。

java
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
  @Override
  protected void configure(final HttpSecurity http) throws Exception {
    http.csrf().disable()
        .authorizeRequests()
        .antMatchers("/login").permitAll()
        .anyRequest().authenticated()
        .and()
        .addFilter(new JWTAuthenticationFilter(this.authenticationManager()))
        .addFilterBefore(new JWTFilter(this.authenticationManager(), this.userDetailsService()),
            UsernamePasswordAuthenticationFilter.class)
        .sessionManagement()
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
  }
  @Override
  protected void configure(final AuthenticationManagerBuilder auth)
      throws Exception {
    auth.inMemoryAuthentication()
        .withUser("admin")
        .password(this.passwordEncoder().encode("password"))
        .roles("ADMIN")
        .and()
        .withUser("user")
        .password(this.passwordEncoder().encode("password"))
        .roles("USER");
  }
  @Bean
  public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }
}

授权

在用户通过身份认证之后,另外一个相关的话题是如何进行授权认证解决的问题是证明用户的身份,而授权解决的是用户能做什么事情。在一个应用中,不同用户的权限是不同的。通常的做法是创建不同的角色,然后为用户分配不同的角色,每个角色有与之对应的权限。这就是基于角色的访问控制(Role-based Access Control,RBAC)。在上面代码 SecurityConfig 中,可以看到 ADMIN 和 USER 这样的角色名称。

在定义了用户及其关联的角色之后,另外一个问题是在什么层次上应用授权控制。最直接的做法是在 API 路径上进行控制,比如 /admin 这个路径,只允许具有 ADMIN 角色的用户来访问。通过 Spring Security 的 Java 配置 API 就可以完成,如下面的代码所示。

java
http.antMatchers("/admin").hasRole("ADMIN")

如果 API 路径的访问控制的粒度太粗,可以使用方法级别的授权控制。通过 @EnableGlobalMethodSecurity 注解可以启用方法级别的访问控制,其属性 securedEnabled 的作用是启用 Spring Security 的 @Secured 注解,如下面的代码所示。

java
@EnableGlobalMethodSecurity(securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}

下面代码中 ProtectedService 类的 doSomething 方法通过 @Secured 注解来声明了只有具有 ADMIN 角色的用户可以访问。

java
@Service
public class ProtectedService {
  @Secured("ROLE_ADMIN")
  public void doSomething() {
    System.out.println("Do this");
  }
}

如果需要更加灵活的访问控制,可以使用 Spring AOP 来完成。通过 SecurityContextHolder.getContext 方法可以获取到当前的 SecurityContext 对象,从中可以得知当前用户的身份信息。通过 AOP 可以检查每个方法调用的参数,并根据当前用户的身份信息,来执行特定的检查逻辑。

本课时介绍的访问控制主要与外部的 Web 应用和移动客户端相关。实际上,在微服务架构应用的内部,不同的微服务之间也有访问控制的需求。这一部分的内容将在第 38 课时介绍服务网格 Istio 时介绍。

总结

非功能性需求在应用开发中也扮演了非常重要的角色。本课时对安全性相关的话题进行了介绍,着重介绍了 JWT 和如何使用 Spring Security 进行用户认证和授权管理。通过本课时的学习,你可以充分了解 JWT,以及如何把 JWT 和 Spring Security 结合起来使用。