返回介绍

10.2 HTTP Digest authentication

发布于 2025-04-26 13:16:47 字数 10566 浏览 0 评论 0 收藏

10.2.1 简介

HTTP 基本认证虽然简单易用,但是在安全方面问题突出,于是又推出了 HTTP 摘要认证。HTTP 摘要认证最早在 RFC2069 中被定义,随后被 RFC2617(https://tools.ietf.org/html/rfc2617)所取代,在 RFC2617 中引入了一系列增强安全性的参数,以防止各种可能存在的网络攻击。

相比于 HTTP 基本认证,HTTP 摘要认证的安全性有了很大提高,但是依然存在问题,例如不支持 bCrypt、PBKDF2、SCrypt 等加密方式。

图 10-3 所示描述了 HTTP 摘要认证的具体流程,从图中可以看出,这个认证流程和 HTTP 基本认证流程一致,不同的是每次传递的参数有所差异。

图 10-3 HTTP 摘要认证流程图

10.2.2 具体用法

Spring Security 中为 HTTP 摘要认证提供了相应的 AuthenticationEntryPoint 和 Filter,但是没有自动化配置,需要我们手动配置,配置方式如下:

    @Configuration
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
       @Override
       protected void configure(HttpSecurity http) throws Exception {
           http.authorizeRequests()
                   .anyRequest().authenticated()
                   .and()
                   .csrf().disable()
                   .exceptionHandling()
                   .authenticationEntryPoint(digestAuthenticationEntryPoint())
                   .and()
                   .addFilter(digestAuthenticationFilter());
       }
       DigestAuthenticationEntryPoint digestAuthenticationEntryPoint() {
           DigestAuthenticationEntryPoint entryPoint =
                                             new DigestAuthenticationEntryPoint();
           entryPoint.setNonceValiditySeconds(3600);
           entryPoint.setRealmName("myrealm");
           entryPoint.setKey("javaboy");
           return entryPoint;
       }
       DigestAuthenticationFilter digestAuthenticationFilter() throws Exception {
           DigestAuthenticationFilter filter = new DigestAuthenticationFilter();
       filter.setAuthenticationEntryPoint(digestAuthenticationEntryPoint());
           filter.setUserDetailsService(userDetailsServiceBean());
           return filter;
       }
       @Override
       @Bean
       public UserDetailsService userDetailsServiceBean() throws Exception {
           InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
           manager.createUser(User.withUsername("javaboy")
                                         .password("123").roles("admin").build());
           return manager;
       }
       @Bean
       PasswordEncoder passwordEncoder() {
           return NoOpPasswordEncoder.getInstance();
       }
    }

(1)首先由开发者提供一个 DigestAuthenticationEntryPoint 实例(相当于 HTTP 基本认证中的 BasicAuthenticationEntryPoint),当用户发起一个没有认证的请求时,由该实例进行处理。配置该实例时,我们需要提供一个随机数的有效期,RealmName 以及一个 Key。

(2)创建一个 DigestAuthenticationFilter 实例,并添加到 Spring Security 过滤器链中,DigestAuthenticationFilter 的作用类似于 HTTP 基本认证中 BasicAuthenticationFilter 过滤器的作用。

(3)配置一个 UserDetailsService 实例。

(4)配置一个 PasswordEncoder 实例。

需要注意的是,由于客户端是对明文密码进行 Hash 运算,所以服务端也需要保存用户的明文密码,因此这里提供的 PasswordEncoder 实例是 NoOpPasswordEncoder 的实例。

在 DigestAuthenticationFilter 过滤器中有一个 passwordAlreadyEncoded 属性,表示用户密码是否已经编码,该属性默认为 false,表示密码未进行编码。开发者可以对密码进行编码,只需要先将该属性设置为 true,然后将 username + ":" + realm + ":" + password 使用 MD5 算法计算其消息摘要,将计算结果作为用户密码即可。举个简单例子,例如用户名是 javaboy,realm 是 myrealm,用户密码是 123,则计算器消息摘要代码如下:

    String rawPassword = "javaboy:myrealm:123";
    MessageDigest digest = MessageDigest.getInstance("MD5");
    String s = new String(Hex.encode(digest.digest(rawPassword.getBytes())));
    System.out.println(s);

计算结果如下:

    e7ecfd3f08e6960f154e1ff29079fbd3

然后修改配置类:

    DigestAuthenticationFilter digestAuthenticationFilter() throws Exception {
       DigestAuthenticationFilter filter = new DigestAuthenticationFilter();
       filter.setAuthenticationEntryPoint(digestAuthenticationEntryPoint());
       filter.setUserDetailsService(userDetailsServiceBean());
       filter.setPasswordAlreadyEncoded(true);
       return filter;
    }
    @Override
    @Bean
    public UserDetailsService userDetailsServiceBean() throws Exception {
       InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
       manager.createUser(User.withUsername("javaboy")
                .password("e7ecfd3f08e6960f154e1ff29079fbd3")
                .roles("admin").build());
       return manager;
    }

调用 DigestAuthenticationFilter 的 setPasswordAlreadyEncoded 方法,将 passwordAlready Encoded 属性设置为 true,然后设置用户密码为编码后的密码即可。

注意,这样配置完成后,PasswordEncoder 的实例依然是 NoOpPasswordEncoder,具体原因将在下一小节的源码分析中介绍。

配置完成后,启动项目,访问页面时,浏览器就会弹出输入框要求输入用户名/密码信息,具体流程和 HTTP 基本认证一致,这里不再赘述。

10.2.3 源码分析

接下来对 HTTP 摘要认证的源码进行简单分析,我们从质询、客户端处理以及请求解析三个方面入手。需要说明的是,Spring Security 源码中关于 HTTP 摘要认证并未严格遵守 RFC2617,下面的分析以 Spring Security 源码为准。

10.2.3.1 质询

HTTP 摘要认证的质询是由 DigestAuthenticationEntryPoint#commence 方法负责处理的,源码如下:

    public void commence(HttpServletRequest request,
                            HttpServletResponse response,
                     AuthenticationException authException) throws IOException {
       HttpServletResponse httpResponse = response;
       long expiryTime = System.currentTimeMillis() + (nonceValiditySeconds * 1000);
       String signatureValue = DigestAuthUtils.md5Hex(expiryTime + ":" + key);
       String nonceValue = expiryTime + ":" + signatureValue;
       String nonceValueBase64 =
               new String(Base64.getEncoder().encode(nonceValue.getBytes()));
       String authenticateHeader = "Digest realm=\"" + realmName + "\", "
               + "qop=\"auth\", nonce=\"" + nonceValueBase64 + "\"";
       if (authException instanceof NonceExpiredException) {
           authenticateHeader = authenticateHeader + ", stale=\"true\"";
       }
       httpResponse.addHeader("WWW-Authenticate", authenticateHeader);
       httpResponse.sendError(HttpStatus.UNAUTHORIZED.value(),
           HttpStatus.UNAUTHORIZED.getReasonPhrase());
    }

和 HTTP 基本认证一样,这里的响应码也是 401,响应头中也包含 WWW-Authenticate 字段,不同的是 WWW-Authenticate 字段的值有所区别:

- Digest:表示这里使用 HTTP 摘要认证。

- Realm:服务端返回的标识访问资源的安全域。

- qop:服务端返回的保护级别,客户端据此选择合适的摘要算法,如果值为 auth,则表示只进行身份认证;如果取值为 auth-int,则除了身份认证之外,还要校验内容完整性。

- nonce:服务端生成的一个随机字符串,在客户端生成摘要信息时会用到该随机字符串。

- stale:一个标记,当随机字符串 nonce 过期时,会包含该标记。stale=true 表示客户端不必再次弹出输入框,只需要带上已有的认证信息,重新发起认证请求即可。

随机字符串 nonce 的生成过程是,先对过期时间和 key 组成的字符串 expiryTime + ":" + key 计算出消息摘要 signatureValue,然后再对 expiryTime + ":" + signatureValue 进行 Base64 编码,进而获取 nonce。

经过上面的分析,我们可以得出,响应头内容如下:

    HTTP/1.1 401
    WWW-Authenticate: Digest realm="myrealm", qop="auth",
    nonce="MTU5OTIyNDE4NDg1NDowZGIzOWU0NGM2MTA5ZDVmZDkyNWYzMzRmNmYxZjg1ZA=="

10.2.3.2 客户端处理

当客户端(浏览器)收到质询请求后,弹出输入框,用户输入用户名/密码,然后客户端会对用户名/密码进行 Hash 运算。根据响应头中 qop 值的不同,运算过程会略有差异。

如果服务端响应头中不包含 qop 参数,则运算过程如下:

(1)对 username + ":" + realm + ":" + password 计算其消息摘要得到 digest1。

(2)对 HttpMethod+ ":" + uri 计算其消息摘要得到 digest2。

(3)对 digest1 + ":" + nonce + ":" + digest2 计算其消息摘要得到 response。

如果服务端响应头中 qop="auth",则前两步计算步骤一致,第 3 步不同,第 3 步计算方式如下:

对 digest1 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + digest2 计算其消息摘要,得到 response。

这里的几个参数和大家解释下:

- nonce 和 qop:就是服务端返回的数据。

- nc:表示请求次数,该参数在防止重放攻击时有用。

- cnonce:表示客户端生成的随机数。

这里计算出来的 response 就是客户端提交给服务端的重要认证信息,服务端主要据此判断用户身份是否合法。

最终客户端提交的请求头如下:

    GET /hello HTTP/1.1
    Host: localhost:8080
    Authorization: Digest username="javaboy", realm="myrealm",
    nonce="MTU5OTIyNDE4NDg1NDowZGIzOWU0NGM2MTA5ZDVmZDkyNWYzMzRmNmYxZjg1ZA=="
    ,uri="/hello", response="f14cbc00cc461092c3f6d392d234f5b1", qop=auth
    , nc=00000002, cnonce="2867d826762e8b56"

用户名放在请求头中,用户密码则经过各种 MD5 运算之后,现在包含在 response 中,生成 response 时所需要的 cnonce、nonce、nc、qop、realm 以及 uri 也都包含在请求头中一并发送给服务端,服务端拿到这些参数之后,再根据用户名去数据库中查询到用户密码,然后进行 MD5 运算,将运算结果和 response 进行比对,就能知道请求是否合法。

什么是重放攻击?

重放攻击(Replay attack)也称为回放攻击,这是一种通过重复或者延迟有效数据的网络攻击形式,是一种低级别的“中间人攻击”。举个简单例子,当用户和服务端进行数据交互时,为了向服务端证明身份,传递了一个经过 MD5 运算的字符串,该字符串被黑客窃取到。黑客就可以通过该字符串冒充受害者。

10.2.3.3 请求解析

请求解析主要是在 DigestAuthenticationFilter 过滤器中完成的,我们来看一下其 doFilter 方法:

这个 doFilter 方法比较冗长,我们逐步进行分析一下:

(1)首先从请求头中获取 Authorization 字段,如果该字段不存在,或者该字段的值不是以 Digest 开头,则直接执行剩下的过滤器。在执行剩下的过滤器时,最终会进入到质询环节。

(2)根据获取到的 Authorization 字段信息,构造出一个 DigestData 对象。这个过程就是将请求头中的 username、realm、nonce、uri、response、qop、nc、cnonce 等字段解析出来,设置给 DigestData 对象中对应的属性,方便后续处理。

(3)接下来调用 validateAndDecode 方法,对刚刚解析出来的数据进行初步的验证。这里的验证代码比较简单,此处就不一一列出来了,主要介绍一下方法的执行逻辑:①首先判断 username、realm、nonce、uri 以及 response 是否为 null,如果存在为 null 的数据,则直接抛出异常;②判断 qop 的值是否为 auth,如果为 auth,则 nc 和 cnonce 都不能为 null,否则抛出异常;③检验请求传来的 realm 和 authenticationEntryPoint 中的 realm 是否相等,如果不相等,则直接抛出异常;④尝试对 nonce 进行 Base64 解码,如果解码失败,则抛出异常;⑤对 nonce 进行 Base64 解码,将解码的结果拆分成一个名为 nonceTokens 的数组,如果数组的长度不为 2,则抛出异常;⑥取出 nonceTokens 数组中的第 0 项,就是 nonce 的过期时间,将其赋值给 nonceExpiryTime 属性;⑦根据 nonceExpiryTime 以及 authenticationEntryPoint 中的 key,进行 MD5 运算,并将运算结果和 nonceTokens 数组中的第 1 项进行比较,如果不相等,则抛出异常。至此,就完成了对请求参数的初步校验。

(4)根据请求传来的用户名去加载用户对象,先去缓存中加载,缓存中没有,则调用 userDetailsService 实例去加载(这也是为什么我们在配置 DigestAuthenticationFilter 过滤器时,需要指定 userDetailsService 实例的原因)。

(5)接下来调用 calculateServerDigest 方法去计算服务端的摘要信息,该方法内部又调用了 DigestAuthUtils.generateDigest 方法。计算过程比较简单,这里主要说下计算流程:①首先是 a1Md5 的计算,如果设置了 passwordAlreadyEncoded,则直接将用户密码赋值给 a1Md5,否则根据 username、realm 以及 password 计算出 a1Md5;②a2Md5 计算方式是固定的,通过 httpMethod 以及请求 uri 计算出 a2Md5;③如果 qop 为 null,则 digest = a1Md5 + ":" + nonce + ":"+ a2Md5;如果 qop 的值为 auth,则 digest = a1Md5 + ":" + nonce + ":" + nc + ":" + cnonce + ":" +qop + ":" + a2Md5;④对 digest 进行 MD5 运算,并将运算结果返回(注意,这里的流程和上一小节所讲的客户端处理是对应的)。

(6)如果服务端基于缓存用户计算出来的摘要信息不等于请求传来的 response 字段的值,则重新从 userDetailsService 中加载用户信息,并重新完成第 5 步的运算。

(7)如果服务端计算出来的摘要信息不等于请求传来的 response 字段的值,则抛出异常。

(8)如果随机字符串 nonce 过期,则抛出异常。

(9)如果前面的步骤都顺利,没有抛出异常,则认证成功。将登录成功的用户信息存入 SecurityContext 中,同时继续执行接下来的过滤器。存入 SecurityContext 中的 Authentication 实例里边用户密码,就是从 userDetailsService 中查询出来的用户密码,在以后的过滤器中,如果还需要进行密码校验,由于 SecurityContext 中的 Authentication 实例中的用户密码和 userDetailsService 对象提供的用户密码一模一样,所以在 10.2.2 小节中配置的 PasswordEncoder 实例只能是 NoOpPasswordEncoder,否则就会校验失败。

这就是整个 HTTP 摘要认证的工作流程。

和 HTTP 基本认证相比,这里最大的亮点是不明文传输用户密码,由客户端对密码进行 MD5 运算,并将运算所需的参数以及运算结果发送到服务端,服务端再去校验数据是否正确,这样可以避免密码泄漏。

这里,大家也能发现 HTTP 摘要认证存在的问题,例如,密码最多只能进行 MD5 运算后存储,或者就只能存储明文密码,无论哪种方式,都存在一定安全隐患。同时,由于使用的复杂性,HTTP 摘要认证在实际项目中使用并不多。

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。