HTTP Digest authentication
简介
HTTP 基本认证虽然简单易用,但是在安全方面问题突出,于是文推出了 HTTP 摘要认证。HTTP 摘要认证最早在 RFC2069 中被定义,随后被 RFC2617( https://tools.ietf.org/html/rfc2617 )所取代,在 RC267 中引入了一系列增强安全性的参数,以防正各种可能存在的网络攻击。
相比于 HTTP 基本认证,HTTP 要认证的安全性有很大提高,旧是依然存在问题,例如不支持 bCrypt、PBKDF2、SCrypt 等加密方式。
图10-3所示描述了 HTTP 摘要认证的具体流程,从图中可以看出,这个认证流程和 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();
}
}
-
首先由开发者提供一个 DigestAuthenticationEntryPoint 实例(相当于 HTTP 基本认证中的 BasicAuthenticationEntryPoint ),当用户发起一个没有认证的请求时,由该实例进行处理。配置该实例时,我们需要提供一个随机数的有效期,RealmName 以及一个 Key。
-
创建一个 DigestAuthenticationFilter 实例,并添加到 Spring Security 过滤器链中,DigestAuthenticationFilter 的作用类似于 HTTP 基本认证中 BasicAuthenticationFilter 过滤器的作用。
-
配置一个 UserDetailsService 实例。
-
配置一个 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 参数,则运算过程如下:
-
对 username+":"realm +":" password 计算其消息摘要得到 digest1。
-
对 HttpMethod+":"+uri 计算其消息摘要得到 digest2。
-
对 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 方法比较冗长,我们逐步进行分析一下:
-
首先从请求头中获取 Authorization 字段,如果该字段不存在,或者该字段的值不是以 Digest 开头,则直接执行剩下的过滤器。在执行剩下的过滤器时,最终会进入到质询环节。
-
根据获取到的 Authorization 字段信息,构造出一个 DigestData 对象。这个过程就是将请求头中的 username、realm、nonce、uri、response、qop、nc、cnonce 等字段解析出来,设置给 DigestData 对象中对应的属性,方便后续处理。
-
接下来调用 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 项进行比较,如果不相等,则抛出异常。至此,就完成了对请求参数的初步校验。
-
根据请求传来的用户名去加载用户对象,先去缓存中加裁,缓存中没有,则调用 userDetailsService 实例去加载(这也是为什么我们在配置 DigestAuthenticationFilter 过滤器时,需要指定 userDetailsService 实例的原因)。
-
接下来调用 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 运算,并将运算结果返回(注意,这里的流程和上一小节所讲的客户端处理是对应的)。
-
如果服务端基于缓存用户计算出来的摘要信息不等于请求传来的 response 字段的值,则重新从 userDetailsService 中加载用户信息,并重新完成第 5 步的运算。
-
如果服务端计算出来的摘要信息不等于请求传来的 response 字段的值,则抛出异常。
-
如果随机字符串 nonce 过期,则抛出异常。
-
如果前面的步骤都顺利,没有抛出异常,则认证成功。将登录成功的用户信息存入 SecurityContext 中,同时继续执行接下来的过滤器。存入 SecurityContext 中的 Authentication 实例里边用户密码,就是从 userDetailsService 中查询出来的用户密码,在以后的过滤器中,如果还需要进行密码校验,由于 SecurityContext 中的 Authentication 实例中的用户密码和 userDetailsService 对象提供的用户密码一模一样,所以在 10.2.2 小节中配置的 PasswordEncoder 实例只能是 NoOpPasswordEncoder,否则就会校验失败。
这就是整个 HTTP 摘要认证的工作流程。
和 HTTP 基本认证相比,这单最大的亮点是不明文传输用户密码,由客户端对密码进行 MD5 运算,并将运算所需的参数以及运算结果发送到服务端,服务端再去校验数据是否正确,这样可以避免密码泄漏。
这里,大家也能发现 HTTP 摘要认证存在的问题,例如,密码最多只能进行 MD5 运算后存储,或者就只能存储明文密码,无论哪种方式,都存在一定安全隐患。同时,由于使用的复杂性,HTTP 摘要认证在实际项目中使用并不多。