常用系統間介面呼叫認證設計/剖析/程式碼實現
阿新 • • 發佈:2019-12-31
簡介
本文實現介面加密簽名及校驗。 可適用於絕大多數系統間介面呼叫。
簽名實現
請求籤名實現過程
- 將當前請求路徑(不含域名。如:http:// x x x.com/sys/user/list,其中請求路徑即為/sys/user/list)作為URL引數的值(例如:URL=/sys/user/list),加上當前請求引數,對這些引數名進行升序排序,排序之後生成請求引數字串queryStr(拼接時要對引數值進行URLEncoder.encode編碼,防止中文等問題),形如:引數a=xx&引數b=22。
- 系統間約定加密字串key【houdask2019】 ,生成當前時間戳time(毫秒數),獲取當前使用者編號 uid(注意uid key time 這三個引數均不參與排序)
- 將步驟1中生成的queryStr 和 time 、key 、uid 進行拼接,形成待加密字串str。形如 queryStr &time=xx&key=xxx&uid=xxx
- 對待加密字串str進行MD5加密,並轉化成大寫,即生成簽名字串hash。
- 客戶端將uid放到請求header中,將原始請求引數和time、hash一起作引數傳遞(注意:其中URL引數不必傳遞)。
校驗請求籤名過程
- 從請求header中獲取使用者編號uid
- 從請求引數裡獲取簽名字串hash,以及請求時間time
- 獲取當前請求路徑,作為引數名為URL的引數值。【URL屬於隱藏引數】
- 再將請求引數進行一遍簽名加密,生成出來正確的簽名字串hash2
- 比較hash和hash2即可
優點:
- 引數防篡改(篡改引數之後簽名不一致)
- 簽名防串用(防止多個介面引數相同)
- 防過期呼叫(需校驗time在三分鐘或者更短時間內)
- 防暴力破解(含隱形引數,隱性引數名可以不用URL換成其他變數,增加安全性)
- 不可逆加密
- 簡單易用,安全性高
缺點:
嚴重依賴key的保密性,如果key洩露和演演算法暴露,安全性就有問題。 建議不定時更改key。
程式碼實現
public class FkSignUtil {
public static final String UID = "uid";
/**
* 加密祕鑰
*/
private static final String KEY = "key可以自定義" ;
/**
* 日誌物件
*/
private static Logger logger = LoggerFactory.getLogger(FkSignUtil.class);
/**
* 生成簽名【注意傳送請求時一定要帶上time 和hash 這2個引數】
*
* 功能:將一個Map按照Key字母升序構成一個QueryString. 並且加入時間混淆的hash串
* @param queryMap query內容
* @param time 加密時候,為當前時間;解密時,為從querystring得到的時間;
* @param uid 表示當前使用者id
* @return
*/
public static String createSign(Map<String,Object> queryMap,long time,String uid) {
if(null == uid || "".equals(uid)){
return null ;
}
String qs = sortQueryParamString(queryMap);
if (qs == null) {
return null;
}
String hash = MD5Util.MD5(String.format("%s&time=%d&key=%s&uid=%s",qs,time,KEY,uid));
hash = hash.toUpperCase();
return hash;
// String params = String.format("%s&time=%d&hash=%s",hash);
// return params;
}
/**
* 對請求引數進行排序
* @param params 請求引數 。注意請求引數中不能包含uid、time【這2引數是排序之後拼接的】
* @return
*/
private static String sortQueryParamString(Map<String,Object> params) {
List<String> listKeys = Lists.newArrayList( params.keySet());
Collections.sort(listKeys);
StringBuilder content = new StringBuilder();
for(String param : listKeys){
try {
content.append(param).append("=").append(URLEncoder.encode(params.get(param).toString(),"UTF-8")).append("&");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}
if(content.length()> 0){
return content.substring(0,content.length() -1 );
}
return content.toString();
}
/**
* 解密判斷是否簽名正確
* @param params 請求引數map【從request的引數中獲取】
* @param uid 表示當前使用者id【從header中獲取】
* @return
*/
public static boolean checkHashSign(Map<String,Object> params,String uid) {
if(null == uid || "".equals(uid)){
if (logger.isInfoEnabled()) {
logger.info("checkHashSign ERROR: uid is null.");
}
return false ;
}
if (!params.containsKey("hash") || !params.containsKey("time") ) {
if (logger.isInfoEnabled()) {
logger.info("checkHashSign ERROR: hash or time is null.");
}
return false;
}
String hash = (String) params.remove("hash");
Long time =Long.parseLong((String) params.remove("time"));
String signHash = createSign(params,uid);
return hash.equals(signHash);
}
public static void main(String[] args) {
Map<String,Object> params = new HashMap<String,Object>();
params.put("zhangId",12321);
params.put("guanId",true);
params.put("test","這是11AVC");
params.put("URL","XXXXXX");
params.put("name","是的商家");
String uid = "asdsa";
long time = System.currentTimeMillis();
String hash = createSign(params,uid);
logger.info("hash={},time={}",hash,time);
params.put("hash",hash);
params.put("time",time);
boolean flag = checkHashSign(params,uid);
logger.info("校驗flag={} ",flag);
}
}
複製程式碼
伺服器端介面校驗
通過Filter實現hash簽名的校驗。
校驗請求籤名過程
- 從請求header中獲取使用者編號uid
- 從請求引數裡獲取簽名字串hash,以及請求時間time
- 獲取當前請求路徑,作為引數名為URL的引數值。【URL屬於隱藏引數】
- 再將請求引數進行一遍簽名加密,生成出來正確的簽名字串hash2
- 比較hash和hash2即可
public class AppFilter implements Filter {
/**
* 日誌物件
*/
private static Logger logger = LoggerFactory.getLogger(AppFilter.class);
private static String CHECK_ERROR = null,PARAM_ERROR = null;
@Override
public void init(FilterConfig filterConfig) throws ServletException {
CHECK_ERROR = new FkJsonResult( "校驗失敗",FkJsonResult.DICT_COMMON_ERROR,"簽名認證失敗。" ).toJSONString();
PARAM_ERROR = new FkJsonResult( "引數錯誤","認證失敗。" ).toJSONString();
}
public static Map<String,Object> reqParamterToMap(HttpServletRequest req){
Map<String,String[]> m=req.getParameterMap();
Map<String,Object> rm=new HashMap<String,Object>(m.size());
Iterator<String> itor=m.keySet().iterator();
while(itor.hasNext()){
String key=itor.next();
String[] strs=m.get(key);
String val=null;
if(strs.length>0){
val=strs[0];
}
rm.put(key,val);
}
// 新增當前請求地址作為引數 防止不同介面間互用祕鑰,該引數屬於隱含引數。
rm.put("URL",req.getRequestURI());
return rm;
}
@Override
public void doFilter(ServletRequest request,ServletResponse response,FilterChain chain) throws IOException,ServletException {
if (request != null){
HttpServletRequest request1 = (HttpServletRequest) request;
String uid = request1.getHeader(FkSignUtil.UID);
if(StringUtils.isEmpty(uid)){
logger.info("header uid is null.");
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
PrintWriter out = response.getWriter();
out.append(PARAM_ERROR);
}else{
Map map = reqParamterToMap(request1);
boolean flag = FkSignUtil.checkHashSign(map,uid);
logger.info(" check flag = {}",flag);
if(flag){
chain.doFilter(request,response);
}else{
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
PrintWriter out = response.getWriter();
out.append(CHECK_ERROR);
}
}
}else{
chain.doFilter(request,response);
}
}
@Override
public void destroy() {
}
}
複製程式碼
其中定義json返回規範
- code:狀態編碼,可以自定義 。1表示成功 0表示通用失敗
- msg:提示資訊
- data: 資料
/**
* 定義 json返回
*/
public class FkJsonResult extends JSONObject implements Serializable {
/**
* 通用成功
*/
public static final String DICT_COMMON_SUCCESS = "1";
/**
* 通用失敗
*/
public static final String DICT_COMMON_ERROR = "0";
private String code;
private String message;
private Object data;
public FkJsonResult() {
}
/**
*
* @param data
* @param code
* @param message
*/
public FkJsonResult(Object data,String code,String message ) {
this.put("code",code);
this.put("message",message);
this.put("data",data);
}
public static FkJsonResult success(Object data){
return new FkJsonResult(data,DICT_COMMON_SUCCESS,"ok");
}
public static FkJsonResult success(Object data,String message){
return new FkJsonResult(data,message);
}
public static FkJsonResult success( ){
return new FkJsonResult(null,"ok");
}
public static FkJsonResult error(String code,String message){
return new FkJsonResult(null,code,message);
}
public static FkJsonResult error(String code,String message,Object data){
return new FkJsonResult(data,message);
}
/**
* 系統錯誤
* @return
*/
public static FkJsonResult error( ){
return new FkJsonResult("系統異常","系統維護中...");
}
public String getCode() {
return this.getString("code");
}
public void setCode(String code) {
this.put("code",code);
}
public String getMessage() {
return this.getString("message");
}
public void setMessage(String message) {
this.put("message",message);
}
public Object getData() {
return this.get ("data");
}
public void setData(Object data) {
this.put("data",data);
}
@Override
public String toString() {
return this.toJSONString();
}
}
複製程式碼
將Filter配置到web.xml中
具體攔截規則 需要根據自己的情況定義。最好使用該Filter的介面統一請求路徑字首。
<filter>
<filter-name>appFilter</filter-name>
<filter-class>com.xxxx.common.filter. AppFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>appFilter</filter-name>
<url-pattern>/aa/*</url-pattern>
此處要定義攔截規則
複製程式碼
由此伺服器接收端已做好自動校驗。
客戶端呼叫
利用Spring RestTemplate,實現2個post呼叫方法。
-
同步呼叫:建議使用非同步呼叫
-
非同步呼叫:新增自定義執行緒池 並設計如下請求引數:
- @param domain 域名
- @param path 請求路徑 (domain + path才是請求連結)
- @param params 引數map
- @param uid 使用者標識
其中JsonResult 同伺服器端的FkJsonResult.java。 TkSignUtil同伺服器端的FkSignUtil.java.。
請求籤名實現過程
- 將當前請求路徑(不含域名。如:xx.com/sys/user/li…,其中請求路徑即為/sys/user/list)作為URL引數的值(例如:URL=/sys/user/list),加上當前請求引數,對這些引數名進行升序排序,排序之後生成請求引數字串queryStr(拼接時要對引數值進行URLEncoder.encode編碼,防止中文等問題),形如:引數a=xx&引數b=22。
- 系統間約定加密字串key【houdask2019】 ,生成當前時間戳time(毫秒數),獲取當前使用者編號 uid(注意uid key time 這三個引數均不參與排序)
- 將步驟1中生成的queryStr 和 time 、key 、uid 進行拼接,形成待加密字串str。形如 queryStr &time=xx&key=xxx&uid=xxx
- 對待加密字串str進行MD5加密,並轉化成大寫,即生成簽名字串hash。
- 客戶端將uid放到請求header中,將原始請求引數和time、hash一起作引數傳遞(注意:其中URL引數不必傳遞)。
public class RestTemplateUtils {
private static Logger logger = LoggerFactory.getLogger(RestTemplateUtils.class);
private static final RestTemplate restTemplate = new RestTemplate();
private static ThreadFactory namedThreadFactory = new ThreadFactoryBuilder().setNameFormat("RestTemplateUtils-pool-%d").build();
private static ExecutorService pool = new ThreadPoolExecutor(5,200,0L,TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>(1024),namedThreadFactory,new ThreadPoolExecutor.AbortPolicy());
/**
* POST請求
*
* @param domain 域名
* @param path 請求路徑 (domain + path才是請求連結)
* @param params 引數map
* @param uid 使用者標識
* @return 返回結果 只有code == 1 才是成功返回
*/
public static JsonResult post(String domain,String path,Map<String,String uid) {
if (StringUtils.isEmpty(uid)) {
return JsonResult.error(JsonResult.DICT_COMMON_ERROR,"引數錯誤。","uid is null.");
}
// 簽名
params = getSignMap(params,uid,path);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.set("uid",uid);
MultiValueMap<String,Object> reqParams = new LinkedMultiValueMap();
for (String s : params.keySet()) {
reqParams.add(s,params.get(s).toString());
}
HttpEntity<MultiValueMap<String,Object>> entity = new HttpEntity<MultiValueMap<String,Object>>(reqParams,headers);
ResponseEntity<JsonResult> resp = restTemplate.exchange
(domain + path,HttpMethod.POST,entity,JsonResult.class);
if (resp.getStatusCode().equals(HttpStatus.OK)) {
return resp.getBody();
} else {
logger.error("{}{}請求錯誤{}:{}",domain,path,resp.getStatusCodeValue(),resp.getBody());
return JsonResult.error(resp.getStatusCodeValue() + "","請求失敗",resp.getBody());
}
}
/**
* 非同步呼叫POST請求
*
* @param domain 域名
* @param path 請求路徑 (domain + path才是請求連結)
* @param params 引數map
* @param uid 使用者標識
* @return 返回結果 FutureTask 通過FutureTask.get()即返回JsonResult。 只有code == 1 才是成功返回
*/
public static FutureTask<JsonResult> asynPost(String domain,String uid) {
FutureTask<JsonResult> task = new FutureTask((Callable<JSONObject>) () -> post(domain,params,uid));
pool.submit(task);
return task;
}
/**
* 獲取簽名引數並返回引數集合
*/
private static Map getSignMap(Map<String,Object> chapterMap,String userId,String path) {
long time = System.currentTimeMillis();
chapterMap.put("URL",path);
String hash = TkSignUtil.createSign(chapterMap,userId);
logger.info("hash={},time);
chapterMap.put("hash",hash);
chapterMap.put("time",time);
chapterMap.remove("URL");
return chapterMap;
}
}
複製程式碼
優化方案
可以考慮使用token快取,免加密解密校驗。 同時也可以校驗time是否不是在有效期內,比如判斷time是不是在當前時間的前2分鐘之內,防止介面擴散重複呼叫。
伺服器端安全升級--https安全升級
利用阿里雲申請 Symantec 免費版 SSL 證書。 在nginx上新增ssl證書到/etc/nginx/ssl.conf資料夾下面。 配置示例:
# 以下屬性中以ssl開頭的屬性代表與證書配置有關,其他屬性請根據自己的需要進行配置。
server {
listen 443;
server_name localhost; # localhost修改為您證書繫結的域名。
ssl on; #設定為on啟用SSL功能。
root html;
index index.html index.htm;
ssl_certificate cert/domain name.pem; #將domain name.pem替換成您證書的檔名。
ssl_certificate_key cert/domain name.key; #將domain name.key替換成您證書的金鑰檔名。
ssl_session_timeout 5m;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4; #使用此加密套件。
ssl_protocols TLSv1 TLSv1.1 TLSv1.2; #使用該協議進行配置。
ssl_prefer_server_ciphers on;
location / {
root html; #站點目錄。
index index.html index.htm;
}
}
複製程式碼
如無法訪問請檢查nginx所在機器的443埠是否開啟