HTTP/1.1 使用的認證方式有
1)BASIC 認證(基本認證);
2)DIGEST 認證(摘要認證);
3)SSL 客戶端認證;
4)FormBase 認證(基於表單認證);
1、DIGEST 認證的步驟
2、Digest 認證涉及到的參數的含義
3、校驗 response 的算法
4、Java + SpringBoot實現 DIGEST 認證
5、測試及結果分析
6、注意事項
1、DIGEST 認證的步驟 <-- 返回目錄
為彌補 BASIC 認證存在的弱點,從 HTTP/1.1 起就有了 DIGEST 認證。 DIGEST 認證同樣使用質詢 / 響應的方式(challenge/response),但不會像 BASIC 認證那樣直接發送明文密碼。
所謂質詢響應方式是指,一開始一方會先發送認證要求給另一方,接着使用從另一方那接收到的質詢碼計算生成響應碼。最后將響應碼返回給對方進行認證的方式。
DIGEST 認證的步驟:
步驟 1: 請求需認證的資源時,服務器會隨着狀態碼 401Authorization Required,返回帶WWW-Authenticate 首部字段的響應。該字段內包含質問響應方式認證所需的臨時質詢碼(隨機數,nonce)。首部字段 WWW-Authenticate 內必須包含realm 和nonce 這兩個字段的信息。客戶端就是依靠向服務器回送這兩個值進行認證的。nonce 是一種每次隨返回的 401 響應生成的任意隨機字符串。該字符串通常推薦由Base64 編碼的十六進制數的組成形式,但實際內容依賴服務器的具體實現。
步驟 2:接收到401狀態碼的客戶端,返回的響應中包含 DIGEST 認證必須的首部字段 Authorization 信息。首部字段 Authorization 內必須包含 username、realm、nonce、uri 和response的字段信息。其中,realm 和 nonce 就是之前從服務器接收到的響應中的字段。
username是realm 限定范圍內可進行認證的用戶名。uri(digest-uri)即Request-URI的值,但考慮到經代理轉發后Request-URI的值可能被修改因此事先會復制一份副本保存在 uri內。
response 也可叫做 Request-Digest,存放經過 MD5 運算后的密碼字符串,形成響應碼。
步驟 3:接收到包含首部字段 Authorization 請求的服務器,會確認認證信息的正確性。認證通過后則返回包含 Request-URI 資源的響應。並且這時會在首部字段 Authentication-Info 寫入一些認證成功的相關信息。(不過我下面的例子沒有去寫這個Authentication-Info,而是直接返回的數據。因為我實在session里緩存的認證結果)。
2、Digest 認證涉及到的參數的含義 <-- 返回目錄
WWW-Authentication:用來定義使用何種方式(Basic、Digest、Bearer等)去進行認證以獲取受保護的資源 realm:表示Web服務器中受保護文檔的安全域(比如公司財務信息域和公司員工信息域),用來指示需要哪個域的用戶名和密碼 qop:保護質量,包含auth(默認的)和auth-int(增加了報文完整性檢測)兩種策略,(可以為空,但是)不推薦為空值 nonce:服務端向客戶端發送質詢時附帶的一個隨機數,這個數會經常發生變化。客戶端計算密碼摘要時將其附加上去,使得多次生成同一用戶的密碼摘要各不相同,用來防止重放攻擊 nc:nonce計數器,是一個16進制的數值,表示同一nonce下客戶端發送出請求的數量。例如,在響應的第一個請求中,客戶端將發送“nc=00000001”。這個指示值的目的是讓服務器保持這個計數器的一個副本,以便檢測重復的請求 cnonce:客戶端隨機數,這是一個不透明的字符串值,由客戶端提供,並且客戶端和服務器都會使用,以避免用明文文本。這使得雙方都可以查驗對方的身份,並對消息的完整性提供一些保護 response:這是由用戶代理軟件計算出的一個字符串,以證明用戶知道口令 Authorization-Info:用於返回一些與授權會話相關的附加信息 nextnonce:下一個服務端隨機數,使客戶端可以預先發送正確的摘要 rspauth:響應摘要,用於客戶端對服務端進行認證 stale:當密碼摘要使用的隨機數過期時,服務器可以返回一個附帶有新隨機數的401響應,並指定stale=true,表示服務器在告知客戶端用新的隨機數來重試,而不再要求用戶重新輸入用戶名和密碼了
3、校驗 response 的算法 <-- 返回目錄
瀏覽器 Authorization 的內容舉例:
Digest username="q", realm="test", nonce="T53sV+xXH3FrrER4YZwpFQ==", uri="/portal/applications",
response="f80492644b0700b404f2fb3f4d62861e", qop=auth, nc=00000001, cnonce="25c980f9f95fd544"
其中 response 是根據如下算法計算得到:
response = MD5(MD5(username:realm:password):nonce:nc:cnonce:qop:MD5(<request-method>:url))
所以,服務器的校驗邏輯就包括校驗 Authorization 請求頭里面的 response 的值。可以看到,response 算法里面有個 password 參數,瀏覽器通過彈框用戶輸入密碼的值得到,服務器是通過用戶名從數據庫查到的。摘要驗證主要就是通過上面的HASH比較的步驟避免掉了基本驗證中的安全性問題。
4、Java + SpringBoot實現 DIGEST 認證 <-- 返回目錄
application.properties

server.port=8089 server.servlet.context-path=/BootDemo
自定義注解 RequireAuth

package com.oy.interceptor; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; // can be used to method @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface RequireAuth { }
攔截器 RequireAuthInterceptor

package com.oy.interceptor; import java.text.MessageFormat; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; import com.oy.model.DigestAuthInfo; import com.oy.util.DigestUtils; public class RequireAuthInterceptor extends HandlerInterceptorAdapter { // 為了 測試Digest nc 值每次請求增加 private int nc = 0; @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) throws Exception { // 請求目標為 method of controller,需要進行驗證 if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod = (HandlerMethod) handler; Object object = handlerMethod.getMethodAnnotation(RequireAuth.class); /* 方法沒有 @RequireAuth 注解, 放行 */ if (object == null) { return true; // 放行 } /* 方法有 @RequireAuth 注解,需要攔截校驗 */ // 沒有 Authorization 請求頭,或者 Authorization 認證信息驗證不通過,攔截 if (!isAuth(req, res)) { // 驗證不通過,攔截 return false; } // 驗證通過,放行 return true; } // 請求目標不是 mehod of controller, 放行 return true; } private boolean isAuth(HttpServletRequest req, HttpServletResponse res) { String authStr = req.getHeader("Authorization"); System.out.println("請求 Authorization 的內容:" + authStr); if (authStr == null || authStr.length() <= 7) { // 沒有 Authorization 請求頭,開啟質詢 return challenge(res); } DigestAuthInfo authObject = DigestUtils.getAuthInfoObject(authStr); // System.out.println(authObject); /* * 生成 response 的算法: * response = MD5(MD5(username:realm:password):nonce:nc:cnonce:qop:MD5(<request-method>:url)) */ // 這里密碼固定為 123456, 實際應用需要根據用戶名查詢數據庫或緩存獲得 String HA1 = DigestUtils.MD5(authObject.getUsername() + ":" + authObject.getRealm() + ":123456"); String HD = String.format(authObject.getNonce() + ":" + authObject.getNc() + ":" + authObject.getCnonce() + ":" + authObject.getQop()); String HA2 = DigestUtils.MD5(req.getMethod() + ":" + authObject.getUri()); String responseValid = DigestUtils.MD5(HA1 + ":" + HD + ":" + HA2); // 如果 Authorization 中的 response(瀏覽器生成的) 與期望的 response(服務器計算的) 相同,則驗證通過 System.out.println("Authorization 中的 response: " + authObject.getResponse()); System.out.println("期望的 response: " + responseValid); if (responseValid.equals(authObject.getResponse())) { /* 判斷 nc 的值,用來防重放攻擊 */ // 判斷此次請求的 Authorization 請求頭里面的 nc 值是否大於之前保存的 nc 值 // 大於,替換舊值,然后 return true // 否則,return false // 測試代碼 start int newNc = Integer.parseInt(authObject.getNc(), 16); System.out.println("old nc: " + this.nc + ", new nc: " + newNc); if (newNc > this.nc) { this.nc = newNc; return true; } return false; // 測試代碼 end } // 驗證不通過,重復質詢 return challenge(res); } /** * 質詢:返回狀態碼 401 和 WWW-Authenticate 響應頭 * * @param res 返回false,則表示攔截器攔截請求 */ private boolean challenge(HttpServletResponse res) { // 質詢前,重置或刪除保存的與該用戶關聯的 nc 值(nc:nonce計數器,是一個16進制的數值,表示同一nonce下客戶端發送出請求的數量) // 將 nc 置為初始值 0, 這里代碼省略 // 測試代碼 start this.nc = 0; // 測試代碼 end res.setStatus(401); String str = MessageFormat.format("Digest realm={0},nonce={1},qop={2}", "\"no auth\"", "\"" + DigestUtils.generateToken() + "\"", "\"auth\""); res.addHeader("WWW-Authenticate", str); return false; } }
注冊攔截器 WebConfig

package com.oy; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import com.oy.interceptor.RequireAuthInterceptor; @Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { RequireAuthInterceptor requireAuthInterceptor = new RequireAuthInterceptor(); registry.addInterceptor(requireAuthInterceptor); } }
DIGEST認證信息model類 DigestAuthInfo

package com.oy.model; public class DigestAuthInfo { private String username; private String realm; private String nonce; private String uri; private String response; private String qop; private String nc; public String cnonce; public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getRealm() { return realm; } public void setRealm(String realm) { this.realm = realm; } public String getNonce() { return nonce; } public void setNonce(String nonce) { this.nonce = nonce; } public String getUri() { return uri; } public void setUri(String uri) { this.uri = uri; } public String getResponse() { return response; } public void setResponse(String response) { this.response = response; } public String getQop() { return qop; } public void setQop(String qop) { this.qop = qop; } public String getNc() { return nc; } public void setNc(String nc) { this.nc = nc; } public String getCnonce() { return cnonce; } public void setCnonce(String cnonce) { this.cnonce = cnonce; } @Override public String toString() { return "DigestAuthInfo [username=" + username + ", realm=" + realm + ", nonce=" + nonce + ", uri=" + uri + ", response=" + response + ", qop=" + qop + ", nc=" + nc + ", cnonce=" + cnonce + "]"; } }
DIGEST認證的工具類 DigestUtils

package com.oy.util; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Base64; import java.util.Random; import org.junit.Test; import com.oy.model.DigestAuthInfo; public class DigestUtils { /** * 根據當前時間戳生成一個隨機字符串 * @return */ public static String generateToken() { String s = String.valueOf(System.currentTimeMillis() + new Random().nextInt()); try { MessageDigest messageDigest = MessageDigest.getInstance("md5"); byte[] digest = messageDigest.digest(s.getBytes()); return Base64.getEncoder().encodeToString(digest); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(); } } @Test public void testGenerateToken() { // heL2WICEml8/UGfAQsS9mQ== System.out.println(generateToken()); } public static String MD5(String inStr) { MessageDigest md5 = null; try { md5 = MessageDigest.getInstance("MD5"); } catch (Exception e) { System.out.println(e.toString()); e.printStackTrace(); return ""; } char[] charArray = inStr.toCharArray(); byte[] byteArray = new byte[charArray.length]; for (int i = 0; i < charArray.length; i++) { byteArray[i] = (byte) charArray[i]; } byte[] md5Bytes = md5.digest(byteArray); StringBuffer hexValue = new StringBuffer(); for (int i = 0; i < md5Bytes.length; i++) { int val = ((int) md5Bytes[i]) & 0xff; if (val < 16) hexValue.append("0"); hexValue.append(Integer.toHexString(val)); } return hexValue.toString(); } /** * 該方法用於將 Authorization 請求頭的內容封裝成一個對象。 * * Authorization 請求頭的內容為: * Digest username="aaa", realm="no auth", nonce="b2b74be03ff44e1884ba0645bb961b53", * uri="/BootDemo/login", response="90aff948e6f2207d69ecedc5d39f6192", qop=auth, * nc=00000002, cnonce="eb73c2c68543faaa" */ public static DigestAuthInfo getAuthInfoObject(String authStr) { if (authStr == null || authStr.length() <= 7) return null; if (authStr.toLowerCase().indexOf("digest") >= 0) { // 截掉前綴 Digest authStr = authStr.substring(6); } // 將雙引號去掉 authStr = authStr.replaceAll("\"", ""); DigestAuthInfo digestAuthObject = new DigestAuthInfo(); String[] authArray = new String[8]; authArray = authStr.split(","); // System.out.println(java.util.Arrays.toString(authArray)); for (int i = 0, len = authArray.length; i < len; i++) { String auth = authArray[i]; String key = auth.substring(0, auth.indexOf("=")).trim(); String value = auth.substring(auth.indexOf("=") + 1).trim(); switch (key) { case "username": digestAuthObject.setUsername(value); break; case "realm": digestAuthObject.setRealm(value); break; case "nonce": digestAuthObject.setNonce(value); break; case "uri": digestAuthObject.setUri(value); break; case "response": digestAuthObject.setResponse(value); break; case "qop": digestAuthObject.setQop(value); break; case "nc": digestAuthObject.setNc(value); break; case "cnonce": digestAuthObject.setCnonce(value); break; } } return digestAuthObject; } }
測試接口類 IndexController

package com.oy.controller; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseBody; import com.oy.interceptor.RequireAuth; @Controller public class IndexController { @RequireAuth @RequestMapping("/login") @ResponseBody public String login(HttpServletRequest req, HttpServletResponse res) { return "{code: 0, data: {username:\"test\"}}"; } @RequireAuth @RequestMapping("/index") @ResponseBody public String index(HttpServletRequest req, HttpServletResponse res) { return "{code: 0, data: {xxx:\"xxx\"}}"; } }
5、測試及結果分析 <-- 返回目錄
測試流程:
1)打開瀏覽器,輸入http://localhost:8089/BootDemo/login,回車后彈出對話框;
2)第一次輸入錯誤密碼(比如123),確定后會再次彈出對話框;
3)第二次輸入正確密碼(123456),回車,返回狀態碼 200 和數據;
4)然后刷新兩次頁面;
后台控制台打印結果及解釋:
// 打開瀏覽器,輸入http://localhost:8089/BootDemo/login // 此時,瀏覽器發起的請求中沒有帶 Authorization 請求頭 請求 Authorization 的內容:null // 第一次質詢,測試時故意密碼輸入錯誤 // nonce 由服務器生成的,瀏覽器在 Authorization 請求頭中帶回 // 服務器質詢,會響應 401 和 請求頭 WWW-Authenticate // WWW-Authenticate: Digest realm="no auth",nonce="rULh6M3A6O2N8jjzxr6vJg==",qop="auth" 請求 Authorization 的內容:Digest username="aaa", realm="no auth", nonce="rULh6M3A6O2N8jjzxr6vJg==", uri="/BootDemo/login",
response="488d501c80ff7ac9a02bdf0b78125b6e", qop=auth, nc=00000001, cnonce="2ba7cc66c38cb785" Authorization 中的 response: 488d501c80ff7ac9a02bdf0b78125b6e 期望的 response: 8c53f6738244a07d1b36a37aa0c9566a // 第二次質詢,輸入正確的密碼 // 重新質詢,服務器重新生成一個 nonce, 瀏覽器在下次請求的 Authorization 請求頭中帶回 請求 Authorization 的內容:Digest username="aaa", realm="no auth", nonce="VeW1RtC+B6eQeehbgPFjEA==", uri="/BootDemo/login",
response="4f3d8b1db57f1f216397ab68dcbccf38", qop=auth, nc=00000001, cnonce="4e7ac593c141cc68" Authorization 中的 response: 4f3d8b1db57f1f216397ab68dcbccf38 期望的 response: 4f3d8b1db57f1f216397ab68dcbccf38 old nc: 0, new nc: 1 // 再次請求,不會再質詢,瀏覽器之間發送 Authorization 請求頭 // 此次請求,nonce 的值為上次質詢成功保存的值,nc 為 00000002 請求 Authorization 的內容:Digest username="aaa", realm="no auth", nonce="VeW1RtC+B6eQeehbgPFjEA==", uri="/BootDemo/login",
response="daf87926d28ac8629c572444b218fc35", qop=auth, nc=00000002, cnonce="41d0d6316bec00e1" Authorization 中的 response: daf87926d28ac8629c572444b218fc35 期望的 response: daf87926d28ac8629c572444b218fc35 old nc: 1, new nc: 2 // 再次請求,不會再質詢,瀏覽器之間發送 Authorization 請求頭 // 此次請求,nonce 的值為上次質詢成功保存的值,nc 為 00000003 請求 Authorization 的內容:Digest username="aaa", realm="no auth", nonce="VeW1RtC+B6eQeehbgPFjEA==", uri="/BootDemo/login",
response="7342c2fe0ab2366d89940f3f1d204de7", qop=auth, nc=00000003, cnonce="2250ac0265250e7e" Authorization 中的 response: 7342c2fe0ab2366d89940f3f1d204de7 期望的 response: 7342c2fe0ab2366d89940f3f1d204de7 old nc: 2, new nc: 3
6、注意事項 <-- 返回目錄
1)nonce 由后台生成傳給瀏覽器的,瀏覽器會在 Authorization 請求頭中帶回;
2)Authorization 請求頭中nc的含義:nonce計數器,是一個16進制的數值,表示同一nonce下客戶端發送出請求的數量,用來防重復攻擊;
3)生成response的算法:response = MD5(MD5(username:realm:password):nonce:nc:cnonce:qop:MD5(<request-method>:url))
- 瀏覽器不發送 password 的值,而是發送 response,可以防止密碼在傳輸過程中被竊取;
參考:
1)《圖解HTTP》
---