Digest摘要认证 - 密文
背景: 仅仅只是用来替代Basic认证,由于Basic认证使用明文账户、密码传输这得确保客户端、服务端传输之间不会被任何人截取报文,这就要求很高,我们都知道网络传输没有绝对的安全,为了不使得明文传输,RFC提出另一种认证方式即Digest认证,认证时仅传账户、密码的摘要,这就避免有人即是劫持报文获得认证信息也不会解析得到用户的账户、密码
讲解(Digest摘要认证)
参考文章: https://www.cnblogs.com/huey/p/5490759.html
官方规范(新-兼容老规范-必须看): https://datatracker.ietf.org/doc/html/rfc7616
官方规范(老规范-必须看): https://datatracker.ietf.org/doc/html/rfc2617
请求头字段规范: https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Digest
好文1(强烈建议看-可以不用看好文2的实现看我的实现也可以): https://www.cnblogs.com/xiaoxiaotank/p/11078571.html
好文2(强烈建议看): https://www.cnblogs.com/xiaoxiaotank/p/11079024.html
//响应头:WWW-Authenticate 或者 请求头:Authorization
Digest username="用户名", realm="角色描述", nonce="服务端随机数(Base64或十六进制编码)", uri="请求uri部分", response="摘要(客户端计算、到时服务端也会计算一份看是否一致)", algorithm="摘要算法", cnonce="客户端随机数(Base64或十六进制编码)", opaque="服务端给的信息原封不动换回去即可", nc="使用次数", qop="auth|auth-init"
//响应头WWW-Authenticate 必须提供
opaque、algorithm
//响应头WWW-Authenticate 提供
qop,则请求头Authorization将这个参数原封不动的还回去
//摘要公式 secret=密钥key data=需要摘要的数据 两个参数都是根据规则由WWW-Authenticate里面的参数进行构建
//由于摘要算法无需密钥即可生成,故充分利用将secret也作为被摘要的部分数据
KD(secret, data)=摘要算法(concat(secret, ":", data))
secret(也称A1)=摘要算法(摘要算法(<username>:<realm>:<password>):<nonce>:<cnonce>)
secret(也称A1-sess形式)=摘要算法(<username>:<realm>:<password>)
data=<nonce>:<nc>:<cnonce>:<qop>:摘要算法(A2)
A2=<request-method>:<uri>
原英文 | 参数(请求头携带的参数名) | 作用 |
---|---|---|
(响应头-认证失败)WWW-Authentication | 用来定义使用何种方式(Basic、Digest、Bearer等)去进行认证以获取受保护的资源、以及各种其他参数添加 | |
digest-uri | uri | 当前请求的uri |
username | 即将认证的用户名 | |
realm | 表示Web服务器中受保护文档的安全域(比如公司财务信息域和公司员工信息域),用来指示需要哪个域的用户名和密码 | |
message-qop | qop | 保护质量,包含 auth(默认的)和 auth-int(增加了报文完整性检测)以及 Token值 三种策略,(可以为空,但是)不推荐为空值 |
nonce | nonce | 服务端向客户端发送质询时附带的一个随机数,这个数会经常发生变化。客户端计算密码摘要时将其附加上去,使得多次生成同一用户的密码摘要各不相同,用来防止重放攻击 = 官方建议每次请求都不同 |
userhash | userhash | (选填)是否支持用户名使用hash,默认false |
opaque | (选填)服务端给的数据一般是Base64或十六进制编码,客户端请求认证时原封不动的返回给服务端 | |
(请求头-开始认证)Authorization | 认证类型以及用于携带客户端认证信息 | |
nonce-count | nc | nonce计数器,是一个16进制的数值,表示同一nonce下客户端发送出请求的数量。例如,在响应的第一个请求中,客户端将发送“nc=00000001”。这个指示值的目的是让服务器保持这个计数器的一个副本,以便检测重复的请求 |
cnonce | 客户端随机数,这是一个不透明的字符串值,由客户端提供,并且客户端和服务器都会使用,以避免用明文文本。这使得双方都可以查验对方的身份,并对消息的完整性提供一些保护 | |
request-digest | response | 这是由用户代理软件计算出的一个字符串,以证明用户知道口令 == 认证凭证(服务端校验)== 公式:MD5(MD5(A1):::::MD5(A2)) |
(响应头-认证成功) Authorization-Info | 用于返回一些与授权会话相关的附加信息 | |
nextnonce | 下一个服务端随机数,使客户端可以预先发送正确的摘要 | |
rspauth | 响应摘要,用于客户端对服务端进行认证 == 认证凭证(客户端校验) == 公式:MD5(MD5(A1):::::MD5(A2)) | |
stale | 当密码摘要使用的随机数过期时,服务器可以返回一个附带有新随机数的401响应,并指定stale=true,表示服务器在告知客户端用新的随机数来重试,而不再要求用户重新输入用户名和密码了 | |
algorithm | 摘要算法,由服务端(响应头)进行指定,默认MD5,支持MD5、MD5-sess、SHA-256、SHA-256-sess、SHA-512-256、SHA-512-256-sess、token值 |
//认证凭证生成算法=即参数response、rspauth
MD5(MD5(A1):<nonce>:<nc>:<cnonce>:<qop>:MD5(A2))
客户端生成用户凭证的参数内容
算法 | A1 |
---|---|
MD5(默认) | MD5(::) |
MD5-sess | MD5(::):: |
qop | A2 |
---|---|
auth(默认) | : |
auth-int | ::MD5() |
服务端生成用户凭证的参数内容 == 无请求方式-参数变化即是少了请求方式而已
qop | A2 |
---|---|
auth(默认) | : |
auth-int | ::MD5() |
实现(Digest认证)
代码(Digest认证)
代码(Digest认证-客户端)
需要额外引入这两个依赖
<!-- 主要用于该工具类线程写好的 sha-512/256摘要算法 ->
<!-- https://mvnrepository.com/artifact/org.bouncycastle/bcprov-jdk15on -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
<version>1.70</version>
</dependency>
<!-- https://mvnrepository.com/artifact/cn.hutool/hutool-all -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.5</version>
</dependency>
DigestAuthInterceptor.java
package work.linruchang.qq.mybaitsplusjoin.config.interceptor;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.lang.Dict;
import cn.hutool.core.lang.UUID;
import cn.hutool.core.text.CharPool;
import cn.hutool.core.util.StrUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import work.linruchang.qq.mybaitsplusjoin.config.interceptor.algorithm.digest.DigestAlgorithmUtil;
import work.linruchang.qq.mybaitsplusjoin.config.interceptor.algorithm.digest.WWWAuthenticate;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* 作用:Basic认证
*
* @author LinRuChang
* @version 1.0
* @date 2022/08/04
* @since 1.8
**/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class DigestAuthInterceptor implements HandlerInterceptor {
@AllArgsConstructor
@Getter
enum AuthErrorEnum {
INVALID_REQUEST(400, "invalid_request", "参数异常"),
INVALID_DIGEST(401, "invalid_digest", "摘要不一致");
Integer errorCode;
String error;
String errorDescription;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
boolean authStatusFlag = false;
String authorization = request.getHeader("Authorization");
AuthErrorEnum authErrorEnum = AuthErrorEnum.INVALID_DIGEST;
if (StrUtil.isNotBlank(authorization)) {
String[] splitInfos = authorization.split(StrUtil.COMMA + StrUtil.SPACE);
splitInfos[0] = StrUtil.removePrefix(splitInfos[0], "Digest ");
//解析认证参数
Map<String, String> splitInfosMap = Stream.of(splitInfos)
.map(StrUtil::trim)
.map(splitInfo -> StrUtil.removeAll(splitInfo, CharPool.DOUBLE_QUOTES))
.collect(Collectors.toMap(splitInfo -> {
return splitInfo.split("=")[0];
}, splitInfo -> {
return splitInfo.split("=")[1];
}));
WWWAuthenticate wwwAuthenticate = BeanUtil.toBean(splitInfosMap, WWWAuthenticate.class);
Dict userInfo = getUserInfo(wwwAuthenticate.getUsername());
if (userInfo != null) {
wwwAuthenticate.setPassword(userInfo.getStr("password"));
wwwAuthenticate.setRequestMethod(request.getMethod());
String serverResponse = DigestAlgorithmUtil.result(wwwAuthenticate);
if (StrUtil.equals(serverResponse, wwwAuthenticate.getResponse())) { //摘要一致
authStatusFlag = true;
}
} else {
authErrorEnum = AuthErrorEnum.INVALID_REQUEST;
}
}
//认证失败
if (!authStatusFlag) {
response.setHeader("WWW-Authenticate", StrUtil.format("Digest realm=\"rights of administrators\", nonce=\"{}\", qop=\"auth\"", UUID.randomUUID().toString(true)));
response.setHeader("content-type", "text/plain;charset=utf-8");
response.setStatus(authErrorEnum.getErrorCode());
response.getWriter().write("认证失败");
return false;
}
return authStatusFlag;
}
public Dict getUserInfo(String userName) {
if (StrUtil.equals(userName, "admin")) {
return Dict.create()
.set("userName", "admin")
.set("password", "admin123");
}
return null;
}
}
DigestAuthInterceptor.java
@Configuration
public class MyConfig implements WebMvcConfigurer {
@Autowired
DigestAuthInterceptor digestAuthInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(digestAuthInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/js/**","/user*/**","/user*/**");
}
}
DigestAlgorithm.java
public interface DigestAlgorithm {
/**
* 计算摘要结果
* 摘要算法(摘要算法(A1):<nonce>:<nc>:<cnonce>:<qop>:摘要算法(A2))
* A1(sess形式): 摘要算法(<username>:<realm>:<password>):<nonce>:<cnonce>
* A1(非sess形式): <username>:<realm>:<password>
*
* A2:<request-method>:<uri>
* @param wwwAuthenticate
* @return
*/
String result(WWWAuthenticate wwwAuthenticate);
}
MD5DigestAlgorithm.java
**/
public class MD5DigestAlgorithm implements DigestAlgorithm{
@Override
public String result(WWWAuthenticate wwwAuthenticate) {
String A1Digest = null;
String A2Digest = null;
//MD5-sess
if(StrUtil.containsIgnoreCase(wwwAuthenticate.getAlgorithm(),"sess")) {
A1Digest = SecureUtil.md5(StrUtil.format("{}:{}:{}", SecureUtil.md5(StrUtil.format("{}:{}:{}", wwwAuthenticate.getUsername(), wwwAuthenticate.getRealm(), wwwAuthenticate.getPassword())), wwwAuthenticate.getNonce(), wwwAuthenticate.getCnonce()));
A2Digest = SecureUtil.md5(StrUtil.format("{}:{}", wwwAuthenticate.getRequestMethod(), wwwAuthenticate.getUri()));
}else { //MD5
A1Digest = SecureUtil.md5(StrUtil.format("{}:{}:{}", wwwAuthenticate.getUsername(), wwwAuthenticate.getRealm(), wwwAuthenticate.getPassword()));
A2Digest = SecureUtil.md5(StrUtil.format("{}:{}", wwwAuthenticate.getRequestMethod(), wwwAuthenticate.getUri()));
}
String serverResponse = SecureUtil.md5(StrUtil.format("{}:{}:{}:{}:{}:{}", A1Digest, wwwAuthenticate.getNonce(), wwwAuthenticate.getNc(), wwwAuthenticate.getCnonce(), wwwAuthenticate.getQop(), A2Digest));
return serverResponse;
}
}
SHA256DigestAlgorithm.java
public class SHA256DigestAlgorithm implements DigestAlgorithm{
@Override
public String result(WWWAuthenticate wwwAuthenticate) {
String A1Digest = null;
String A2Digest = null;
//SHA256-sess
if(StrUtil.containsIgnoreCase(wwwAuthenticate.getAlgorithm(),"sess")) {
A1Digest = SecureUtil.sha256(StrUtil.format("{}:{}:{}", SecureUtil.md5(StrUtil.format("{}:{}:{}", wwwAuthenticate.getUsername(), wwwAuthenticate.getRealm(), wwwAuthenticate.getPassword())), wwwAuthenticate.getNonce(), wwwAuthenticate.getCnonce()));
A2Digest = SecureUtil.sha256(StrUtil.format("{}:{}", wwwAuthenticate.getRequestMethod(), wwwAuthenticate.getUri()));
}else { //SHA256
A1Digest = SecureUtil.sha256(StrUtil.format("{}:{}:{}", wwwAuthenticate.getUsername(), wwwAuthenticate.getRealm(), wwwAuthenticate.getPassword()));
A2Digest = SecureUtil.sha256(StrUtil.format("{}:{}", wwwAuthenticate.getRequestMethod(), wwwAuthenticate.getUri()));
}
String serverResponse = SecureUtil.sha256(StrUtil.format("{}:{}:{}:{}:{}:{}", A1Digest, wwwAuthenticate.getNonce(), wwwAuthenticate.getNc(), wwwAuthenticate.getCnonce(), wwwAuthenticate.getQop(), A2Digest));
return serverResponse;
}
}
SHA512256DigestAlgorithm.java
public class SHA512256DigestAlgorithm implements DigestAlgorithm{
@Override
public String result(WWWAuthenticate wwwAuthenticate) {
String A1Digest = null;
String A2Digest = null;
Digester digester = DigestUtil.digester("sha-512/256");
//SHA-512/256-sess
if(StrUtil.containsIgnoreCase(wwwAuthenticate.getAlgorithm(),"sess")) {
A1Digest = digester.digestHex(StrUtil.format("{}:{}:{}", digester.digestHex(StrUtil.format("{}:{}:{}", wwwAuthenticate.getUsername(), wwwAuthenticate.getRealm(), wwwAuthenticate.getPassword())), wwwAuthenticate.getNonce(), wwwAuthenticate.getCnonce()));
A2Digest = digester.digestHex(StrUtil.format("{}:{}", wwwAuthenticate.getRequestMethod(), wwwAuthenticate.getUri()));
}else { //SHA-512/256
A1Digest = digester.digestHex(StrUtil.format("{}:{}:{}", wwwAuthenticate.getUsername(), wwwAuthenticate.getRealm(), wwwAuthenticate.getPassword()));
A2Digest = digester.digestHex(StrUtil.format("{}:{}", wwwAuthenticate.getRequestMethod(), wwwAuthenticate.getUri()));
}
String serverResponse =digester.digestHex(StrUtil.format("{}:{}:{}:{}:{}:{}", A1Digest, wwwAuthenticate.getNonce(), wwwAuthenticate.getNc(), wwwAuthenticate.getCnonce(), wwwAuthenticate.getQop(), A2Digest));
return serverResponse;
}
}
DigestAlgorithmUtil.java
public class DigestAlgorithmUtil {
private final static ConcurrentHashMap<String, DigestAlgorithm> digestAlgorithmMap = new ConcurrentHashMap<>();
/**
* 计算摘要结果
*
* @param wwwAuthenticate
* @return
*/
@SneakyThrows
public static String result(WWWAuthenticate wwwAuthenticate) {
String packageName = ClassUtil.getPackage(DigestAlgorithmUtil.class);
String algorithm = StrUtil.removeAll(StrUtil.split(wwwAuthenticate.getAlgorithm(), "-sess").get(0), StrUtil.DASHED);
DigestAlgorithm digestAlgorithm = Optional.ofNullable(digestAlgorithmMap.get(algorithm))
.orElseGet(() -> {
DigestAlgorithm currentDigestAlgorithm = null;
try {
Class<?> digestAlgorithmClass = Class.forName(StrUtil.format("{}.{}DigestAlgorithm", packageName, algorithm));
currentDigestAlgorithm = (DigestAlgorithm) ReflectUtil.newInstance(digestAlgorithmClass);
digestAlgorithmMap.put(algorithm, currentDigestAlgorithm);
} catch (ClassNotFoundException e) {
}
return currentDigestAlgorithm;
});
Assert.notNull(digestAlgorithm, "【{}】摘要算法找不到,请检查", algorithm);
return digestAlgorithm.result(wwwAuthenticate);
}
@SneakyThrows
public static void main(String[] args) {
Class<?> md5DigestAlgorithm = Class.forName("MD5DigestAlgorithm");
Console.log(md5DigestAlgorithm);
}
}
WWWAuthenticate.java
@Data
public class WWWAuthenticate {
String qop;
String nc;
String realm;
String cnonce;
String uri;
String nonce;
String algorithm;
String username;
String response;
/**
* 用于计算摘要
*/
String password;
String requestMethod;
}
ArticleCategoryController.java
@RestController
@RequestMapping("article-category")
public class ArticleCategoryController {
@GetMapping("/one/{id}")
public CommonHttpResult<ArticleCategory> findById(@PathVariable("id") String id) {
return CommonHttpResult.success(articleCategoryService.getById(id));
}
}
演示(Digest认证-postman)
理由: 为什么使用postman,因为只要输入账号、密码,发现第一个请求认证失败,返回的nonce等信息,postman自动帮你计算好摘要,然后重新发送,如果再次认证认证失败,则不会在帮你重新发送,所以postman最多帮你发送两遍。
发送错误的用户名或密码
发送正确的用户名或密码