[RFC 2617] Digest 签名摘要式认证

本贴最后更新于 2019 天前,其中的信息可能已经时移世改

1532979324900313.png

Postman 测试工具

httpDigest0001.jpg

上图是 Postman 测试工具的页面截图,可以看到 Authorization Type 有很多种方式 ,这篇文章将介绍 Digest 签名摘要式认证,其它几种方式将在后续文章中介绍。

  • Bearer Token
  • Basic Auth
  • Digest Auth
  • Oauth 2.0

Digest 摘要认证优点

  • 密码并非直接在摘要中使用,而是 HA1 = MD5(username:realm:password)。可解决明文方式在网络上发送密码的问题。
  • 服务器随机数 nonce 允许包含时间戳。因此服务器可以检查客户端提交的随机数 nonce,以防止重放攻击。

RFC 规范文档

  1. RFC 2617 HTTP Authentication: Basic and Digest Access Authentication
  2. RFC 7616 HTTP Digest Access Authentication
  3. RFC 2616 Hypertext Transfer Protocol -- HTTP/1.1
  4. RFC 2069 An Extension to HTTP : Digest Access Authentication

请求认证流程

  1. 客户端请求受保护资源,未携带认证信息

    POST http://127.0.0.1:8087/digest/auth HTTP/1.1
    Accept: application/json
    cache-control: no-cache
    Postman-Token: 0d4e957a-f8ab-4b01-850f-4967ff10b8a0
    User-Agent: PostmanRuntime/7.6.0
    Connection: keep-alive
    
  2. 服务器返回 401 状态和 WWW-Authenticate 响应头

    HTTP/1.1 401
    WWW-Authenticate: Digest realm="digest#Realm", qop="auth", nonce="MTU1NTMzMDg2MDA4MDo5MTdiMGI4ZmIwMDc2ZTgzOWU5NzA4YzEyZWEwNzlmMg=="
    Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    Pragma: no-cache
    Content-Type: application/json;charset=UTF-8
    
  3. 客户端接收到 401 响应表示需要进行认证,根据"算法"(后面介绍)生成一个消息摘要,将摘要放到 Authorization 的请求头中重新发送命令给服务器。

    POST http://127.0.0.1:8087/digest/auth HTTP/1.1
    Accept: application/json
    User-Agent: PostmanRuntime/7.6.0
    Authorization: Digest username="123", realm="digest#Realm", nonce="MTU1NTMzMDg2MDA4MDo5MTdiMGI4ZmIwMDc2ZTgzOWU5NzA4YzEyZWEwNzlmMg==", uri="/digest/auth", algorithm="MD5", qop=auth, nc=00000001, cnonce="eYnywapi", response="0568a40f79a6960114e21f6ef2b60807"
    Connection: keep-alive
    
  4. 服务器从 Header 中取出 digest 摘要信息,根据其中信息重新计算新的摘要,然后跟客户端传输的摘要进行比较(就是比较 response 的值是否相等);因为服务器拥有与客户端同样的信息,因此服务器可以进行同样的计算,以验证客户端提交的 response 值的正确性。

    HTTP/1.1 200
    Expires: 0
    Content-Type: application/json;charset=UTF-8
    Content-Length: 22
    
    ** digest auth is success **
    

WWW-Authenticate 响应头字段

  • realm: 显示给客户端的字符串 example is registered_users@example.com

  • nonce: 服务端生成唯一的、不重复的随机值

    //RFC2617示例 nonce = BASE64(time-stamp MD5(time-stamp ":" ETag ":" private-key)) 
    //下面是spring-security的实现
    long expiryTime = System.currentTimeMillis() + (long)(this.nonceValiditySeconds * 1000);
    String signatureValue = DigestAuthUtils.md5Hex(expiryTime + ":" + this.key);
    String nonceValue = expiryTime + ":" + signatureValue;
    String nonceValueBase64 = new String(Base64.getEncoder().encode(nonceValue.getBytes()));
    
  • algorithm: 默认MD5算法

  • opaque:

  • qop: auth/auth-int 会影响摘要的算法

  • stale:
    密码随机数nonce过期

Authorization 请求头字段

  • response: 客户端根据算法算出的摘要值
  • username: 要认证的用户名
  • realm: 认证域,可取任意标识值
  • uri: 请求的资源位置
  • qop: 保护质量
  • nonce: 服务器密码随机数
  • nc: 16进制请求认证计数器,第一次 00000001
  • algorithm: 默认MD5算法
  • cnonce: 客户端密码随机数

Request-Digest 摘要计算过程

若算法是:MD5 或者是未指定
则 A1= <username>:<realm>:<passwd>

若 qop 未定义或者 auth:
则 A2= <request-method>:<digest-uri-value>

若 qop 为 auth
      response=MD5(MD5(A1):<nonce>:<nc>:<cnonce>:<qop>:MD5(A2)) 
若 qop 没有定义
      response=MD5(MD5(A1):<nonce>:MD5(A2)) 

客户端实现 Chrome

  1. Chrome 请求受保护资源 ,弹出用户名密码输入框
    httpDigest0002.jpg

  2. 点击取消,返回 HTTP/1.1 401
    和 WWW-Authenticate 响应头
    httpDigest0003.jpg

  3. 输入用户名和密码再次请求,Header Authorization 中携带计算出的 response 值,服务器验证成功,成功请求该 URI 资源
    httpDigest0004.jpg

  4. 重复刷新,请求头摘要中的 nc 计数器会递增,当 nonce 过期时,会返回 stale=false

    Authorization: 
    	Digest username="123", 
    	realm="digest#Realm", 	
    	nonce="MTU1NTUxMTA2MTc2MTo4MGFjOWUyNTk0Mzc1NDNmMjA0Y2RiZTQ1MDM4Yjc5Ng==", 
    	uri="/digest/auth", 
    	response="aaba4f7bfa8177ce3f94d04afe1053db", 
    	qop=auth, 
    	nc=00000005, 
    	cnonce="22d2bd7af81592f7"
    

服务端 Spring-Security 实现分析

DigestAuthenticationEntryPoint

  • 设置 401 和 WWW-Authenticate 响应头
public class DigestAuthenticationEntryPoint implements AuthenticationEntryPoint, InitializingBean, Ordered {

    private static final Log logger = LogFactory.getLog(DigestAuthenticationEntryPoint.class);
    private String key;
    private String realmName;
    private int nonceValiditySeconds = 300;
    private int order = 2147483647;

    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        long expiryTime = System.currentTimeMillis() + (long)(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\"";
        }
        if (logger.isDebugEnabled()) {
            logger.debug("WWW-Authenticate header sent to user agent: " + authenticateHeader);
        }
        response.addHeader("WWW-Authenticate", authenticateHeader);
        response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
    }

}

DigestAuthenticationFilter

  • 过滤器拦截
//摘录部分代码
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Digest ")) {
  chain.doFilter(request, response);
  return;
}
//header中获取摘要信息
//this.username = headerMap.get("username");
//this.realm = headerMap.get("realm");
//this.nonce = headerMap.get("nonce");
//this.uri = headerMap.get("uri");
//this.response = headerMap.get("response");
//this.qop = headerMap.get("qop"); // RFC 2617 extension
//this.nc = headerMap.get("nc"); // RFC 2617 extension
//this.cnonce = headerMap.get("cnonce"); // RFC 2617 extensionDigestData digestAuth = new DigestData(header);
try {
digestAuth.validateAndDecode(this.authenticationEntryPoint.getKey(),this.authenticationEntryPoint.getRealmName());
}catch (BadCredentialsException e) {
  fail(request, response, e);
  return;
}
//注意:DAO-提供的密码必须是明文-没有编码/加盐
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);
  }
  //根据算法 计算response值
  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;
}
//比较客户端传输和服务器计算的response
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;
}
//检查 nonce 是否过期
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() + "'");
}

DigestAuthUtils

  • 摘要计算工具类
static String generateDigest(boolean passwordAlreadyEncoded, String username,

		String realm, String password, String httpMethod, String uri, String qop,
		String nonce, String nc, String cnonce) throws IllegalArgumentException {
	String a1Md5;
	String a2 = httpMethod + ":" + uri;
	String a2Md5 = md5Hex(a2);
	if (passwordAlreadyEncoded) {
		a1Md5 = password;
	}else {
		a1Md5 = DigestAuthUtils.encodePasswordInA1Format(username, realm, password);
	}
	String digest;
	if (qop == null) {
		// as per RFC 2069 compliant clients (also reaffirmed by RFC 2617)
		digest = a1Md5 + ":" + nonce + ":" + a2Md5;
	}else if ("auth".equals(qop)) {
		// As per RFC 2617 compliant clients
		digest = a1Md5 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":"
				+ a2Md5;
	}else {
		throw new IllegalArgumentException("This method does not support a qop: '"
				+ qop + "'");
	}
	return md5Hex(digest);
}

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...
  • shentu

    您好 ,请问 cnonce 这个参数怎么弄得?