使用Java和Spring Security的JWT实现REST安全性

本文概述

安全

安全是便利的敌人, 反之亦然。从物理入口到网络银行平台的任何系统, 无论是虚拟的还是真实的, 该声明都是正确的。工程师一直在努力寻找给定用例的正确平衡, 偏向于一侧或另一侧。通常, 当出现新威胁时, 我们会朝着安全性方向发展, 而不是从便利性角度出发。然后, 我们看看是否可以在不降低安全性的前提下恢复失去的便利。而且, 这种恶性循环永远持续下去。

春季安全性教程:安全性与便利性图解

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

鸣叫

让我们尝试使用简单的Spring安全性教程来演示当今的REST安全性状态。

REST(代表代表性状态转移)服务最初是对具有大量规范和繁琐格式的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认证

JWT(是JSON Web令牌的缩写)是通常不使用REST服务就使用令牌在Web上进行身份验证的缺少的标准。当前, 它处于RFC 7519的草稿状态。它功能强大且可以承载很多信息, 但是即使它的大小相对较小, 也仍然易于使用。像任何其他令牌一样, JWT可以用于在身份提供者和服务提供者(不一定是同一系统)之间传递经过身份验证的用户的身份。它还可以携带用户的所有要求, 例如授权数据, 因此服务提供商不需要进入数据库或外部系统来验证每个请求的用户角色和权限。从令牌中提取数据。

这是设计JWT安全性的方式:

JWT Java流程图
  • 客户端通过将其凭据发送到身份提供者来登录。
  • 身份提供者验证凭据;如果一切正常, 它将检索用户数据, 生成一个JWT, 其中包含将用于访问服务的用户详细信息和权限, 并且还将在JWT上设置到期时间(可能是无限的)。
  • 身份提供者进行签名, 并在需要时对JWT进行加密, 并将其发送给客户端, 作为对具有凭据的初始请求的响应。
  • 客户端将JWT存储有限或无限的时间, 具体取决于身份提供者设置的到期时间。
  • 客户端将针对每个请求的存储的JWT发送到”授权”标头中, 以发送给服务提供商。
  • 对于每个请求, 服务提供者从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, 我们将通过在根XML元素中添加xmlns =” http://www.springframework.org/schema/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 id="jwtAuthenticationFilter" class="com.srcmini.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)接下来, 我们定义应用于所有请求的过滤器链, 同时添加两个重要的配置:入口点引用并将会话创建设置为无状态(我们不希望出于安全目的而创建会话, 因为我们为每个请求使用令牌) 。
  • (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中使用令牌的解析。从不安全的登录和注册REST服务中调用generateToken()方法, 这些服务是不安全的, 不会触发任何安全检查或要求请求中包含令牌。最后, 它根据用户生成将返回给客户端的令牌。

总结

尽管旧的标准化安全方法(会话cookie, HTTP Basic和HTTP Digest)也可以与REST服务一起使用, 但是它们都有一些问题, 可以通过使用更好的标准来避免。 JWT准时到达是为了节省一天, 最重要的是, 它非常接近成为IETF标准。

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

微信公众号
手机浏览(小程序)
0
分享到:
没有账号? 忘记密码?