Postman 测试工具
上图是 Postman 测试工具的页面截图,可以看到 Authorization Type 有很多种方式 ,这篇文章将介绍 Digest 签名摘要式认证,其它几种方式将在后续文章中介绍。
- Bearer Token
- Basic Auth
- Digest Auth
- Oauth 2.0
Digest 摘要认证优点
- 密码并非直接在摘要中使用,而是 HA1 = MD5(username:realm:password)。可解决明文方式在网络上发送密码的问题。
- 服务器随机数 nonce 允许包含时间戳。因此服务器可以检查客户端提交的随机数 nonce,以防止重放攻击。
RFC 规范文档
- RFC 2617 HTTP Authentication: Basic and Digest Access Authentication
- RFC 7616 HTTP Digest Access Authentication
- RFC 2616 Hypertext Transfer Protocol -- HTTP/1.1
RFC 2069 An Extension to HTTP : Digest Access Authentication
请求认证流程
-
客户端请求受保护资源,未携带认证信息
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
-
服务器返回 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
-
客户端接收到 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
-
服务器从 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
-
Chrome 请求受保护资源 ,弹出用户名密码输入框
-
点击取消,返回 HTTP/1.1 401
和 WWW-Authenticate 响应头
-
输入用户名和密码再次请求,Header Authorization 中携带计算出的 response 值,服务器验证成功,成功请求该 URI 资源
-
重复刷新,请求头摘要中的 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);
}
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于