[SpringSecurity5.6.2源码分析十九]:DigestAuthenticationFilter

30 阅读5分钟

前言

  • 前文介绍了基本认证的概念,基本认证是将用户名和密码明文发给服务器,不够安全
  • 摘要认证是基本认证的一个升级版本,其原理是
    • 服务器将密钥+过期时间等参数散列生成nonce
    • 客户端根据nonce+密码等值生成response
    • 客户端将会传递以下参数 image.png
    • 服务端会根据nonce解析出过期时间,然后再加上密钥重新生成nonce,再比较两个地方的nonce值是否相同
    • 服务端再通过nonce + 密码等值重新生成response,再比较两个地方的response值是否相同
    • 两个参数的值都相同即为认证成功
  • 为了弥补基本认证的不足,摘要认证做了以下改进:
    • 以密文(不可逆)形式发- - - 但是浏览器计算摘要是要先通过服务器获取一个随机数在进行计算,也就说攻击者拿到当前的摘要发给服务器,服务器判断是匿名用户无权限,然后就发给攻击者一个随机数要求攻击者按照随机数等等参数再次生成摘要,但是攻击者不知道密码也就和服务器生成的摘要不一样了送密码
    • 防止重放攻击:攻击者可以通过截获摘要,一遍遍的重放给服务器
      • 但是浏览器计算摘要是要先通过服务器获取一个随机数在进行计算,也就说攻击者拿到当前的摘要发给服务器,服务器判断是匿名用户无权限,然后就发给攻击者一个随机数要求攻击者按照随机数等等参数再次生成摘要,但是攻击者不知道密码也就和服务器生成的摘要不一样了

1. 如何使用

  • 摘要认证不像基本认知或者表单认证一样有对应的配置类可以直接使用,需要自己注册过滤器
  • 使用如下:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    ...

    @Override
    protected void configure(HttpSecurity http) throws Exception {
      ...
      DigestAuthenticationFilter filter = new DigestAuthenticationFilter();
      DigestAuthenticationEntryPoint entryPoint = digestAuthenticationEntryPoint();
      filter.setAuthenticationEntryPoint(entryPoint);
      filter.setUserDetailsService(userDetailsService());

      http.addFilter(filter);
      http.exceptionHandling().authenticationEntryPoint(entryPoint);
   }

   //@Bean
   DigestAuthenticationEntryPoint digestAuthenticationEntryPoint() {
      DigestAuthenticationEntryPoint entryPoint = new DigestAuthenticationEntryPoint();
      entryPoint.setRealmName("realmName");
      entryPoint.setKey("key");
      return entryPoint;
   }

}
  • 这里不仅仅是要注册DigestAuthenticationFilter,还要将摘要认证独有的身份认证入口点注册到ExceptionTranslationFilter中

2. DigestAuthenticationFilter

  • 直接看核心方法:
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
      throws IOException, ServletException {
   String header = request.getHeader("Authorization");
   if (header == null || !header.startsWith("Digest ")) {
      chain.doFilter(request, response);
      return;
   }
   logger.debug(LogMessage.format("Digest Authorization header received from user agent: %s", header));
   DigestData digestAuth = new DigestData(header);
   try {
      //检查摘要数据的合法性
      digestAuth.validateAndDecode(this.authenticationEntryPoint.getKey(),
            this.authenticationEntryPoint.getRealmName());
   }
   catch (BadCredentialsException ex) {
      fail(request, response, ex);
      return;
   }

   //是否使用缓存中的用户
   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) {
         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 ex) {
      String message = this.messages.getMessage("DigestAuthenticationFilter.usernameNotFound",
            new Object[] { digestAuth.getUsername() }, "Username {0} not found");
      fail(request, response, new BadCredentialsException(message));
      return;
   }

   // 如果摘要值仍然不正确,则执行失败策略
   if (!serverDigestMd5.equals(digestAuth.getResponse())) {
      logger.debug(LogMessage.format(
            "Expected response: '%s' but received: '%s'; is AuthenticationDao returning clear text passwords?",
            serverDigestMd5, digestAuth.getResponse()));
      String message = this.messages.getMessage("DigestAuthenticationFilter.incorrectResponse",
            "Incorrect response");
      //失败策略
      fail(request, response, new BadCredentialsException(message));
      return;
   }
   // 走这一步,摘要已经是有效的
   // 但是可能会存在过期的情况
   if (digestAuth.isNonceExpired()) {
      String message = this.messages.getMessage("DigestAuthenticationFilter.nonceExpired",
            "Nonce has expired/timed out");
      fail(request, response, new NonceExpiredException(message));
      return;
   }
   logger.debug(LogMessage.format("Authentication success for user: '%s' with response: '%s'",
         digestAuth.getUsername(), digestAuth.getResponse()));


   //认证成功,创建认证对象
   Authentication authentication = createSuccessfulAuthentication(request, user);

   //创建线程级别的安全上下文
   SecurityContext context = SecurityContextHolder.createEmptyContext();
   context.setAuthentication(authentication);
   SecurityContextHolder.setContext(context);

   chain.doFilter(request, response);
}
  • 我们再讲下整个认证步骤:
    • 当认证异常或访问权限不足的时候会交由ExceptionTranslationFilter处理异常
    • 然后就会调用摘要认证独有的身份认证入口点:DigestAuthenticationEntryPoint
    • 然后这个里面就会生成nonce值返回给客户端
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
          AuthenticationException authException) throws IOException {
    
       //通过过期时间和密钥生成nonce
       long expiryTime = System.currentTimeMillis() + (this.nonceValiditySeconds * 1000);
       String signatureValue = DigestAuthUtils.md5Hex(expiryTime + ":" + this.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="" + this.realmName + "", " + "qop="auth", nonce=""
             + nonceValueBase64 + """;
       if (authException instanceof NonceExpiredException) {
          authenticateHeader = authenticateHeader + ", stale="true"";
       }
       logger.debug(LogMessage.format("WWW-Authenticate header sent to user agent: %s", authenticateHeader));
       response.addHeader("WWW-Authenticate", authenticateHeader);
       response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
    }
    
    • 然后客户端就会根据这个nonce,用户名,密码值生成下面的参数 image.png
    • 检查摘要数据的合法性:主要是检查nonce值是否一样
    void validateAndDecode(String entryPointKey, String expectedRealm) throws BadCredentialsException {
       //检查所有必需参数都已经有值
       if ((this.username == null) || (this.realm == null) || (this.nonce == null) || (this.uri == null)
             || (this.response == null)) {
          throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage(
                "DigestAuthenticationFilter.missingMandatory", new Object[] { this.section212response },
                "Missing mandatory digest value; received header {0}"));
       }
       //继续检查参数
       if ("auth".equals(this.qop)) {
          if ((this.nc == null) || (this.cnonce == null)) {
             logger.debug(LogMessage.format("extracted nc: '%s'; cnonce: '%s'", this.nc, this.cnonce));
             throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage(
                   "DigestAuthenticationFilter.missingAuth", new Object[] { this.section212response },
                   "Missing mandatory digest value; received header {0}"));
          }
       }
       //检查保护域是否是我们的
       if (!expectedRealm.equals(this.realm)) {
          throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage(
                "DigestAuthenticationFilter.incorrectRealm", new Object[] { this.realm, expectedRealm },
                "Response realm name '{0}' does not match system realm name of '{1}'"));
       }
       //检查摘要数据是否能够通过Base64解密
       final byte[] nonceBytes;
       try {
          nonceBytes = Base64.getDecoder().decode(this.nonce.getBytes());
       }
       catch (IllegalArgumentException ex) {
          throw new BadCredentialsException(
                DigestAuthenticationFilter.this.messages.getMessage("DigestAuthenticationFilter.nonceEncoding",
                      new Object[] { this.nonce }, "Nonce is not encoded in Base64; received nonce {0}"));
       }
       // 解密nonce,nonce格式为: base64(expirationTime + ":" + md5Hex(expirationTime + ":" + key))
       String nonceAsPlainText = new String(nonceBytes);
       String[] nonceTokens = StringUtils.delimitedListToStringArray(nonceAsPlainText, ":");
    
       //nonce中只会包含两个东西
       if (nonceTokens.length != 2) {
          throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage(
                "DigestAuthenticationFilter.nonceNotTwoTokens", new Object[] { nonceAsPlainText },
                "Nonce should have yielded two tokens but was {0}"));
       }
    
       //从nonce中提取过期时间
       try {
          this.nonceExpiryTime = new Long(nonceTokens[0]);
       }
       catch (NumberFormatException nfe) {
          throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage(
                "DigestAuthenticationFilter.nonceNotNumeric", new Object[] { nonceAsPlainText },
                "Nonce token should have yielded a numeric first token, but was {0}"));
       }
    
       //用过期时间+密钥重新加密
       //判断是否是自己创建的
       String expectedNonceSignature = DigestAuthUtils.md5Hex(this.nonceExpiryTime + ":" + entryPointKey);
       if (!expectedNonceSignature.equals(nonceTokens[1])) {
          throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage(
                "DigestAuthenticationFilter.nonceCompromised", new Object[] { nonceAsPlainText },
                "Nonce token compromised {0}"));
       }
    }
    
    • 再就是重新生成response值,与客户端发过来的进行比较
    String calculateServerDigest(String password, String httpMethod) {
       // Compute the expected response-digest (will be in hex form). Don't catch
       // IllegalArgumentException (already checked validity)
       return DigestAuthUtils.generateDigest(DigestAuthenticationFilter.this.passwordAlreadyEncoded, this.username,
             this.realm, password, httpMethod, this.uri, this.qop, this.nonce, this.nc, this.cnonce);
    }
    
    • 如若两次比较都成功的话,就生成认证对象放入上下文中