?傳送門
數據安全系列1:開篇
數據安全系列2:單向散列函數概念
數據安全系列3:密碼技術概述
什么是認證?
一談到認證,多數人的反應可能就是"用戶認證" 。就是應用系統如何識別用戶的身份,直接一點就是常說的"登錄"功能,這可以說是一個系統中最基本的功能了:
認證(Authentication)、授權(Authorization)和憑證(Credentials)這三項可以說是一個系統中最基礎的安全設計了,哪怕是再簡陋的信息系統,大概也不可能忽略掉“用戶登錄”這個功能。
--------------------引自系統如何正確分辨操作用戶的真實身份
而"登錄"又是所有安全功能中的重中之重:沒有經過用戶認證的過程,所有的安全設計都這空中樓閣,這就意義著登錄其實不是一件簡單的事情:不僅僅是校驗一下用戶名、密碼是否正確這么簡單,而是一系列圍繞認證展開的復雜問題:
- 賬戶和權限信息作為一種必須最大限度保障安全和隱私
- 同時又要兼顧各個系統模塊、甚至是系統間共享訪問的基礎主數據
所以登錄場景下的用戶,除了一般意義上的真實的人,也可能不是一個真正的人:只要擁有用戶名、密碼并經過了系統的安全認證,就可以被系統所接受了。比如有些黑客程序,或者所謂的"攻擊機器人",其實并不是真正的用戶在操作。但是這里討論的場景中,用戶指的一般開發口中的各種應用系統,以及為了安全性而設計的應用身份識別!
應用身份認證
應用身份認證的場景,其實在開發中還是很常見的(可能對于非開發人員來說,倒不常見,因為一般用戶操作的是時候以自己為主體的,所以不存在什么應用身份認證)。
API接口對于程序員來說(尤其是后端開發)幾乎是每個人都接觸過的,不論是開發API接口還是調用API接口都并不陌生。API接口一般是由應用系統開發出來供別的系統來調用,只要符合接口的規范或約定,一般都能調用成功。這里成功要說明一下:
- 不考慮網絡環境,默認是通的
- 也不保證業務執行成功與否,只考慮是否滿足參數、URL、請求方式等
調用API接口如果只滿足基本要求就能調用,在安全性上其實是不夠的。就好比一個系統如果沒有"登錄"這種基本的認證,任何人都能訪問那不是一個道理嗎?
在一般的內網環境里面,因為有防火墻的存在,其實對于應用之間的API接口調用的認證要求,倒并不是很嚴格。但是以下的一些情況卻是不能忽視:
- 涉及外網業務,提供了對外的API接口調用
- 涉及敏感操作,比如轉賬匯款、刪除資源的高危操作
- 涉及集中管理,比如一些開放網關、公共應用平臺系統
- 其它一些暫時沒有想到的......
有上面這些場景,系統就不能再"裸奔"了!對于具體怎么設計應用身份認證并沒有統一的標準和既定的規范,放之四海皆準。不過還是有一些借鑒模式:
- 使用Oauth2協議的密碼模式
- 使用消息認證碼模式
具體使用Oauth2的密碼模式還是消息論證碼模式并沒有明確的規定,主要看應用場景。如果是上面提到的開放網關、平臺類系統,出于安全性及管理的需要,使用Oauht2的密碼模式比較合適。如果是開發小型系統,也不用對接什么平臺類的系統,要自主開發一套應用身份認證功能,可以采用消認證碼模式,接下來可以具體討論一下如何實現及對比之間的差異!
Oauth2密碼模式
對于Oauth2協議前面討論的足夠多了,其中又專門介紹了Oauth2系列4:密碼模式,所以不再贅述。
這里再簡單畫一個示意圖來說明應用場景:
- A系統開發API接口,并到平臺系統注冊
- B系統調用API接口 ,也到平臺系統注冊
- 平臺系統負責管理注冊的應用(包括對應的接口等資源),并負責在系統間接口調用時進行身份論證
那應用身份認證這個場景跟密碼模式具體有什么關系呢,或者說為什么可以采用密碼模式來做API接口調用的控制?這里覺得有必要做一個探討與解釋。我們知道Oauth協議其實是一個授權協議(可參考Oauth2系列1:初識Oauth2):
看一下網站應用微信登錄開發指南
從上面的時序圖可以看出標準場景Oauth2的流程有真實用戶參與,所以為了應對沒有沒有真實用戶參與的情況,比如應用身份認證(一般都是應用間接口調用,比如服務間通過HTTP接口調用),Oauth2制定了密碼模式來應對:將應用模擬為"用戶",并也向應用頒發"賬號-clientID"、"密碼-clientSecret",應用通過賬號、密碼直接獲取token來完成身份認證!上面流程就變成了下面這樣:
消息認證碼
如果說Oauth2的密碼模式適用于平臺類系統,提供了一種通用、與業務無關的身份認證方式,那么消息認證碼就是另外一種相對更底層與業務參數有關的認證方式。關于消息認證碼的概念,可以參考數據安全系列3:密碼技術概述,那么為什么消息論證碼可以達到身份論證的目的呢?再回顧一下消息論證碼的過程:
- 在這樣的交互過程中,交互的雙方需要共享密鑰,也即是前面的對稱密鑰
- 要計算MAC值,必須持有共享密鑰,沒有就無法計算MAC值,消息認證碼正是利用此特性來完成所謂的認證的。
除此以外,還需要說明的是這個過程里面還依賴于單向散列函數的不可逆性!
密鑰管理
從Oauh2協議可以看出,可以單獨做一個注冊服務,負責client_id、client_secret的管理,對網關這種這種平臺系統是必要的。如果是對接系統很少甚至就一個,只要雙方約定好"密鑰"就行:比如服務提供方生成一個16位"隨機數",并頒發給調用方作為"密鑰",這樣會更簡單:
UUID.randomUUID().toString()
至于密鑰的具體生成、傳輸、存儲、管理也是一個很大話題,一般可能會涉及到KMS之類系統,這里就不展開了。
接下來模擬一個接口,看下通過消息認證碼如何實現身份認證!假設有一個用戶注冊接口:
@PostMapping("register")public void register(@RequestParam("userName") String userName, @RequestParam("email") String email) {}
接受2個參數userName、email:規定只能擁有"密鑰"的系統才能調用。
實現-版本1-基本功能
能最直接想到的辦法是,檢驗參數內容是否符合要求:
- 調用方:將userName、email拼接起來生成消息認證碼,并傳遞給服務方
- 服務方:接收userName、email,拼接起來生成消息認證碼,并與調用方傳遞的認證碼比較
- 如果一致,表示認證成功,不一致則不允許調用
通過這個分析,接口就要多加一個參數接收消息認證碼,比如叫signature或digest:
@PostMapping("register")public void register(@RequestParam String userName, @RequestParam String email, @RequestParam String signature) {System.out.printf("userName:" + userName + ",email:" + email + ",signature:" + signature);}
這里還有一個問題就是如何生成消息認證碼,這里提供一個Hmacsha256方法(可自行選擇算法):
public static String genHmacSha256Sign(String message, String secret) {// 初始化密鑰,這里使用一個示例密鑰(在實際應用中,密鑰應該保密)byte[] secretKeyBytes = secret.getBytes(StandardCharsets.UTF_8);SecretKeySpec secretKey = new SecretKeySpec(secretKeyBytes, "HmacSHA256");try {// 獲取HMAC-SHA256的Mac實例Mac mac = Mac.getInstance("HmacSHA256");mac.init(secretKey);// 要簽名的數據byte[] dataBytes = message.getBytes(StandardCharsets.UTF_8);mac.update(dataBytes);// 執行MAC計算byte[] resultBytes = mac.doFinal();// 編碼為Base64字符串return Base64.getEncoder().encodeToString(resultBytes);} catch (Exception e) {throw new RuntimeException(e);}}
好,現在假定約定的密鑰是:826270b4-542b-4e48-b48c-856bea6453db
注冊的用戶名、email分別是:張三、zhangsan@qq.com,客戶端計算出來signature:
public static void main(String[] args) {String secret = "826270b4-542b-4e48-b48c-856bea6453db";String userName = "張三", email = "zhangsan@qq.com";String message = userName + email;System.out.println(genHmacSha256Sign(message, secret));}
輸出摘要為:uTo95CYO1AchnvRK9uAJ1W+nc2bJo2p1IsOtLOdWpsk=?
服務端的驗證邏輯調成為:
@PostMapping("register")public String register(@RequestParam String userName, @RequestParam String email, @RequestParam String signature) throws UnsupportedEncodingException {System.out.printf("userName:" + userName + ",email:" + email + ",signature:" + signature);String message = userName + email;String sha256Sign = URLDecoder.decode(SignUtil.genHmacSha256Sign(message, "826270b4-542b-4e48-b48c-856bea6453db"), StandardCharsets.UTF_8.name());if (signature.equals(sha256Sign)) {return "success";}return "error";// 省略注冊業務邏輯}
現在啟動一下服務端,通過postman來調用一下:
調用成功,一個最基本的認證功能實現完成了!?
實現-版本2-與業務解耦
上面的方式雖然實現了功能,不過還是會發現還是有一些問題:
- signature放在業務接口里面
- 要針對每個接口的參數單獨約定好message的拼接規則(比如哪些參數參與認證、拼接順序)
總之一句話,身份認證與業務接口沒有強綁定了,所以最好把身份認證設計成一個通用的功能:
- 提供一個過濾器,在里面進行身份認證的檢驗,并且指定攔截的URL
- 為了統一message的拼接規則,統一規則接口的所有參數都參與拼接
所以約定:
- 將signature從業務接口里面提出來,入到header中傳遞
- 接口的入參統一用RequestBody的json形式接收,不再定義成RequestParam
改定代碼,服務接口:
import com.tw.tsm.auth.dto.RegisterDtoReq;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;import java.io.UnsupportedEncodingException;@RestController
public class RegisterController {@PostMapping("register")public String register(@RequestBody RegisterDtoReq register) throws UnsupportedEncodingException {System.out.printf("userName:" + register.getUserName() + ",email:" + register.getEmail() + ",signature:" + signature);// 不再業務代碼里面進行身份認證了// String message = userName + email;
// String sha256Sign = URLDecoder.decode(SignUtil.genHmacSha256Sign(message, "826270b4-542b-4e48-b48c-856bea6453db"), StandardCharsets.UTF_8.name());
// if (signature.equals(sha256Sign)) {
// return "success";
// }
// return "error";// 省略注冊業務邏輯return null;}}@Data
@NoArgsConstructor
@AllArgsConstructor
public class RegisterDtoReq {private String userName;private String email;
}
過濾器:
import com.tw.tsm.base.util.RequestWrapper;
import com.tw.tsm.base.util.SignUtil;
import org.apache.commons.io.IOUtils;import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.nio.charset.StandardCharsets;public class VerityFilter implements Filter {@Overridepublic void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {ServletRequest requestWrapper = null;if (request instanceof HttpServletRequest) {requestWrapper = new RequestWrapper((HttpServletRequest) request);}// 在chain.doFiler方法中傳遞新的request對象if (requestWrapper == null) {chain.doFilter(request, response);} else {verity((HttpServletRequest) requestWrapper);chain.doFilter(requestWrapper, response);}}private void verity(HttpServletRequest requestWrapper) throws IOException {//獲取請求中的流如何,將取出來的字符串,再次轉換成流,然后把它放入到新request對象中。String requestBody = IOUtils.toString(requestWrapper.getInputStream(), StandardCharsets.UTF_8.name()).replaceAll("\r\n", "");System.out.printf(requestBody);String sha256Sign = SignUtil.genHmacSha256Sign(requestBody, "826270b4-542b-4e48-b48c-856bea6453db");String signature = requestWrapper.getHeader("signature");if (signature.equals(sha256Sign)) {return;}throw new IllegalArgumentException("參數異常!");}
}
包裝的HttpServletRequest,用于讀取Body:
import org.apache.commons.io.IOUtils;import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.*;public class RequestWrapper extends HttpServletRequestWrapper {private byte[] requestBody;private HttpServletRequest request;public RequestWrapper(HttpServletRequest request) throws IOException {super(request);this.request = request;}@Overridepublic BufferedReader getReader() throws IOException {return new BufferedReader(new InputStreamReader(getInputStream()));}@Overridepublic ServletInputStream getInputStream() throws IOException {if (requestBody == null) {ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();IOUtils.copy(request.getInputStream(), byteArrayOutputStream);this.requestBody = byteArrayOutputStream.toByteArray();}final ByteArrayInputStream bais = new ByteArrayInputStream(requestBody);return new ServletInputStream() {@Overridepublic int read() throws IOException {return bais.read();}@Overridepublic boolean isFinished() {return false;}@Overridepublic boolean isReady() {return false;}@Overridepublic void setReadListener(ReadListener readListener) {}};}}
注冊Filter:
@Beanpublic FilterRegistrationBean httpServletRequestReplacedRegistration() {FilterRegistrationBean registration = new FilterRegistrationBean();registration.setFilter(new VerityFilter());registration.addUrlPatterns("/register");registration.addInitParameter("paramName", "paramValue");registration.setName("VerityFilter");registration.setOrder(1);return registration;}
客戶端生成signature:
public static void main(String[] args) {String secret = "826270b4-542b-4e48-b48c-856bea6453db";String userName = "張三", email = "zhangsan@qq.com";
// String message = userName + email;JSONObject jsonObject = new JSONObject();jsonObject.put("userName", userName);jsonObject.put("email", email);String message = jsonObject.toJSONString();System.out.println(genHmacSha256Sign(message, secret));// System.out.println(genHmacSha256Sign(jsonObject.toString(), secret));}
?現在啟動一下服務端,通過postman來調用一下:
header里面也要傳參數:
實現-版本3-防重放
經過迭代過的版本,已經將身份認證與業務接口解耦開了,不過這里還有一個安全問題,就是防重放攻擊,具體的應對方案也比較成熟:
- 加時間戳-timestamp。該方法優點是不用額外保存其他信息。缺點是認證雙方需要準確的時間同步,同步越好,受攻擊的可能性就越小。但當系統很龐大,跨越的區域較廣時,要做到精確的時間同步并不是很容易。所以一般會采用在指定時間范圍,比如一分鐘以內的請求才接受。并且單獨使用時間戳,很難完全杜絕重放攻擊
- 加隨機數-nonce。該方法優點是認證雙方不需要時間同步,雙方記住(客戶端生成、傳遞給服務端)使用過的隨機數,如發現報文中有以前使用過的隨機數,就認為是重放攻擊。缺點是需要額外保存使用過的隨機數,若記錄的時間段較長,則保存和查詢的開銷較大。所以一般會采用時間戳+隨機數方式的:一分鐘以內的+此時間段內不重復的隨機數請求才接受(存儲采用redis,利用reids的TTL機制自動清理數據)
在實際中,常將方法(1)和方法(2)組合使用,這樣就只需保存某個很短時間段內的所有隨機數,而且時間戳的同步也不需要太精確。時間戳一般都是客戶端生成,而nonce可以由客戶端生成、也可以由服務端生成:
- 服務端生成的話,要額外增加一個接口級客戶端單獨獲取nonce
- 客戶端生成則不需要,可以簡化調用邏輯
生成timestamp、nonce,也放到header中做為公共參數,并參與message的拼接:message = 摘要算法(業務參數的json字符串+timestamp+nonce)。這里就不再實現了,代碼也不難
?