【2019年8月9日更新完善】常用系统间接口调用认证设计剖析及服务器客户端代码实现

简介

本文实现接口加密签名及校验。
可适用于绝大多数系统间接口调用。

签名实现

请求签名实现过程

  1. 将当前请求路径(不含域名。如:http://xx.com/sys/user/list ,其中请求路径即为/sys/user/list)作为URL参数的值(例如:URL=/sys/user/list),加上当前请求参数,对这些参数名进行升序排序,排序之后生成请求参数字符串queryStr(拼接时要对参数值进行URLEncoder.encode编码,防止中文等问题),形如:参数a=xx&参数b=22。
  2. 系统间约定加密字符串key【houdask2019】 ,生成当前时间戳time(毫秒数),获取当前用户编号 uid(注意uid key time 这三个参数均不参与排序)
  3. 将步骤1中生成的queryStr 和 time 、key 、uid 进行拼接,形成待加密字符串str。形如 queryStr &time=xx&key=xxx&uid=xxx
  4. 对待加密字符串str进行MD5加密,并转化成大写,即生成签名字符串hash。
  5. 客户端将uid放到请求header中,将原始请求参数和time、hash一起作参数传递(注意:其中URL参数不必传递)。

校验请求签名过程

  1. 从请求header中获取用户编号uid
  2. 从请求参数里获取签名字符串hash,以及请求时间time
  3. 获取当前请求路径,作为参数名为URL的参数值。【URL属于隐藏参数】
  4. 再将请求参数进行一遍签名加密,生成出来正确的签名字符串hash2
  5. 比较hash和hash2即可

优点:

  1. 参数防篡改(篡改参数之后签名不一致)
  2. 签名防串用(防止多个接口参数相同)
  3. 防过期调用(需校验time在三分钟或者更短时间内)
  4. 防暴力破解(含隐形参数,隐性参数名可以不用URL换成其他变量,增加安全性)
  5. 不可逆加密
  6. 简单易用,安全性高

缺点:

严重依赖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签名的校验。

校验请求签名过程

  1. 从请求header中获取用户编号uid
  2. 从请求参数里获取签名字符串hash,以及请求时间time
  3. 获取当前请求路径,作为参数名为URL的参数值。【URL属于隐藏参数】
  4. 再将请求参数进行一遍签名加密,生成出来正确的签名字符串hash2
  5. 比较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.。

请求签名实现过程

  1. 将当前请求路径(不含域名。如:http://xx.com/sys/user/list ,其中请求路径即为/sys/user/list)作为URL参数的值(例如:URL=/sys/user/list),加上当前请求参数,对这些参数名进行升序排序,排序之后生成请求参数字符串queryStr(拼接时要对参数值进行URLEncoder.encode编码,防止中文等问题),形如:参数a=xx&参数b=22。
  2. 系统间约定加密字符串key【houdask2019】 ,生成当前时间戳time(毫秒数),获取当前用户编号 uid(注意uid key time 这三个参数均不参与排序)
  3. 将步骤1中生成的queryStr 和 time 、key 、uid 进行拼接,形成待加密字符串str。形如 queryStr &time=xx&key=xxx&uid=xxx
  4. 对待加密字符串str进行MD5加密,并转化成大写,即生成签名字符串hash。
  5. 客户端将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端口是否打开

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 210,978评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 89,954评论 2 384
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,623评论 0 345
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,324评论 1 282
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,390评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,741评论 1 289
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,892评论 3 405
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,655评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,104评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,451评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,569评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,254评论 4 328
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,834评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,725评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,950评论 1 264
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,260评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,446评论 2 348

推荐阅读更多精彩内容