HTTP Digest authentication

简介

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

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

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

image 2024 04 14 19 26 52 586
Figure 1. 图10-3 HTTP摘要认证流程图

具体用法

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("e7ecfd3f08e6960f154e1ff29079fbd3").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 方法,将 passwordAlreadyEncoded 属性设置为 true,然后设置用户密码为编码后的密码即可。

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

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

源码分析

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

质询

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

public void commence(HttpServletRequest request, HttpServletResponse response,
        AuthenticationException authException) throws IOException {
    HttpServletResponse httpResponse = response;

    // compute a nonce (do not use remote IP address due to proxy farms)
    // format of nonce is:
    // base64(expirationTime + ":" + md5Hex(expirationTime + ":" + key))
    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()));

    // qop is quality of protection, as defined by RFC 2617.
    // we do not use opaque due to IE violation of RFC 2617 in not
    // representing opaque on subsequent requests in same session.
    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="MTU5OTIyNDE4NDg1NDowZGIzOWUONGM2MTA5zDVmZDkyNWYzMzRmNmYxZjg1ZA=="

客户端处理

当客户端(浏览器)收到质询请求后,弹出输入框,用户输入用户名/密码,然后客户端会对用户名/密码进行 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 运算的字符串,该字符串被黑客窃取到。黑客就可以通过该字符串冒充受害者。

请求解析

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

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
        throws IOException, ServletException {
    HttpServletRequest request = (HttpServletRequest) req;
    HttpServletResponse response = (HttpServletResponse) res;

    String header = request.getHeader("Authorization");

    if (header == null || !header.startsWith("Digest ")) {
        chain.doFilter(request, response);

        return;
    }

    DigestData digestAuth = new DigestData(header);

    try {
        digestAuth.validateAndDecode(this.authenticationEntryPoint.getKey(),
                this.authenticationEntryPoint.getRealmName());
    }
    catch (BadCredentialsException e) {
        fail(request, response, e);

        return;
    }

    // Lookup password for presented username
    // NB: DAO-provided password MUST be clear text - not encoded/salted
    // (unless this instance's passwordAlreadyEncoded property is 'false')
    boolean cacheWasUsed = true;
    UserDetails user = this.userCache.getUserFromCache(digestAuth.getUsername());
    String serverDigestMd5;

    try {
        if (user == null) {
            cacheWasUsed = false;
            user = this.userDetailsService
                    .loadUserByUsername(digestAuth.getUsername());

            if (user == null) {
                throw new AuthenticationServiceException(
                        "AuthenticationDao returned null, which is an interface contract violation");
            }

            this.userCache.putUserInCache(user);
        }

        serverDigestMd5 = digestAuth.calculateServerDigest(user.getPassword(),
                request.getMethod());

        // If digest is incorrect, try refreshing from backend and recomputing
        if (!serverDigestMd5.equals(digestAuth.getResponse()) && cacheWasUsed) {
            if (logger.isDebugEnabled()) {
                logger.debug(
                        "Digest comparison failure; trying to refresh user from DAO in case password had changed");
            }

            user = this.userDetailsService
                    .loadUserByUsername(digestAuth.getUsername());
            this.userCache.putUserInCache(user);
            serverDigestMd5 = digestAuth.calculateServerDigest(user.getPassword(),
                    request.getMethod());
        }

    }
    catch (UsernameNotFoundException notFound) {
        fail(request, response,
                new BadCredentialsException(this.messages.getMessage(
                        "DigestAuthenticationFilter.usernameNotFound",
                        new Object[] { digestAuth.getUsername() },
                        "Username {0} not found")));

        return;
    }

    // If digest is still incorrect, definitely reject authentication attempt
    if (!serverDigestMd5.equals(digestAuth.getResponse())) {
        if (logger.isDebugEnabled()) {
            logger.debug("Expected response: '" + serverDigestMd5
                    + "' but received: '" + digestAuth.getResponse()
                    + "'; is AuthenticationDao returning clear text passwords?");
        }

        fail(request, response,
                new BadCredentialsException(this.messages.getMessage(
                        "DigestAuthenticationFilter.incorrectResponse",
                        "Incorrect response")));
        return;
    }

    // To get this far, the digest must have been valid
    // Check the nonce has not expired
    // We do this last so we can direct the user agent its nonce is stale
    // but the request was otherwise appearing to be valid
    if (digestAuth.isNonceExpired()) {
        fail(request, response,
                new NonceExpiredException(this.messages.getMessage(
                        "DigestAuthenticationFilter.nonceExpired",
                        "Nonce has expired/timed out")));

        return;
    }

    if (logger.isDebugEnabled()) {
        logger.debug("Authentication success for user: '" + digestAuth.getUsername()
                + "' with response: '" + digestAuth.getResponse() + "'");
    }

    Authentication authentication = createSuccessfulAuthentication(request, user);
    SecurityContext context = SecurityContextHolder.createEmptyContext();
    context.setAuthentication(authentication);
    SecurityContextHolder.setContext(context);

    chain.doFilter(request, response);
}

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

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

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

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

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

  5. 接下来调用 calculateServerDigest 方法去计算服务端的摘要信息,该方法内部又调用了 DigestAuthUtils.generateDigest 方法。计算过程比较简单,这里主要说下计算流程:1) 首先是 a1Md5 的计算,如果设置了 passwordAlreadyEncoded,则直接将用户密码赋值给 a1Md5,否则根据 username、realm 以及 password 计算出 a1Md5;2) a2Md5 计算方式是固定的,通过 httpMethod 以及请求 uri 计算出 a2Md5; 3) 如果 qop 为 null,则 digest =a1Md5 +":" + nonce +":" + a2Md5; 如果 qop 的值为 auth,则 digest= alMd5 +":" + nonce +":" + nc +":" + cnonce +":" + qop +":"+a2Md5;4) 对 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 摘要认证在实际项目中使用并不多。