简介
本文实现接口加密签名及校验。
可适用于绝大多数系统间接口调用。
签名实现
请求签名实现过程
- 将当前请求路径(不含域名。如:http://xx.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", qs, time, 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, time, 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, time, 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( "参数错误",FkJsonResult.DICT_COMMON_ERROR,"认证失败。" ).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 , DICT_COMMON_SUCCESS, message);
}
public static FkJsonResult success( ){
return new FkJsonResult(null , DICT_COMMON_SUCCESS,"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 , code,message);
}
/**
* 系统错误
* @return
*/
public static FkJsonResult error( ){
return new FkJsonResult("系统异常" , FkJsonResult.DICT_COMMON_ERROR, "系统维护中...");
}
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.。
请求签名实现过程
- 将当前请求路径(不含域名。如:http://xx.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参数不必传递)。
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, Object> params, 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 path, Map<String, Object> params, String uid) {
FutureTask<JsonResult> task = new FutureTask((Callable<JSONObject>) () -> post(domain, path, 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, time, userId);
logger.info("hash={} ,time={}", 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端口是否打开