简介
浏览器弹出这个原生的对话框,想必大家都不陌生,就是 HTTP Baisc 认证的机制。
这是浏览器自带的,遵循 RFC2617/7617 协议。但必须指出的是,遇到这界面,不一定是 Basic Authentication,也可能是 Digest Authentication。关于浏览器自带的认证,简单说有以下版本:
- Basic: RFC 2617 (1999) -> RFC 7617 (2015)
- Digest: RFC 2069 (1997) -> RFC 2617 (1999) -> RFC 7617 (2015)
- OAuth 1.0 (Twitter, 2007)
- OAuth 2.0 (2012)/Bearer (OAuth 2.0): RFC 6750 (2012)
- JSON Web Tokens (JWT): RFC 7519 (2015)
可參照 MDN - HTTP authentication 了解更多。
Basic 为最简单版本,密码就用 Base64 编码一下,安全性低等于裸奔,好处是够简单;今天说的 Digest,不直接使用密码,而是密码的 MD5。虽说不是百分百安全(也不存在百分百)但安全性立马高级很多。
原生实现
试验一个新技术,我最喜欢简单直接无太多封装的原生代码,——就让我们通过经典 Servlet 的例子看看如何实现 Digest Authentication;另外最后针对我自己的框架,提供另外一个封装的版本,仅依赖 Spring 和我自己的一个库。
开门见山,先贴完整代码。
package com;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.codec.digest.DigestUtils;
/**
* Servlet implementation class TestController
*/
@WebServlet("/foo")
public class TestController extends HttpServlet {
/**
* 用户名,你可以改为你配置的
*/
private String userName = "usm";
/**
* 密码,你可以改为你配置的
*/
private String password = "password";
/**
*
*/
private String authMethod = "auth";
/**
*
*/
private String realm = "example.com";
public String nonce;
private static final long serialVersionUID = 1L;
/**
* 定时器,每分钟刷新 nonce
*/
public TestController() {
nonce = calculateNonce();
Executors.newScheduledThreadPool(1).scheduleAtFixedRate(() -> {
// log("刷新 Nonce....");
nonce = calculateNonce();
}, 1, 1, TimeUnit.MINUTES);
}
protected void authenticate(HttpServletRequest req, HttpServletResponse resp) {
resp.setContentType("text/html;charset=UTF-8");
String requestBody = readRequestBody(req);
String authHeader = req.getHeader("Authorization");
try (PrintWriter out = resp.getWriter();) {
if (isBlank(authHeader)) {
resp.addHeader("WWW-Authenticate", getAuthenticateHeader());
resp.sendError(HttpServletResponse.SC_UNAUTHORIZED);
} else {
if (authHeader.startsWith("Digest")) {
// parse the values of the Authentication header into a hashmap
Map<String, String> headerValues = parseHeader(authHeader);
String method = req.getMethod();
String ha1 = md5Hex(userName + ":" + realm + ":" + password);
String ha2;
String qop = headerValues.get("qop");
String reqURI = headerValues.get("uri");
if (!isBlank(qop) && qop.equals("auth-int")) {
String entityBodyMd5 = md5Hex(requestBody);
ha2 = md5Hex(method + ":" + reqURI + ":" + entityBodyMd5);
} else
ha2 = md5Hex(method + ":" + reqURI);
String serverResponse;
if (isBlank(qop))
serverResponse = md5Hex(ha1 + ":" + nonce + ":" + ha2);
else {
// String domain = headerValues.get("realm");
String nonceCount = headerValues.get("nc");
String clientNonce = headerValues.get("cnonce");
serverResponse = md5Hex(ha1 + ":" + nonce + ":" + nonceCount + ":" + clientNonce + ":" + qop + ":" + ha2);
}
String clientResponse = headerValues.get("response");
if (!serverResponse.equals(clientResponse)) {
resp.addHeader("WWW-Authenticate", getAuthenticateHeader());
resp.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
} else
resp.sendError(HttpServletResponse.SC_UNAUTHORIZED, " This Servlet only supports Digest Authorization");
}
out.println("<head>");
out.println("<title>Servlet HttpDigestAuth</title>");
out.println("</head>");
out.println("<body>");
out.println("<h1>已通过 HttpDigestAuth 认证 at" + req.getContextPath() + "</h1>");
out.println("</body>");
out.println("</html>");
} catch (IOException e) {
e.printStackTrace();
}
}
private static String md5Hex(String string) {
return DigestUtils.md5Hex(string);
// try {
// MessageDigest md = MessageDigest.getInstance("MD5");
// md.update(password.getBytes());
// byte[] digest = md.digest();
//
// return DatatypeConverter.printHexBinary(digest).toUpperCase();
// } catch (NoSuchAlgorithmException e) {
// e.printStackTrace();
// }
// return null;
}
/**
* Handles the HTTP
* <code>GET</code> method.
*
* @param request servlet request
* @param response servlet response
* @throws ServletException if a servlet-specific error occurs
* @throws IOException if an I/O error occurs
*/
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
authenticate(request, response);
}
/**
* Handles the HTTP
* <code>POST</code> method.
*
* @param request servlet request
* @param response servlet response
* @throws ServletException if a servlet-specific error occurs
* @throws IOException if an I/O error occurs
*/
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
authenticate(request, response);
}
/**
* Returns a short description of the servlet.
*
* @return a String containing servlet description
*/
@Override
public String getServletInfo() {
return "This Servlet Implements The HTTP Digest Auth as per RFC2617";
}
/**
* 解析 Authorization 头,将其转换为一个 Map
* Gets the Authorization header string minus the "AuthType" and returns a
* hashMap of keys and values
*
* @param header
* @return
*/
private static Map<String, String> parseHeader(String header) {
// seperte out the part of the string which tells you which Auth scheme is it
String headerWithoutScheme = header.substring(header.indexOf(" ") + 1).trim();
String keyValue[] = headerWithoutScheme.split(",");
Map<String, String> values = new HashMap<>();
for (String keyval : keyValue) {
if (keyval.contains("=")) {
String key = keyval.substring(0, keyval.indexOf("="));
String value = keyval.substring(keyval.indexOf("=") + 1);
values.put(key.trim(), value.replaceAll("\"", "").trim());
}
}
return values;
}
/**
* 生成认证的 HTTP 头
*
* @return
*/
private String getAuthenticateHeader() {
String header = "";
header += "Digest realm=\"" + realm + "\",";
if (!isBlank(authMethod))
header += "qop=" + authMethod + ",";
header += "nonce=\"" + nonce + "\",";
header += "opaque=\"" + getOpaque(realm, nonce) + "\"";
return header;
}
private boolean isBlank(String str) {
return str == null || "".equals(str);
}
/**
* 根据时间和随机数生成 nonce
*
* Calculate the nonce based on current time-stamp upto the second, and a random seed
*
* @return
*/
public String calculateNonce() {
Date d = new Date();
String fmtDate = new SimpleDateFormat("yyyy:MM:dd:hh:mm:ss").format(d);
Integer randomInt = new Random(100000).nextInt();
return md5Hex(fmtDate + randomInt.toString());
}
/**
* 域名跟 nonce 的 md5 = Opaque
*
* @param domain
* @param nonce
* @return
*/
private static String getOpaque(String domain, String nonce) {
return md5Hex(domain + nonce);
}
/**
* 返回请求体
*
* Returns the request body as String
*
* @param request
* @return
*/
private String readRequestBody(HttpServletRequest request) {
StringBuilder sb = new StringBuilder();
try (InputStream inputStream = request.getInputStream();) {
if (inputStream != null) {
try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));) {
char[] charBuffer = new char[128];
int bytesRead = -1;
while ((bytesRead = bufferedReader.read(charBuffer)) > 0) {
sb.append(charBuffer, 0, bytesRead);
}
}
} else
sb.append("");
} catch (IOException e) {
e.printStackTrace();
}
return sb.toString();
}
}
注意 MD5 部分依赖了这个:
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.14</version>
</dependency>
这是源自老外的代码,是一个标准 Servlet,但我觉得是 Filter 更合理,而且没有定义如何鉴权通过后的操作(当前只是显示一段文本),有时间的话我再改改。
封装一下
结合自己的库封装一下。
package com;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.util.DigestUtils;
import com.ajaxjs.util.SetTimeout;
import com.ajaxjs.util.io.StreamHelper;
/**
* Servlet implementation class TestController
*/
@WebServlet("/bar")
public class TestController2 extends HttpServlet {
/**
* 用户名,你可以改为你配置的
*/
private String userName = "usm";
/**
* 密码,你可以改为你配置的
*/
private String password = "password";
/**
*
*/
private String authMethod = "auth";
/**
*
*/
private String realm = "example.com";
public String nonce;
private static final long serialVersionUID = 1L;
/**
* 定时器,每分钟刷新 nonce
*/
public TestController2() {
nonce = calculateNonce();
SetTimeout.timeout(() -> {
// log("刷新 Nonce....");
nonce = calculateNonce();
}, 1, 1);
}
protected void authenticate(HttpServletRequest req, HttpServletResponse resp) {
resp.setContentType("text/html;charset=UTF-8");
String authHeader = req.getHeader("Authorization");
try (PrintWriter out = resp.getWriter();) {
if (isBlank(authHeader)) {
resp.addHeader("WWW-Authenticate", getAuthenticateHeader());
resp.sendError(HttpServletResponse.SC_UNAUTHORIZED);
} else {
if (authHeader.startsWith("Digest")) {
// parse the values of the Authentication header into a hashmap
Map<String, String> headerValues = parseHeader(authHeader);
String method = req.getMethod();
String ha1 = md5Hex(userName + ":" + realm + ":" + password);
String ha2;
String qop = headerValues.get("qop");
String reqURI = headerValues.get("uri");
if (!isBlank(qop) && qop.equals("auth-int")) {
String requestBody = "";
try (InputStream in = req.getInputStream()) {
StreamHelper.byteStream2string(in);
}
String entityBodyMd5 = md5Hex(requestBody);
ha2 = md5Hex(method + ":" + reqURI + ":" + entityBodyMd5);
} else
ha2 = md5Hex(method + ":" + reqURI);
String serverResponse;
if (isBlank(qop))
serverResponse = md5Hex(ha1 + ":" + nonce + ":" + ha2);
else {
// String domain = headerValues.get("realm");
String nonceCount = headerValues.get("nc");
String clientNonce = headerValues.get("cnonce");
serverResponse = md5Hex(ha1 + ":" + nonce + ":" + nonceCount + ":" + clientNonce + ":" + qop + ":" + ha2);
}
String clientResponse = headerValues.get("response");
if (!serverResponse.equals(clientResponse)) {
resp.addHeader("WWW-Authenticate", getAuthenticateHeader());
resp.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
} else
resp.sendError(HttpServletResponse.SC_UNAUTHORIZED, " This Servlet only supports Digest Authorization");
}
out.println("<head>");
out.println("<title>Servlet HttpDigestAuth</title>");
out.println("</head>");
out.println("<body>");
out.println("<h1>已通过 HttpDigestAuth 认证 at" + req.getContextPath() + "</h1>");
out.println("</body>");
out.println("</html>");
} catch (IOException e) {
e.printStackTrace();
}
}
private static String md5Hex(String str) {
return DigestUtils.md5DigestAsHex(str.getBytes());
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
authenticate(request, response);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
authenticate(request, response);
}
/**
* 解析 Authorization 头,将其转换为一个 Map
* Gets the Authorization header string minus the "AuthType" and returns a
* hashMap of keys and values
*
* @param header
* @return
*/
private static Map<String, String> parseHeader(String header) {
// seperte out the part of the string which tells you which Auth scheme is it
String headerWithoutScheme = header.substring(header.indexOf(" ") + 1).trim();
String keyValue[] = headerWithoutScheme.split(",");
Map<String, String> values = new HashMap<>();
for (String keyval : keyValue) {
if (keyval.contains("=")) {
String key = keyval.substring(0, keyval.indexOf("="));
String value = keyval.substring(keyval.indexOf("=") + 1);
values.put(key.trim(), value.replaceAll("\"", "").trim());
}
}
return values;
}
/**
* 生成认证的 HTTP 头
*
* @return
*/
private String getAuthenticateHeader() {
String header = "";
header += "Digest realm=\"" + realm + "\",";
if (!isBlank(authMethod))
header += "qop=" + authMethod + ",";
header += "nonce=\"" + nonce + "\",";
header += "opaque=\"" + getOpaque(realm, nonce) + "\"";
return header;
}
private boolean isBlank(String str) {
return str == null || "".equals(str);
}
/**
* 根据时间和随机数生成 nonce
*
* Calculate the nonce based on current time-stamp upto the second, and a random seed
*
* @return
*/
public static String calculateNonce() {
String now = new SimpleDateFormat("yyyy:MM:dd:hh:mm:ss").format(new Date());
return md5Hex(now + new Random(100000).nextInt());
}
/**
* 域名跟 nonce 的 md5 = Opaque
*
* @param domain
* @param nonce
* @return
*/
private static String getOpaque(String domain, String nonce) {
return md5Hex(domain + nonce);
}
}
参考
- 《Web应用中基于密码的身份认证机制(表单认证、HTTP认证: Basic、Digest、Mutual)》好详细的原理分析,但没啥代码
- 一个实现
- Java猿社区—Http digest authentication 请求代码最全示例 代码有点复杂
- 開發者必備知識 - HTTP認證(HTTP Authentication)科普文章,简单明了
- https://www.pudn.com/news/628f82f3bf399b7f351e5a86.html