HTTP Digest Authentication 使用心得

简介

浏览器弹出这个原生的用户登录对话框,想必大家都不陌生,就是 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 为最简单版本(我 13 年有博文《Java Web 实现 HTTP 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);
	}
}

过滤器版本

修改为 Filter 版本,并进一步重构代码,变成只有 180 行不到的逻辑。源码在这里

package com.ajaxjs.web.http_auth;

import java.io.IOException;
import java.io.InputStream;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.util.StringUtils;

import com.ajaxjs.util.StrUtil;
import com.ajaxjs.util.io.StreamHelper;

public class DigestAuthentication implements Filter {

	/**
	 * 用户名,你可以改为你配置的
	 */
	private String userName = "usm";

	/**
	 * 密码,你可以改为你配置的
	 */
	private String password = "password";

	/**
	 * 
	 */
	private String authMethod = "auth";

	/**
	 * 
	 */
	private String realm = "example.com";

	public String nonce;

	@Override
	public void init(FilterConfig filterConfig) throws ServletException {
		System.out.println("HTTP DigestAuthentication……");

		// 定时器,每分钟刷新 nonce
		nonce = calculateNonce();

//		SetTimeout.timeout(() -> {
//			System.out.println("刷新 Nonce....");
			log("刷新 Nonce....");
//			nonce = calculateNonce();
//		}, 1, 1);
	}

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws ServletException {
		authenticate((HttpServletRequest) request, (HttpServletResponse) response, chain);
	}

	@Override
	public void destroy() {
	}

	protected void authenticate(HttpServletRequest req, HttpServletResponse resp, FilterChain chain) throws ServletException {
		resp.setContentType("text/html;charset=UTF-8");
		String authHeader = req.getHeader("Authorization");

		try {
			if (StringUtils.hasText(authHeader)) {
				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 = StrUtil.md5(userName + ":" + realm + ":" + password);
					String ha2;
					String qop = headerValues.get("qop");
					String reqURI = headerValues.get("uri");

					if (StringUtils.hasText(qop) && qop.equals("auth-int")) {
						String requestBody = "";
						try (InputStream in = req.getInputStream()) {
							StreamHelper.byteStream2string(in);
						}

						String entityBodyMd5 = StrUtil.md5(requestBody);
						ha2 = StrUtil.md5(method + ":" + reqURI + ":" + entityBodyMd5);
					} else
						ha2 = StrUtil.md5(method + ":" + reqURI);

					String serverResponse;

					if (StringUtils.hasText(qop)) {
//						String domain = headerValues.get("realm");
						String nonceCount = headerValues.get("nc");
						String clientNonce = headerValues.get("cnonce");

						serverResponse = StrUtil.md5(ha1 + ":" + nonce + ":" + nonceCount + ":" + clientNonce + ":" + qop + ":" + ha2);
					} else
						serverResponse = StrUtil.md5(ha1 + ":" + nonce + ":" + ha2);

					String clientResponse = headerValues.get("response");

					if (!serverResponse.equals(clientResponse)) {
						show401(resp);
						return;
					}
				} else {
					resp.sendError(HttpServletResponse.SC_UNAUTHORIZED, " This Servlet only supports Digest Authorization");
					return;
				}
			} else {
				show401(resp);
				return;
			}

			// allows to go
			chain.doFilter(req, resp);
		} catch (IOException e) {
			e.printStackTrace();
		}
	}

	private void show401(HttpServletResponse resp) throws IOException {
		resp.addHeader("WWW-Authenticate", getAuthenticateHeader());
		resp.sendError(HttpServletResponse.SC_UNAUTHORIZED);
	}

	/**
	 * 解析 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 (StringUtils.hasText(authMethod))
			header += "qop=" + authMethod + ",";

		header += "nonce=\"" + nonce + "\",";
		header += "opaque=\"" + StrUtil.md5(realm + nonce) + "\""; // 域名跟 nonce 的 md5 = Opaque

		return header;
	}

	/**
	 * 根据时间和随机数生成 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 StrUtil.md5(now + new Random(100000).nextInt());
	}
}

使用方法,传统的 web.xml

<filter>
	<filter-name>HttpDigestAuthentication</filter-name>
	<filter-class>com.ajaxjs.web.http_auth.DigestAuthentication</filter-class>
</filter>

<filter-mapping>
	<filter-name>HttpDigestAuthentication</filter-name>
	<url-pattern>/foo2/*</url-pattern>
</filter-mapping>

或者 Servlet 3.0 k基于注解的

import javax.servlet.annotation.WebFilter;

import com.ajaxjs.web.http_auth.DigestAuthentication;

/**
 * Servlet Filter implementation class TestFilter
 */
@WebFilter("/foo2/*")
public class TestFilter extends DigestAuthentication {
}

又或者基于 Spring Boot 的

@Bean
public FilterRegistrationBean<DigestAuthentication> loggingFilter(){
    FilterRegistrationBean<DigestAuthentication> registrationBean  = new FilterRegistrationBean<>();
        
    registrationBean.setFilter(new RequestResponseLoggingFilter());
    registrationBean.addUrlPatterns("/users/*");
    registrationBean.setOrder(2);
        
    return registrationBean;    
}

如果是 Spring MVC 又不想用 web.xml 呢?——抱歉,只能通过 @Componet 注入成为全局的 url,而无法针对某个 url 控制。

参考

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

sp42a

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值