前言
- 前文介绍了基本认证的概念,基本认证是将用户名和密码明文发给服务器,不够安全
- 摘要认证是基本认证的一个升级版本,其原理是
- 服务器将密钥+过期时间等参数散列生成nonce
- 客户端根据nonce+密码等值生成response
- 客户端将会传递以下参数

- 服务端会根据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);
}
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 (!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 {
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()));
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,用户名,密码值生成下面的参数

- 检查摘要数据的合法性:主要是检查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}'"));
}
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}"));
}
String nonceAsPlainText = new String(nonceBytes);
String[] nonceTokens = StringUtils.delimitedListToStringArray(nonceAsPlainText, ":");
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}"));
}
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) {
return DigestAuthUtils.generateDigest(DigestAuthenticationFilter.this.passwordAlreadyEncoded, this.username,
this.realm, password, httpMethod, this.uri, this.qop, this.nonce, this.nc, this.cnonce);
}
- 如若两次比较都成功的话,就生成认证对象放入上下文中