安全優雅的RESTful API簽名實現方案
安全優雅的RESTful API簽名實現方案
1、介面簽名的必要性
在為第三方系統提供介面的時候,肯定要考慮介面資料的安全問題,比如資料是否被篡改,資料是否已經過時,資料是否可以重複提交等問題。其中我認為最終要的還是資料是否被篡改。在此分享一下我的關於介面簽名的實踐方案。
2、專案中籤名方案痛點
- 每個介面有各自的簽名方案,不統一,維護成本較高。
- 沒有對訊息實體進行簽名,無法避免資料被篡改。
- 無法避免資料重複提交。
3、簽名及驗證流程
4、簽名規則
- 線下分配appid和appsecret,針對不同的呼叫方分配不同的appid和appsecret。
- 加入timestamp(時間戳),10分鐘內資料有效。
- 加入流水號nonce(防止重複提交),至少為10位。針對查詢介面,流水號只用於日誌落地,便於後期日誌核查。 針對辦理類介面需校驗流水號在有效期內的唯一性,以避免重複請求。
- 加入signature,所有資料的簽名信息。
其中appid、timestamp、nonce、signature這四個欄位放入請求頭中。
5、簽名生成
5.1、資料部分
- Path:按照path中的順序將所有value進行拼接
- Query:按照key字典序排序,將所有key=value進行拼接
- Form:按照key字典序排序,將所有key=value進行拼接
- Body:
Json: 按照key字典序排序,將所有key=value進行拼接(例如{"a":"a","c":"c","b":{"e":"e"}} => a=a^_^b=e=e^_^c=c) String: 整個字串作為一個拼接
如果存在多種資料形式,則按照path、query、form、body的順序進行再拼接,得到所有資料的拼接值。
上述拼接的值記作 Y。
5.2、請求頭部分
X="appid=xxxnonce=xxxtimestamp=xxx"
5.3、生成簽名
最終拼接值=XY
最後將最終拼接值按照如下方法進行加密得到簽名(signature)。
signature=org.apache.commons.codec.digest.HmacUtils.hmacSha256Hex(app secret, 拼接的值);
6、簽名演算法實現
6.1、指定哪些介面或者哪些實體需要簽名
@Target({TYPE, METHOD}) @Retention(RUNTIME) @Documented public @interface Signature { String ORDER_SORT = "ORDER_SORT";//按照order值排序 String ALPHA_SORT = "ALPHA_SORT";//字典序排序 boolean resubmit() default true;//允許重複請求 String sort() default Signature.ALPHA_SORT; }
6.2、指定哪些欄位需要簽名
@Target({FIELD})
@Retention(RUNTIME)
@Documented
public @interface SignatureField {
//簽名順序
int order() default 0;
//欄位name自定義值
String customName() default "";
//欄位value自定義值
String customValue() default "";
}
6.3、簽名核心演算法(SignatureUtils)
public static String toSplice(Object object) {
if (Objects.isNull(object)) {
return StringUtils.EMPTY;
}
if (isAnnotated(object.getClass(), Signature.class)) {
Signature sg = findAnnotation(object.getClass(), Signature.class);
switch (sg.sort()) {
case Signature.ALPHA_SORT:
return alphaSignature(object);
case Signature.ORDER_SORT:
return orderSignature(object);
default:
return alphaSignature(object);
}
}
return toString(object);
}
private static String alphaSignature(Object object) {
StringBuilder result = new StringBuilder();
Map<String, String> map = new TreeMap<>();
for (Field field : getAllFields(object.getClass())) {
if (field.isAnnotationPresent(SignatureField.class)) {
field.setAccessible(true);
try {
if (isAnnotated(field.getType(), Signature.class)) {
if (!Objects.isNull(field.get(object))) {
map.put(field.getName(), toSplice(field.get(object)));
}
} else {
SignatureField sgf = field.getAnnotation(SignatureField.class);
if (StringUtils.isNotEmpty(sgf.customValue()) || !Objects.isNull(field.get(object))) {
map.put(StringUtils.isNotBlank(sgf.customName()) ? sgf.customName() : field.getName()
, StringUtils.isNotEmpty(sgf.customValue()) ? sgf.customValue() : toString(field.get(object)));
}
}
} catch (Exception e) {
LOGGER.error("簽名拼接(alphaSignature)異常", e);
}
}
}
for (Iterator<Map.Entry<String, String>> iterator = map.entrySet().iterator(); iterator.hasNext(); ) {
Map.Entry<String, String> entry = iterator.next();
result.append(entry.getKey()).append("=").append(entry.getValue());
if (iterator.hasNext()) {
result.append(DELIMETER);
}
}
return result.toString();
}
private static String toString(Object object) {
Class<?> type = object.getClass();
if (BeanUtils.isSimpleProperty(type)) {
return object.toString();
}
if (type.isArray()) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < Array.getLength(object); ++i) {
sb.append(toSplice(Array.get(object, i)));
}
return sb.toString();
}
if (ClassUtils.isAssignable(Collection.class, type)) {
StringBuilder sb = new StringBuilder();
for (Iterator<?> iterator = ((Collection<?>) object).iterator(); iterator.hasNext(); ) {
sb.append(toSplice(iterator.next()));
if (iterator.hasNext()) {
sb.append(DELIMETER);
}
}
return sb.toString();
}
if (ClassUtils.isAssignable(Map.class, type)) {
StringBuilder sb = new StringBuilder();
for (Iterator<? extends Map.Entry<String, ?>> iterator = ((Map<String, ?>) object).entrySet().iterator(); iterator.hasNext(); ) {
Map.Entry<String, ?> entry = iterator.next();
if (Objects.isNull(entry.getValue())) {
continue;
}
sb.append(entry.getKey()).append("=").append(toSplice(entry.getValue()));
if (iterator.hasNext()) {
sb.append(DELIMETER);
}
}
return sb.toString();
}
return NOT_FOUND;
}
- toSplice方法首先判斷物件是否注有@Signature註解,如果有則獲取簽名的排序規則(key值字典序排序或者指定order的值進行排序),比如排序規則是Signature.ALPHA_SORT(字典序)會呼叫alphaSignature方法生成key=value的拼接串;如果物件沒有@Signature註解,該物件型別可能是陣列、者集合類等,則呼叫toString方法生成key=value的拼接串。
- alphaSignature方法通過反射獲取到物件的所有Field屬性,需要判斷兩種情況:(1)獲取該Field屬性對應的Class資訊,如果Class資訊含有@Signature註解,則呼叫toSplice方法生成key=value的拼接串;(2)該Field屬性含有@SignatureField註解,呼叫toString方法生成key=value的拼接串。
- toString方法針對array, collection, simple property, map型別的資料做處理。其中如果物件是java的simple property型別,直接呼叫物件的toString方法返回value;如果是array、collection、map型別的資料,再呼叫toSplice方法生成key=value的拼接串。
7、簽名校驗
7.1、header中引數
7.2、簽名實體SignatureHeaders, 繫結request中header資訊
@ConfigurationProperties(prefix = "wmhopenapi.validate", exceptionIfInvalid = false)
@Signature
public class SignatureHeaders {
public static final String SIGNATURE_HEADERS_PREFIX = "wmhopenapi-validate";
public static final Set<String> HEADER_NAME_SET = Sets.newHashSet();
private static final String HEADER_APPID = SIGNATURE_HEADERS_PREFIX + "-appid";
private static final String HEADER_TIMESTAMP = SIGNATURE_HEADERS_PREFIX + "-timestamp";
private static final String HEADER_NONCE = SIGNATURE_HEADERS_PREFIX + "-nonce";
private static final String HEADER_SIGNATURE = SIGNATURE_HEADERS_PREFIX + "-signature";
static {
HEADER_NAME_SET.add(HEADER_APPID);
HEADER_NAME_SET.add(HEADER_TIMESTAMP);
HEADER_NAME_SET.add(HEADER_NONCE);
HEADER_NAME_SET.add(HEADER_SIGNATURE);
}
/**
* 線下分配的值
* 客戶端和服務端各自儲存appId對應的appSecret
*/
@NotBlank(message = "Header中缺少" + HEADER_APPID)
@SignatureField
private String appid;
/**
* 線下分配的值
* 客戶端和服務端各自儲存,與appId對應
*/
@SignatureField
private String appsecret;
/**
* 時間戳,單位: ms
*/
@NotBlank(message = "Header中缺少" + HEADER_TIMESTAMP)
@SignatureField
private String timestamp;
/**
* 流水號【防止重複提交】; (備註:針對查詢介面,流水號只用於日誌落地,便於後期日誌核查; 針對辦理類介面需校驗流水號在有效期內的唯一性,以避免重複請求)
*/
@NotBlank(message = "Header中缺少" + HEADER_NONCE)
@SignatureField
private String nonce;
/**
* 簽名
*/
@NotBlank(message = "Header中缺少" + HEADER_SIGNATURE)
private String signature;
}
7.3、根據request中header值生成簽名實體SignatureHeaders
private SignatureHeaders generateSignatureHeaders(Signature signature, HttpServletRequest request) throws Exception {
//處理header name
Map<String, Object> headerMap = Collections.list(request.getHeaderNames())
.stream()
.filter(headerName -> SignatureHeaders.HEADER_NAME_SET.contains(headerName))
.collect(Collectors.toMap(headerName -> headerName.replaceAll("-", "."), headerName -> request.getHeader(headerName)));
//將header資訊:name=value轉換成PropertySource
PropertySource propertySource = new MapPropertySource("signatureHeaders", headerMap);
//將header資訊繫結到SignatureHeaders物件
SignatureHeaders signatureHeaders = RelaxedConfigurationBinder.with(SignatureHeaders.class)
.setPropertySources(propertySource)
.doBind();
Optional<String> result = ValidatorUtils.validateResultProcess(signatureHeaders);
if (result.isPresent()) {
throw new ServiceException("WMH5000", result.get());
}
//從配置中拿到appid對應的appsecret
String appSecret = limitConstants.getSignatureLimit().get(signatureHeaders.getAppid());
if (StringUtils.isBlank(appSecret)) {
LOGGER.error("未找到appId對應的appSecret, appId=" + signatureHeaders.getAppid());
throw new ServiceException("WMH5002");
}
//其他合法性校驗
Long now = System.currentTimeMillis();
Long requestTimestamp = Long.parseLong(signatureHeaders.getTimestamp());
if ((now - requestTimestamp) > EXPIRE_TIME) {
String errMsg = "請求時間超過規定範圍時間10分鐘, signature=" + signatureHeaders.getSignature();
LOGGER.error(errMsg);
throw new ServiceException("WMH5000", errMsg);
}
String nonce = signatureHeaders.getNonce();
if (nonce.length() < 10) {
String errMsg = "隨機串nonce長度最少為10位, nonce=" + nonce;
LOGGER.error(errMsg);
throw new ServiceException("WMH5000", errMsg);
}
if (!signature.resubmit()) {
String existNonce = redisCacheService.getString(nonce);
if (StringUtils.isBlank(existNonce)) {
redisCacheService.setex(nonce, nonce, (int) TimeUnit.MILLISECONDS.toSeconds(RESUBMIT_DURATION));
} else {
String errMsg = "不允許重複請求, nonce=" + nonce;
LOGGER.error(errMsg);
throw new ServiceException("WMH5000", errMsg);
}
}
//設定appsecret
signatureHeaders.setAppsecret(appSecret);
return signatureHeaders;
}
生成簽名前需要如下幾個校驗步驟。
- 處理header name,通過工具類將header資訊繫結到簽名實體SignatureHeaders物件上。
- 驗證appid是否合法。
- 根據appid從配置中心中拿到appsecret。
- 請求是否已經超時,預設10分鐘。
- 隨機串是否合法。
- 是否允許重複請求。
7.4、生成header資訊引數拼接
String headersToSplice = SignatureUtils.toSplice(signatureHeaders);
7.5、切面攔截控制層方法,生成method中引數的拼接
private List<String> generateAllSplice(Method method, Object[] args, String headersToSplice) {
List<String> pathVariables = Lists.newArrayList(), requestParams = Lists.newArrayList();
String beanParams = StringUtils.EMPTY;
for (int i = 0; i < method.getParameterCount(); ++i) {
MethodParameter mp = new MethodParameter(method, i);
boolean findSignature = false;
for (Annotation anno : mp.getParameterAnnotations()) {
if (anno instanceof PathVariable) {
if (!Objects.isNull(args[i])) {
pathVariables.add(args[i].toString());
}
findSignature = true;
} else if (anno instanceof RequestParam) {
RequestParam rp = (RequestParam) anno;
String name = mp.getParameterName();
if (StringUtils.isNotBlank(rp.name())) {
name = rp.name();
}
if (!Objects.isNull(args[i])) {
List<String> values = Lists.newArrayList();
if (args[i].getClass().isArray()) {
//陣列
for (int j = 0; j < Array.getLength(args[i]); ++j) {
values.add(Array.get(args[i], j).toString());
}
} else if (ClassUtils.isAssignable(Collection.class, args[i].getClass())) {
//集合
for (Object o : (Collection<?>) args[i]) {
values.add(o.toString());
}
} else {
//單個值
values.add(args[i].toString());
}
values.sort(Comparator.naturalOrder());
requestParams.add(name + "=" + StringUtils.join(values));
}
findSignature = true;
} else if (anno instanceof RequestBody || anno instanceof ModelAttribute) {
beanParams = SignatureUtils.toSplice(args[i]);
findSignature = true;
}
if (findSignature) {
break;
}
}
if (!findSignature) {
LOGGER.info(String.format("簽名未識別的註解, method=%s, parameter=%s, annotations=%s", method.getName(), mp.getParameterName(), StringUtils.join(mp.getMethodAnnotations())));
}
}
List<String> toSplices = Lists.newArrayList();
toSplices.add(headersToSplice);
toSplices.addAll(pathVariables);
requestParams.sort(Comparator.naturalOrder());
toSplices.addAll(requestParams);
toSplices.add(beanParams);
return toSplices;
}
generateAllSplice方法是在控制層切面內執行,可以在方法執行之前獲取到已經繫結好的入參。分別對注有@PathVariable、@RequestParam、@RequestBody、@ModelAttribute註解的引數進行引數拼接的處理。其中注@RequestParam註解的引數需要特殊處理一下,分別考慮陣列、集合、原始型別這三種情況。
7.6、對最終的拼接結果重新生成簽名信息
SignatureUtils.signature(allSplice.toArray(new String[]{}), signatureHeaders.getAppsecret());
8、客戶端使用示例
8.1、生成簽名
//初始化請求頭資訊
SignatureHeaders signatureHeaders = new SignatureHeaders();
signatureHeaders.setAppid("111");
signatureHeaders.setAppsecret("222");
signatureHeaders.setNonce(SignatureUtils.generateNonce());
signatureHeaders.setTimestamp(String.valueOf(System.currentTimeMillis()));
List<String> pathParams = new ArrayList<>();
//初始化path中的資料
pathParams.add(SignatureUtils.encode("18237172801", signatureHeaders.getAppsecret()));
//呼叫簽名工具生成簽名
signatureHeaders.setSignature(SignatureUtils.signature(signatureHeaders, pathParams, null, null));
System.out.println("簽名資料: " + signatureHeaders);
System.out.println("請求資料: " + pathParams);
8.2、輸出結果
拼接結果: appid=111^_^appsecret=222^_^nonce=c9e778ba668c8f6fedf35634eb493af6304d54392d990262d9e7c1960b475b67^_^timestamp=1538207443910^_^w8rAwcXDxcDKwsM=^_^
簽名資料: SignatureHeaders{appid=111, appsecret=222, timestamp=1538207443910, nonce=c9e778ba668c8f6fedf35634eb493af6304d54392d990262d9e7c1960b475b67, signature=0a7d0b5e802eb5e52ac0cfcd6311b0faba6e2503a9a8d1e2364b38617877574d}
請求資料: [w8rAwcXDxcDKwsM=]
9、思考
上述的簽名方案的實現校驗邏輯是在控制層的切面內完成的。如果專案用的是springmvc框架,可以放在Filter或者攔截器裡嗎?很明顯是不行的(因為ServletRequest的輸入流InputStream 在預設情況只能讀取一次)。上述方案需要獲取繫結後的引數結果,然後執行簽名校驗邏輯。在執行控制層方法之前,springmvc已經幫我們完成了繫結的步驟,當然了,在繫結的過程中會解析ServletRequest中引數資訊(例如path引數、parameter引數、body引數)。
其實如果我們能在Filter或者攔截器中實現上述方案,那麼複雜度將會大大的降低。首先考慮如何讓ServletRequest的輸入流InputStream可以多次讀取,然後通過ServletRequest獲取path variable(對應@PathVariable)、parameters(對應@RequestParam)、body(對應@RequestBody)引數,最後整體按照規則進行拼接並生成簽名。
優化方案參考:https://www.cnblogs.com/hujunzheng/p/10178584.html