使用Java和Spring Security的JWT的REST安全性(译)

一、安全

安全是方便的敌人,反之亦然。对于虚拟或真实的系统,从物理入口到网络银行平台,这一说法是真实的。工程师不断尝试为给定的用例找到适当的平衡,倾斜到一边或另一侧。

通常,当出现新的威胁时,我们转向安全,远离方便。然后,我们看看是否可以恢复一些丢失的方便,而不会降低安全性。此外,这个恶性循环也永远存在。

安全性与方便性

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

让我们来看看REST服务目前在安全性和便利性方面的地位。REST(代表代理状态转移)服务作为极其简化的Web服务开始,具有巨大的规范和繁琐的格式,例如:用于描述服务的 <u>WSDL</u> 或用于指定消息格式的 <u>SOAP</u>。在 REST 中,我们没有一个。我们可以在纯文本文件中描述 REST 服务,并使用我们想要的任何消息格式,如 JSON,XML,甚至是纯文本。简化的方法也适用于 REST 服务的安全性; 没有定义的标准强加了一种特定的方式来验证用户。

虽然REST服务没有太多的规定,但重要的是缺少状态。这意味着服务器不保留任何客户端状态,会话是一个很好的例子。因此,服务器回复每个请求,就好像它是客户端的第一个。然而,即使现在,许多实现仍然使用基于 cookie 的身份验证,这是从标准网站架构设计继承的。REST 的无状态方法使得会话 cookie 从安全的角度来看是不合适的,但是它们仍然被广泛使用。除了忽视所需的无国籍之外,简化的做法也是预期的安全性权衡。与用于 Web 服务的 WS-Security 标准相比,创建和使用 REST 服务要容易得多,因此方便通过了屋顶。权衡是非常苗条的安全;

在尝试从服务器中删除客户端会话时,有些其他方法已经被偶尔使用,比如 Basic 或 Digest HTTP 认证。两者都使用一个 Authorization 标头来传送用户凭证,并添加一些编码(HTTP Basic)或加密(HTTP Digest)。当然,它们在网站上也出现了同样的缺陷:HTTP Basic 必须通过 HTTPS 使用,因为用户名和密码以易于逆转的 base64 编码发送,而 HTTP 摘要强制使用被证明是不安全的过时的 MD5 哈希值。

最后,一些实现使用任意令牌来验证客户端。这个选项似乎是我们现在最好的。如果正确实现,它会修复 HTTP Basic,HTTP Digest 或会话 cookie 的所有安全问题,使用起来很简单,并且遵循无状态模式。

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

二、JWT

JWT(从 JSON Web Token 缩写)是通常使用令牌进行身份验证的缺少的标准化,不仅适用于 REST 服务。目前,草案状态为 <u>RFC 7519</u>。它是强大的,可以携带大量的信息,但即使它的尺寸相对较小,仍然使用起来很简单。像任何其他令牌一样,JWT 可以用于在身份提供商和服务提供商(不一定是相同的系统)之间传递身份验证的用户身份。它还可以承载用户的所有权利,例如授权数据,因此服务提供商不需要进入数据库或外部系统来验证每个请求的用户角色和权限; 从令牌中提取数据。

以下是JWT的工作原理:

JWT流
  • 客户端通过将其凭据发送给身份提供者来登录。

  • 身份提供者验证凭据; 如果一切正常,它将检索用户数据,生成包含用于访问服务的用户详细信息和权限的 JWT,并且还会在 JWT 上设置到期(可能是无限制的)。

  • 身份提供商签名,如果需要,加密 JWT,并将其发送给客户端,作为对具有凭据的初始请求的响应。

  • 客户端根据身份提供商设置的到期时间限制或无限制地存储 JWT。

  • 客户端将所存储的 JWT 发送到服务提供商的每个请求的授权头中。

  • 对于每个请求,服务提供者从 Authorization 头部接收 JWT ,如果需要,对其进行解密,验证签名,如果一切正常,则提取用户数据和权限。仅基于这些数据,并且再次查找数据库中的进一步细节或联系身份提供者时,它可以接受或拒绝客户端请求。唯一的要求是身份和服务提供商就加密达成协议,以便服务可以验证签名,甚至解密哪个身份被加密。

这个流程允许很大的灵活性,同时保持安全和容易开发。通过使用这种方法,可以轻松地向服务提供商集群添加新的服务器节点,只需要通过向其提供一个共享的秘密密钥来初始化它们,只能验证签名并解密令牌。不需要会话复制,数据库同步或节点间通信。REST 在其充分的荣耀。

JWT 和其他任意令牌之间的主要区别是令牌内容的标准化。另一个推荐的方法是 Authorization 使用承载方案将 JWT 令牌发送到头部。标题的内容应如下所示:

Authorization: Bearer <token>

三、实施

对于 REST 服务,如预期的那样工作,与传统的多页面网站相比,我们需要稍微不同的授权方法。

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

另一方面,在经典的多页面网站中成功的认证之后,用户通过使用HTTP代码 301(永久移动)来重定向,通常到主页,或者甚至更好地到达用户最初请求触发的页面认证过程。使用 REST,这再没有任何意义。相反,我们将继续执行请求,就像资源根本不安全一样,返回 HTTP 代码 200(OK)和预期的响应体。

四、Spring Security

REST安全与JWT,Spring安全和Java

现在,我们来看看如何使用 <u>Java</u> 和 <u>Spring</u> 来 <u>实现</u> 基于JWT 令牌的 REST API,同时尝试重用 Spring 安全性默认行为。正如预期的那样,Spring Security 框架带有许多准备插入的类,它们处理“旧”授权机制:会话 cookie,HTTP Basic 和 HTTP 摘要。然而,它缺乏对 JWT 的本地支持,我们需要自己动手,使其工作。

首先,我们从普通的Spring Security过滤器定义开始 web.xml

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

请注意,过滤器的名称必须恰好 springSecurityFilterChain 适用于Spring配置的其余部分才能开箱即用。

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

其余的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.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)在这一行中,我们激活 @PreFilter@PreAuthorize@PostFilter@PostAuthorize 在上下文任何弹簧豆注释。

  • (2)我们定义登录和注册端点来跳过安全性; 甚至“匿名”都应该能够做这两个操作。

  • (3)接下来,我们定义应用于所有请求的过滤器链,同时添加两个重要的配置:入口点引用和设置会话创建 stateless(我们不希望为安全起见创建会话,因为我们正在为每个请求使用令牌)。

  • (4)我们不需要 csrf 保护,因为我们的令牌是免疫的。

  • (5)接下来,我们在 Spring 的预定义过滤器链中插入特殊的认证过滤器,就在 Form 登录过滤器之前。

  • (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。

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 对象生成令牌。它是直接的,因为它使用 <u>jjwt图书馆</u> 来完成所有的 JWT 工作。在我们的例子中,我们简单地将用户名,用户ID和用户角色存储在令牌中。我们还可以存储更多的任意东西,并添加更多的安全功能,例如令牌的到期。 AuthenticationProvider 如上所示,使用令牌的解析。该 generateToken() 方法从登录和注册 REST 服务调用,它们是不安全的,并且不会触发任何安全检查或要求令牌存在于请求中。最后,它会根据用户生成将返回给客户端的令牌。

五、结论

虽然旧的标准化安全方法(会话 cookie,HTTP Basic 和 HTTP 摘要)也可以与 REST 服务一起使用,但是它们都有一些问题,通过使用更好的标准来避免这种情况。JWT 即将到来,以节省时间,最重要的是非常接近成为 IETF 标准。

JWT 的主要优势是在无状态的情况下处理用户身份验证,因此可扩展的方式,同时保持一切安全与最新的密码学标准。将令牌(用户角色和权限)存储在令牌本身中,在发布请求的服务器无法访问认证数据源的分布式系统体系结构中创造了巨大的收益。

BY DEJAN MILOSEVIC
原文:https://www.toptal.com/java/rest-security-with-jwt-spring-security-and-java
谷歌翻译

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容