API接口的安全设计验证:ticket,签名,时间戳
https://mp.weixin.qq.com/s/vdeeypw5juuveY_NEtS1tA
与前端对接的API接口,如果被第三方抓包并进行恶意篡改参数,可能会导致数据泄露,甚至会被篡改数据,我主要围绕时间戳,token,签名三个部分来保证API接口的安全性
1.用户成功登陆站点后,服务器会返回一个token,用户的任何操作都必须带了这个参数,可以将这个参数直接放到header里。
2.客户端用需要发送的参数和token生成一个签名sign,作为参数一起发送给服务端,服务端在用同样的方法生成sign进行检查是否被篡改。
3.但这依然存在问题,可能会被进行恶意无限制访问,这时我们需要引入一个时间戳参数,如果超时即是无效的。
4.服务端需要对token,签名,时间戳进行验证,只有token有效,时间戳未超时,签名有效才能被放行。
工具类
package com.example.springdemo1221.util; import javax.servlet.http.HttpServletRequest; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.security.GeneralSecurityException; import java.security.MessageDigest; import java.util.*; public class SignUtils { private static final String secretKeyOfWxh = "e10adc3949ba59abbe56e057f20f883f"; public static void main(String[] args) { //参数签名算法测试 HashMap<String, String> signMap = new HashMap<String, String>(); signMap.put("ticket","a2144337fdcd47daab3ed0623deb1817"); signMap.put("personId","1"); signMap.put("ts","1609827917"); System.out.println("得到签名sign1:"+getSign(signMap,secretKeyOfWxh)); } /** * @param request * @return */ public static Boolean checkSign(HttpServletRequest request,String sign){ Boolean flag= false; //检查sigin是否过期 Enumeration<?> pNames = request.getParameterNames(); Map<String, String> params = new HashMap<String, String>(); while (pNames.hasMoreElements()) { String pName = (String) pNames.nextElement(); if("sign".equals(pName)) continue; String pValue = (String)request.getParameter(pName); params.put(pName, pValue); } System.out.println("现在的sign-->>" + sign); System.out.println("验证的sign-->>" + getSign(params,secretKeyOfWxh)); if(sign.equals(getSign(params, secretKeyOfWxh))){ flag = true; } return flag; } public static String utf8Encoding(String value, String sourceCharsetName) { try { return new String(value.getBytes(sourceCharsetName), "UTF-8"); } catch (UnsupportedEncodingException e) { throw new IllegalArgumentException(e); } } private static byte[] getMD5Digest(String data) throws IOException { byte[] bytes = null; try { MessageDigest md = MessageDigest.getInstance("MD5"); bytes = md.digest(data.getBytes("UTF-8")); } catch (GeneralSecurityException gse) { throw new IOException(gse); } return bytes; } private static String byte2hex(byte[] bytes) { StringBuilder sign = new StringBuilder(); for (int i = 0; i < bytes.length; i++) { String hex = Integer.toHexString(bytes[i] & 0xFF); if (hex.length() == 1) { sign.append("0"); } sign.append(hex.toUpperCase()); } return sign.toString(); } /** * 得到签名 * @param params 参数集合不含secretkey * @param secret 验证接口的secretkey * @return */ public static String getSign(Map<String, String> params,String secret) { String sign=""; StringBuilder sb = new StringBuilder(); //step1:先对请求参数排序 Set<String> keyset=params.keySet(); TreeSet<String> sortSet=new TreeSet<String>(); sortSet.addAll(keyset); Iterator<String> it=sortSet.iterator(); //step2:把参数的key value链接起来 secretkey放在最后面,得到要加密的字符串 while(it.hasNext()) { String key=it.next(); String value=params.get(key); sb.append(key).append(value); } sb.append(secret); byte[] md5Digest; try { //得到Md5加密得到sign md5Digest = getMD5Digest(sb.toString()); sign = byte2hex(md5Digest); } catch (IOException e) { System.out.println("生成签名错误"); } return sign; } public static long getTimestamp(){ long timestampLong = System.currentTimeMillis(); long timestampsStr = timestampLong / 1000; return timestampsStr; } }
开放接口
没有进行任何限制,简单粗暴的访问方式,这样的接口方式一般在开放的应用平台,查天气,查快递,只要你输入正确对应的参数调用,即可获取到自己需要的信息,我们可以任意修改参数值。
/* * Description: 开放的接口 * @author huangweicheng * @date 2020/12/21 */ @RestController @RequestMapping("/token") public class TokenSignController { @Autowired private TokenSignService tokenSignService; @RequestMapping(value = "openDemo",method = RequestMethod.GET) public List<PersonEntity> openDemo(int personId){ return tokenSignService.getPersonList(personId); } }
Token认证获取
用户登录成功后,会获取一个ticket值,接下去任何接口的访问都需要这个参数。我们把它放置在redis内,有效期为10分钟,在ticket即将超时,无感知续命。延长使用时间,如果用户在一段时间内没进行任何操作,就需要重新登录系统。扩展:记一次token安全认证的实践
@RequestMapping(value = "login",method = RequestMethod.POST) public JSONObject login(@NotNull String username, @NotNull String password){ return tokenSignService.login(username,password); }
登录操作,查看是否有这个用户,用户名和密码匹配即可成功登录。
/** * * Description:验证登录,ticket成功后放置缓存中, * @param * @author huangweicheng * @date 2020/12/31 */ public JSONObject login(String username,String password){ JSONObject result = new JSONObject(); PersonEntity personEntity = personDao.findByLoginName(username); if (personEntity == null || (personEntity != null && !personEntity.getPassword().equals(password))){ result.put("success",false); result.put("ticket",""); result.put("code","999"); result.put("message","用户名和密码不匹配"); return result; } if (personEntity.getLoginName().equals(username) && personEntity.getPassword().equals(password)){ String ticket = UUID.randomUUID().toString(); ticket = ticket.replace("-",""); redisTemplate.opsForValue().set(ticket,personEntity.getLoginName(),10L, TimeUnit.MINUTES); result.put("success",true); result.put("ticket",ticket); result.put("code",200); result.put("message","登录成功"); return result; } result.put("success",false); result.put("ticket",""); result.put("code","1000"); result.put("message","未知异常,请重试"); return result; }
Sign签名
把所有的参数拼接一起,在加入系统秘钥,进行MD5计算生成一个sign签名,防止参数被人恶意篡改,后台按同样的方法生成秘钥,进行签名对比。
/** * @param request * @return */ public static Boolean checkSign(HttpServletRequest request,String sign){ Boolean flag= false; //检查sigin是否过期 Enumeration<?> pNames = request.getParameterNames(); Map<String, String> params = new HashMap<String, String>(); while (pNames.hasMoreElements()) { String pName = (String) pNames.nextElement(); if("sign".equals(pName)) continue; String pValue = (String)request.getParameter(pName); params.put(pName, pValue); } System.out.println("现在的sign-->>" + sign); System.out.println("验证的sign-->>" + getSign(params,secretKeyOfWxh)); if(sign.equals(getSign(params, secretKeyOfWxh))){ flag = true; } return flag; }
重复访问
引入一个时间戳参数,保证接口仅在一分钟内有效,需要和客户端时间保持一致
public static long getTimestamp(){ long timestampLong = System.currentTimeMillis(); long timestampsStr = timestampLong / 1000; return timestampsStr; }
需要跟当前服务器时间进行对比,如果超过一分钟,就拒绝本次请求,节省服务器查询数据的消耗
拦截器
每次请求都带有这三个参数,我们都需要进行验证,只有在三个参数都满足我们的要求,才允许数据返回或被操作。
public class LoginInterceptor implements HandlerInterceptor { @Autowired private RedisTemplate redisTemplate; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response,Object handler) throws IOException { JSONObject jsonObject = new JSONObject(); String ticket = request.getParameter("ticket"); String sign = request.getParameter("sign"); String ts = request.getParameter("ts"); if (StringUtils.isEmpty(ticket) || StringUtils.isEmpty(sign) || StringUtils.isEmpty(ts)){ jsonObject.put("success",false); jsonObject.put("message","args is isEmpty"); jsonObject.put("code","1001"); PrintWriter printWriter = response.getWriter(); printWriter.write(jsonObject.toJSONString()); return false; } //如果redis存在ticket就认为是合法的请求 if (redisTemplate.hasKey(ticket)){ System.out.println(redisTemplate.opsForValue().getOperations().getExpire(ticket)); String values = (String) redisTemplate.opsForValue().get(ticket); //判断ticket是否即将过期,进行续命操作 if (redisTemplate.opsForValue().getOperations().getExpire(ticket) != -2 && redisTemplate.opsForValue().getOperations().getExpire(ticket) < 20){ redisTemplate.opsForValue().set(ticket,values,10L, TimeUnit.MINUTES); } System.out.println(SignUtils.getTimestamp()); //判断是否重复访问,存在重放攻击的时间窗口期 if (SignUtils.getTimestamp() - Long.valueOf(ts) > 600){ jsonObject.put("success",false); jsonObject.put("message","Overtime to connect to server"); jsonObject.put("code","1002"); PrintWriter printWriter = response.getWriter(); printWriter.write(jsonObject.toJSONString()); return false; } //验证签名 if (!SignUtils.checkSign(request,sign)){ jsonObject.put("success",false); jsonObject.put("message","sign is invalid"); jsonObject.put("code","1003"); PrintWriter printWriter = response.getWriter(); printWriter.write(jsonObject.toJSONString()); return false; } return true; }else { jsonObject.put("success",false); jsonObject.put("message","ticket is invalid,Relogin."); jsonObject.put("code","1004"); PrintWriter printWriter = response.getWriter(); printWriter.write(jsonObject.toJSONString()); } return false; } }
访问
先登录系统,获取合法的ticket
生成一个合法的sign验证,获取测试ts,访问openDemo,即可正常访问。还可以将参数加密,将http换成https,就不一 一展开了。