10.2 HTTP Digest authentication
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 摘要认证在实际项目中使用并不多。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论