REST 安全性与 JWT 使用 Java 和 Spring Security

已发表: 2022-03-11

安全

安全是便利的敌人,反之亦然。 这句话适用于任何系统,无论是虚拟的还是真实的,从实体房屋入口到网上银行平台。 工程师一直在努力为给定的用例找到合适的平衡点,倾向于一侧或另一侧。 通常,当出现新的威胁时,我们会转向安全而远离便利。 然后,我们看看是否可以在不过多降低安全性的情况下恢复一些失去的便利。 而且,这种恶性循环会永远持续下去。

spring security 教程:安全性与便利性图解

安全是便利的敌人,反之亦然。
鸣叫

让我们尝试检查一下今天的 REST 安全状态,使用简单的 Spring 安全教程来演示它的实际操作。

REST(代表 Representational State Transfer)服务最初是一种极其简化的 Web 服务方法,它具有大量规范和繁琐的格式,例如用于描述服务的 WSDL,或用于指定消息格式的 SOAP。 在 REST 中,我们没有这些。 我们可以在纯文本文件中描述 REST 服务,并使用我们想要的任何消息格式,例如 JSON、XML 甚至是纯文本。 简化的方法也适用于 REST 服务的安全性; 没有定义的标准强加一种特定的方式来验证用户。

尽管 REST 服务没有太多的规定,但一个重要的问题是缺少状态。 这意味着服务器不保留任何客户端状态,会话就是一个很好的例子。 因此,服务器响应每个请求,就好像它是客户端发出的第一个请求一样。 然而,即使是现在,许多实现仍然使用基于 cookie 的身份验证,它继承自标准网站架构设计。 REST 的无状态方法使得会话 cookie 从安全角度来看是不合适的,但尽管如此,它们仍然被广泛使用。 除了忽略所需的无国籍状态外,简化方法是一种预期的安全权衡。 与用于 Web 服务的 WS-Security 标准相比,创建和使用 REST 服务要容易得多,因此便利性达到了顶峰。 权衡是相当渺茫的安全性。 会话劫持和跨站请求伪造 (XSRF) 是最常见的安全问题。

在试图摆脱服务器的客户端会话时,偶尔会使用一些其他方法,例如基本或摘要 HTTP 身份验证。 两者都使用Authorization标头来传输用户凭据,并添加了一些编码(HTTP Basic)或加密(HTTP Digest)。 当然,它们也存在与网站相同的缺陷:必须通过 HTTPS 使用 HTTP Basic,因为用户名和密码以易于可逆的 base64 编码发送,而 HTTP Digest 强制使用已证明不安全的过时 MD5 散列。

最后,一些实现使用任意令牌来验证客户端。 这个选项似乎是我们目前最好的。 如果实施得当,它可以解决 HTTP Basic、HTTP Digest 或会话 cookie 的所有安全问题,使用简单,并且遵循无状态模式。

然而,对于这样的任意令牌,几乎没有涉及标准。 每个服务提供商都有自己的想法,知道在令牌中放入什么,以及如何对其进行编码或加密。 使用来自不同提供商的服务需要额外的设置时间,只是为了适应所使用的特定令牌格式。 另一方面,其他方法(会话 cookie、HTTP Basic 和 HTTP Digest)为开发人员所熟知,几乎所有设备上的所有浏览器都可以开箱即用地使用它们。 框架和语言已经为这些方法做好了准备,具有内置函数可以无缝地处理每个方法。

智威汤逊认证

JWT(从 JSON Web Token 缩写)是使用令牌在 Web 上进行身份验证的一般标准缺失的标准,不仅适用于 REST 服务。 目前,它处于 RFC 7519 的草案状态。它很健壮,可以承载大量信息,但尽管它的大小相对较小,但使用起来仍然很简单。 与任何其他令牌一样,JWT 可用于在身份提供者和服务提供者(不一定是相同的系统)之间传递经过身份验证的用户的身份。 它还可以携带用户的所有声明,例如授权数据,因此服务提供者不需要进入数据库或外部系统来验证每个请求的用户角色和权限; 该数据是从令牌中提取的。

以下是 JWT 安全设计的工作原理:

JWT java流程图解

  • 客户端通过将其凭据发送给身份提供者来登录。
  • 身份提供者验证凭据; 如果一切正常,它会检索用户数据,生成包含将用于访问服务的用户详细信息和权限的 JWT,并在 JWT 上设置过期时间(可能无限制)。
  • 身份提供者签名,如果需要,加密 JWT 并将其发送给客户端,作为对带有凭据的初始请求的响应。
  • 客户端将 JWT 存储有限或无限的时间,具体取决于身份提供者设置的到期时间。
  • 客户端将存储的 JWT 发送到服务提供商的每个请求的 Authorization 标头中。
  • 对于每个请求,服务提供者从Authorization标头中获取 JWT 并对其进行解密,如果需要,验证签名,如果一切正常,则提取用户数据和权限。 仅基于此数据,并且无需在数据库中查找更多详细信息或联系身份提供者,它就可以接受或拒绝客户请求。 唯一的要求是身份和服务提供者有一个加密协议,以便服务可以验证签名甚至解密哪个身份被加密。

此流程提供了极大的灵活性,同时仍保持事物的安全性和易于开发。 通过使用这种方法,很容易将新的服务器节点添加到服务提供者集群中,初始化它们时只有验证签名和解密令牌的能力,方法是为它们提供一个共享的密钥。 不需要会话复制、数据库同步或节点间通信。 充分发挥 REST 的作用。

JWT 与其他任意令牌的主要区别在于令牌内容的标准化。 另一种推荐的方法是使用 Bearer 方案在Authorization标头中发送 JWT 令牌。 标头的内容应如下所示:

 Authorization: Bearer <token>

REST 安全实施

为了使 REST 服务按预期工作,与经典的多页面网站相比,我们需要一种稍微不同的授权方法。

当客户端请求安全资源时,REST 服务器不会通过重定向到登录页面来触发身份验证过程,而是使用请求本身中可用的数据(在本例中为 JWT 令牌)对所有请求进行身份验证。 如果此类身份验证失败,则重定向将毫无意义。 REST API 只是发送一个 HTTP 代码 401(未经授权)响应,客户端应该知道该做什么; 例如,浏览器将显示一个动态 div 以允许用户提供用户名和密码。

另一方面,在经典的多页面网站中成功验证后,用户通过使用 HTTP 代码 301(永久移动)重定向到主页,或者更好的是,用户最初请求触发的页面认证过程。 使用 REST,这又是没有意义的。 相反,我们将继续执行请求,就好像资源根本没有受到保护一样,返回 HTTP 代码 200(OK)和预期的响应正文。

Spring 安全示例

使用 JWT 和 Java 的 Spring REST 安全性

现在,让我们看看如何使用 Java 和 Spring 实现基于 JWT 令牌的 REST API,同时尽可能地重用 Spring Security 默认行为。

正如预期的那样,Spring Security 框架附带了许多处理“旧”授权机制的现成插件类:会话 cookie、HTTP Basic 和 HTTP Digest。 但是,它缺乏对 JWT 的原生支持,我们需要亲自动手才能使其工作。 有关更详细的概述,您应该查阅 Spring Security 官方文档。

现在,让我们开始使用web.xml中通常的Spring Security 过滤器定义

 <filter> <filter-name>springSecurityFilterChain</filter-name> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> </filter> <filter-mapping> <filter-name>springSecurityFilterChain</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

请注意,Spring Security 过滤器的名称必须完全是springSecurityFilterChain才能使 Spring 配置的其余部分开箱即用。

接下来是与安全性相关的 Spring bean 的 XML 声明。 为了简化 XML,我们将通过将xmlns="http://www.springframework.org/schema/security"添加到根 XML 元素来将默认命名空间设置为security 。 XML 的其余部分如下所示:

 <global-method-security pre-post-annotations="enabled" /> (1) <http pattern="/api/login" security="none"/> (2) <http pattern="/api/signup" security="none"/> <http pattern="/api/**" entry-point-ref="restAuthenticationEntryPoint" create-session="stateless"> (3) <csrf disabled="true"/> (4) <custom-filter before="FORM_LOGIN_FILTER" ref="jwtAuthenticationFilter"/> (5) </http> <beans:bean class="com.toptal.travelplanner.security.JwtAuthenticationFilter"> (6) <beans:property name="authenticationManager" ref="authenticationManager" /> <beans:property name="authenticationSuccessHandler" ref="jwtAuthenticationSuccessHandler" /> (7) </beans:bean> <authentication-manager alias="authenticationManager"> <authentication-provider ref="jwtAuthenticationProvider" /> (8) </authentication-manager>
  • (1) 在这一行中,我们在上下文中的任何 spring bean 上激活@PreFilter@PreAuthorize@PostFilter@PostAuthorize注释。
  • (2) 我们定义登录和注册端点以跳过安全性; 即便是“匿名者”也应该能够做到这两个操作。
  • (3) 接下来,我们定义应用于所有请求的过滤器链,同时添加两个重要配置:入口点引用和将会话创建设置为stateless (我们不希望出于安全目的而创建会话,因为我们为每个请求使用令牌) .
  • (4) 我们不需要csrf保护,因为我们的代币不受它的影响。
  • (5) 接下来,我们在 Spring 的预定义过滤器链中插入我们的特殊身份验证过滤器,就在表单登录过滤器之前。
  • (6) 这个bean是我们的认证过滤器的声明; 因为它扩展了 Spring 的AbstractAuthenticationProcessingFilter ,我们需要在 XML 中声明它来连接它的属性(自动连接在这里不起作用)。 稍后我们将解释过滤器的作用。
  • (7) AbstractAuthenticationProcessingFilter的默认成功处理程序对于 REST 用途来说不够好,因为它将用户重定向到成功页面; 这就是我们在这里设置自己的原因。
  • (8) 我们的过滤器使用authenticationManager创建的提供者的声明来对用户进行身份验证。

现在让我们看看我们如何实现上面 XML 中声明的特定类。 请注意,Spring 将为我们连接它们。 我们从最简单的开始。

RestAuthenticationEntryPoint.java

 public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { // This is invoked when user tries to access a secured REST resource without supplying any credentials // We should just send a 401 Unauthorized response because there is no 'login page' to redirect to response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); } }

如上所述,当身份验证失败时,此类仅返回 HTTP 代码 401(未授权),覆盖默认的 Spring 重定向。

JwtAuthenticationSuccessHandler.java

 public class JwtAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { // We do not need to do anything extra on REST authentication success, because there is no page to redirect to } }

这个简单的覆盖删除了成功身份验证的默认行为(重定向到主页或用户请求的任何其他页面)。 如果你想知道为什么我们不需要重写AuthenticationFailureHandler ,这是因为如果没有设置重定向 URL,默认实现不会重定向到任何地方,所以我们只是避免设置 URL,这已经足够了。

JwtAuthenticationFilter.java

 public class JwtAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public JwtAuthenticationFilter() { super("/**"); } @Override protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { return true; } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { String header = request.getHeader("Authorization"); if (header == null || !header.startsWith("Bearer ")) { throw new JwtTokenMissingException("No JWT token found in request headers"); } String authToken = header.substring(7); JwtAuthenticationToken authRequest = new JwtAuthenticationToken(authToken); return getAuthenticationManager().authenticate(authRequest); } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { super.successfulAuthentication(request, response, chain, authResult); // As this authentication is in HTTP header, after success we need to continue the request normally // and return the response as if the resource was not secured at all chain.doFilter(request, response); } }

这个类是我们JWT认证过程的入口点; 过滤器从请求标头中提取 JWT 令牌,并将身份验证委托给注入的AuthenticationManager 。 如果未找到令牌,则会引发异常以停止处理请求。 我们还需要一个覆盖来成功验证,因为默认的 Spring 流将停止过滤器链并继续进行重定向。 请记住,如上所述,我们需要链完全执行,包括生成响应。

JwtAuthenticationProvider.java

 public class JwtAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { @Autowired private JwtUtil jwtUtil; @Override public boolean supports(Class<?> authentication) { return (JwtAuthenticationToken.class.isAssignableFrom(authentication)); } @Override protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { } @Override protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { JwtAuthenticationToken jwtAuthenticationToken = (JwtAuthenticationToken) authentication; String token = jwtAuthenticationToken.getToken(); User parsedUser = jwtUtil.parseToken(token); if (parsedUser == null) { throw new JwtTokenMalformedException("JWT token is not valid"); } List<GrantedAuthority> authorityList = AuthorityUtils.commaSeparatedStringToAuthorityList(parsedUser.getRole()); return new AuthenticatedUser(parsedUser.getId(), parsedUser.getUsername(), token, authorityList); } }

在这个类中,我们使用 Spring 的默认AuthenticationManager ,但我们使用自己的AuthenticationProvider注入它来执行实际的身份验证过程。 为了实现这一点,我们扩展了AbstractUserDetailsAuthenticationProvider ,它只要求我们根据身份验证请求返回UserDetails ,在我们的例子中,是包装在JwtAuthenticationToken类中的 JWT 令牌。 如果令牌无效,我们会抛出异常。 但是,如果它是有效的并且JwtUtil解密成功,我们将提取用户详细信息(我们将在JwtUtil类中看到确切的方式),而根本不访问数据库。 有关用户的所有信息,包括他或她的角色,都包含在令牌本身中。

JwtUtil.java

 public class JwtUtil { @Value("${jwt.secret}") private String secret; /** * Tries to parse specified String as a JWT token. If successful, returns User object with username, id and role prefilled (extracted from token). * If unsuccessful (token is invalid or not containing all required user properties), simply returns null. * * @param token the JWT token to parse * @return the User object extracted from specified token or null if a token is invalid. */ public User parseToken(String token) { try { Claims body = Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); User u = new User(); u.setUsername(body.getSubject()); u.setId(Long.parseLong((String) body.get("userId"))); u.setRole((String) body.get("role")); return u; } catch (JwtException | ClassCastException e) { return null; } } /** * Generates a JWT token containing username as subject, and userId and role as additional claims. These properties are taken from the specified * User object. Tokens validity is infinite. * * @param u the user for which the token will be generated * @return the JWT token */ public String generateToken(User u) { Claims claims = Jwts.claims().setSubject(u.getUsername()); claims.put("userId", u.getId() + ""); claims.put("role", u.getRole()); return Jwts.builder() .setClaims(claims) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } }

最后, JwtUtil类负责将令牌解析为User对象并从User对象生成令牌。 这很简单,因为它使用jjwt库来完成所有 JWT 工作。 在我们的示例中,我们只是将用户名、用户 ID 和用户角色存储在令牌中。 我们还可以存储更多任意内容并添加更多安全功能,例如令牌的过期时间。 如上所示,在AuthenticationProvider中使用了对令牌的解析。 generateToken()方法是从登录和注册 REST 服务调用的,这些服务是不安全的,不会触发任何安全检查,也不会要求请求中存在令牌。 最后,它根据用户生成将返回给客户端的令牌。

结论

尽管旧的标准化安全方法(会话 cookie、HTTP Basic 和 HTTP Digest)也适用于 REST 服务,但它们都存在问题,如果使用更好的标准可以很好地避免这些问题。 JWT 及时出现以挽救局面,最重要的是,它非常接近成为 IETF 标准。

JWT 的主要优势是以无状态且因此可扩展的方式处理用户身份验证,同时使用最新的加密标准确保一切安全。 在令牌本身中存储声明(用户角色和权限)在分布式系统架构中创造了巨大的好处,其中发出请求的服务器无法访问身份验证数据源。