查看原文
其他

【实战篇】Java 生鲜电商平台 - API 接口设计之 token、timestamp、sign 具体架构与实现

作者 | 巨人大哥

来源 | cnblogs.com/jurendage/p/12653865.html


上一篇:【热议篇】28岁程序员期权过亿,彪悍从字节跳动提前退休,网友:酸了酸了!



一:token 简介


timestamp: 时间戳,是客户端调用接口时对应的当前时间戳,时间戳用于防止DoS攻击。当黑客劫持了请求的url去DoS攻击,每次调用接口时接口都会判断服务器当前系统时间和接口中传的的timestamp的差值,如果这个差值超过某个设置的时间(假如5分钟),那么这个请求将被拦截掉,如果在设置的超时时间范围内,是不能阻止DoS攻击的。timestamp机制只能减轻DoS攻击的时间,缩短攻击时间。如果黑客修改了时间戳的值可通过sign签名机制来处理。

DoS


DoS是Denial of Service的简称,即拒绝服务,造成DoS的攻击行为被称为DoS攻击,其目的是使计算机或网络无法提供正常的服务。最常见的DoS攻击有计算机网络带宽攻击和连通性攻击。

DoS攻击是指故意的攻击网络协议实现的缺陷或直接通过野蛮手段残忍地耗尽被攻击对象的资源,目的是让目标计算机或网络无法提供正常的服务或资源访问,使目标系统服务系统停止响应甚至崩溃,而在此攻击中并不包括侵入目标服务器或目标网络设备。这些服务资源包括网络带宽,文件系统空间容量,开放的进程或者允许的连接。这种攻击会导致资源的匮乏,无论计算机的处理速度多快、内存容量多大、网络带宽的速度多快都无法避免这种攻击带来的后果。

  • Pingflood: 该攻击在短时间内向目的主机发送大量ping包,造成网络堵塞或主机资源耗尽。

  • Synflood: 该攻击以多个随机的源主机地址向目的主机发送SYN包,而在收到目的主机的SYN ACK后并不回应,这样,目的主机就为这些源主机建立了大量的连接队列,而且由于没有收到ACK一直维护着这些队列,造成了资源的大量消耗而不能向正常请求提供服务。

  • Smurf:该攻击向一个子网的广播地址发一个带有特定请求(如ICMP回应请求)的包,并且将源地址伪装成想要攻击的主机地址。子网上所有主机都回应广播包请求而向被攻击主机发包,使该主机受到攻击。

  • Land-based:攻击者将一个包的源地址和目的地址都设置为目标主机的地址,然后将该包通过IP欺骗的方式发送给被攻击主机,这种包可以造成被攻击主机因试图与自己建立连接而陷入死循环,从而很大程度地降低了系统性能。


  • Ping of Death:根据TCP/IP的规范,一个包的长度最大为65536字节。尽管一个包的长度不能超过65536字节,但是一个包分成的多个片段的叠加却能做到。当一个主机收到了长度大于65536字节的包时,就是受到了Ping of Death攻击,该攻击会造成主机的宕机。


  • Teardrop:IP数据包在网络传递时,数据包可以分成更小的片段。攻击者可以通过发送两段(或者更多)数据包来实现TearDrop攻击。第一个包的偏移量为0,长度为N,第二个包的偏移量小于N。为了合并这些数据段,TCP/IP堆栈会分配超乎寻常的巨大资源,从而造成系统资源的缺乏甚至机器的重新启动。


  • PingSweep:使用ICMP Echo轮询多个主机。


四:防止重复提交


对于一些重要的操作需要防止客户端重复提交的(如非幂等性重要操作),具体办法是当请求第一次提交时将sign作为key保存到redis,并设置超时时间,超时时间和Timestamp中设置的差值相同。当同一个请求第二次访问时会先检测redis是否存在该sign,如果存在则证明重复提交了,接口就不再继续调用了。如果sign在缓存服务器中因过期时间到了,而被删除了,此时当这个url再次请求服务器时,因token的过期时间和sign的过期时间一直,sign过期也意味着token过期,那样同样的url再访问服务器会因token错误会被拦截掉,这就是为什么sign和token的过期时间要保持一致的原因。拒绝重复调用机制确保URL被别人截获了也无法使用(如抓取数据)。

对于哪些接口需要防止重复提交可以自定义个注解来标记。

注意:所有的安全措施都用上的话有时候难免太过复杂,在实际项目中需要根据自身情况作出裁剪,比如可以只使用签名机制就可以保证信息不会被篡改,或者定向提供服务的时候只用Token机制就可以了。如何裁剪,全看项目实际情况和对接口安全性的要求。


六:示例代码


1. dependency

<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><dependency><groupId>redis.clients</groupId><artifactId>jedis</artifactId><version>2.9.0</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>


2. RedisConfiguration

@Configurationpublic class RedisConfiguration {@Beanpublic JedisConnectionFactory jedisConnectionFactory(){return new JedisConnectionFactory(); }/** * 支持存储对象 * @return */@Beanpublic RedisTemplate<String, String> redisTemplate(){ RedisTemplate<String, String> redisTemplate = new StringRedisTemplate(); redisTemplate.setConnectionFactory(jedisConnectionFactory()); Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class); ObjectMapper objectMapper = new ObjectMapper(); objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL); jackson2JsonRedisSerializer.setObjectMapper(objectMapper); redisTemplate.setValueSerializer(jackson2JsonRedisSerializer); redisTemplate.afterPropertiesSet();return redisTemplate; }}


3. TokenController

@Slf4j@RestController@RequestMapping("/api/token")public class TokenController {@Autowiredprivate RedisTemplate redisTemplate;/** * API Token * * @param sign * @return */@PostMapping("/api_token")public ApiResponse<AccessToken> apiToken(String appId, @RequestHeader("timestamp") String timestamp, @RequestHeader("sign") String sign) { Assert.isTrue(!StringUtils.isEmpty(appId) && !StringUtils.isEmpty(timestamp) && !StringUtils.isEmpty(sign), "参数错误"); long reqeustInterval = System.currentTimeMillis() - Long.valueOf(timestamp); Assert.isTrue(reqeustInterval < 5 * 60 * 1000, "请求过期,请重新请求");// 1. 根据appId查询数据库获取appSecret AppInfo appInfo = new AppInfo("1", "12345678954556");// 2. 校验签名String signString = timestamp + appId + appInfo.getKey();String signature = MD5Util.encode(signString); log.info(signature); Assert.isTrue(signature.equals(sign), "签名错误");// 3. 如果正确生成一个token保存到redis中,如果错误返回错误信息 AccessToken accessToken = this.saveToken(0, appInfo, null);return ApiResponse.success(accessToken); }@NotRepeatSubmit(5000)@PostMapping("user_token")public ApiResponse<UserInfo> userToken(String username, String password) {// 根据用户名查询密码, 并比较密码(密码可以RSA加密一下) UserInfo userInfo = new UserInfo(username, "81255cb0dca1a5f304328a70ac85dcbd", "111111");String pwd = password + userInfo.getSalt();String passwordMD5 = MD5Util.encode(pwd); Assert.isTrue(passwordMD5.equals(userInfo.getPassword()), "密码错误");// 2. 保存Token AppInfo appInfo = new AppInfo("1", "12345678954556"); AccessToken accessToken = this.saveToken(1, appInfo, userInfo); userInfo.setAccessToken(accessToken);return ApiResponse.success(userInfo); }private AccessToken saveToken(int tokenType, AppInfo appInfo, UserInfo userInfo) {String token = UUID.randomUUID().toString();// token有效期为2小时 Calendar calendar = Calendar.getInstance(); calendar.setTime(new Date()); calendar.add(Calendar.SECOND, 7200);Date expireTime = calendar.getTime();// 4. 保存token ValueOperations<String, TokenInfo> operations = redisTemplate.opsForValue(); TokenInfo tokenInfo = new TokenInfo(); tokenInfo.setTokenType(tokenType); tokenInfo.setAppInfo(appInfo);if (tokenType == 1) { tokenInfo.setUserInfo(userInfo); } operations.set(token, tokenInfo, 7200, TimeUnit.SECONDS); AccessToken accessToken = new AccessToken(token, expireTime);return accessToken; }public static void main(String[] args) { long timestamp = System.currentTimeMillis(); System.out.println(timestamp);String signString = timestamp + "1" + "12345678954556";String sign = MD5Util.encode(signString); System.out.println(sign); System.out.println("-------------------"); signString = "password=123456&username=1&12345678954556" + "ff03e64b-427b-45a7-b78b-47d9e8597d3b1529815393153sdfsdfsfs" + timestamp + "A1scr6"; sign = MD5Util.encode(signString); System.out.println(sign); }}


4. WebMvcConfiguration

@Configurationpublic class WebMvcConfiguration extends WebMvcConfigurationSupport {
private static final String[] excludePathPatterns = {"/api/token/api_token"};
@Autowired private TokenInterceptor tokenInterceptor;
@Override public void addInterceptors(InterceptorRegistry registry) { super.addInterceptors(registry); registry.addInterceptor(tokenInterceptor) .addPathPatterns("/api/**") .excludePathPatterns(excludePathPatterns); }}5. TokenInterceptor@Componentpublic class TokenInterceptor extends HandlerInterceptorAdapter {
@Autowired private RedisTemplate redisTemplate;
/** * * @param request * @param response * @param handler 访问的目标方法 * @return * @throws Exception */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("token"); String timestamp = request.getHeader("timestamp"); // 随机字符串 String nonce = request.getHeader("nonce"); String sign = request.getHeader("sign"); Assert.isTrue(!StringUtils.isEmpty(token) && !StringUtils.isEmpty(timestamp) && !StringUtils.isEmpty(sign), "参数错误");
// 获取超时时间 NotRepeatSubmit notRepeatSubmit = ApiUtil.getNotRepeatSubmit(handler); long expireTime = notRepeatSubmit == null ? 5 * 60 * 1000 : notRepeatSubmit.value();
// 2. 请求时间间隔 long reqeustInterval = System.currentTimeMillis() - Long.valueOf(timestamp); Assert.isTrue(reqeustInterval < expireTime, "请求超时,请重新请求");
// 3. 校验Token是否存在 ValueOperations<String, TokenInfo> tokenRedis = redisTemplate.opsForValue(); TokenInfo tokenInfo = tokenRedis.get(token); Assert.notNull(tokenInfo, "token错误");
// 4. 校验签名(将所有的参数加进来,防止别人篡改参数) 所有参数看参数名升续排序拼接成url // 请求参数 + token + timestamp + nonce String signString = ApiUtil.concatSignString(request) + tokenInfo.getAppInfo().getKey() + token + timestamp + nonce; String signature = MD5Util.encode(signString); boolean flag = signature.equals(sign); Assert.isTrue(flag, "签名错误");
// 5. 拒绝重复调用(第一次访问时存储,过期时间和请求超时时间保持一致), 只有标注不允许重复提交注解的才会校验 if (notRepeatSubmit != null) { ValueOperations<String, Integer> signRedis = redisTemplate.opsForValue(); boolean exists = redisTemplate.hasKey(sign); Assert.isTrue(!exists, "请勿重复提交"); signRedis.set(sign, 0, expireTime, TimeUnit.MILLISECONDS); }
return super.preHandle(request, response, handler); }}


6. MD5Util ----MD5工具类,加密生成数字签名

public class MD5Util {private static final String hexDigits[] = { "0", "1", "2", "3", "4", "5","6", "7", "8", "9", "a", "b", "c", "d", "e", "f" };private static String byteArrayToHexString(byte b[]) { StringBuffer resultSb = new StringBuffer();for (int i = 0; i < b.length; i++) resultSb.append(byteToHexString(b[i]));return resultSb.toString(); }private static String byteToHexString(byte b) {int n = b;if (n < 0) n += 256;int d1 = n / 16;int d2 = n % 16;return hexDigits[d1] + hexDigits[d2]; }public static String encode(String origin) {return encode(origin, "UTF-8"); }public static String encode(String origin, String charsetname) { String resultString = null;try { resultString = new String(origin); MessageDigest md = MessageDigest.getInstance("MD5");if (charsetname == null || "".equals(charsetname)) resultString = byteArrayToHexString(md.digest(resultString .getBytes()));else resultString = byteArrayToHexString(md.digest(resultString .getBytes(charsetname))); } catch (Exception exception) { }return resultString; }}


7. @NotRepeatSubmit   -----自定义注解,防止重复提交。

/** * 禁止重复提交 */@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface NotRepeatSubmit {/** 过期时间,单位毫秒 **/long value() default 5000;}


8. AccessToken

@Data@AllArgsConstructorpublic class AccessToken {/** token */private String token;/** 失效时间 */private Date expireTime;}


9. AppInfo

@Data@NoArgsConstructor@AllArgsConstructorpublic class AppInfo {/** App id */private String appId;/** API 秘钥 */private String key;}


10. TokenInfo

@Datapublic class TokenInfo {/** token类型: api:0 、user:1 */private Integer tokenType;/** App 信息 */private AppInfo appInfo;/** 用户其他数据 */private UserInfo userInfo;}


11. UserInfo

@Datapublic class UserInfo {/** 用户名 */private String username;/** 手机号 */private String mobile;/** 邮箱 */private String email;/** 密码 */private String password;/** 盐 */private String salt;private AccessToken accessToken;public UserInfo(String username, String password, String salt) {this.username = username;this.password = password;this.salt = salt; }}


12. ApiCodeEnum

/** * 错误码code可以使用纯数字,使用不同区间标识一类错误,也可以使用纯字符,也可以使用前缀+编号 * * 错误码:ERR + 编号 * * 可以使用日志级别的前缀作为错误类型区分 Info(I) Error(E) Warning(W) * * 或者以业务模块 + 错误号 * * TODO 错误码设计 * * Alipay 用了两个code,两个msg(https://docs.open.alipay.com/api_1/alipay.trade.pay) */public enum ApiCodeEnum { SUCCESS("10000", "success"), UNKNOW_ERROR("ERR0001","未知错误"), PARAMETER_ERROR("ERR0002","参数错误"), TOKEN_EXPIRE("ERR0003","认证过期"), REQUEST_TIMEOUT("ERR0004","请求超时"), SIGN_ERROR("ERR0005","签名错误"), REPEAT_SUBMIT("ERR0006","请不要频繁操作"), ;/** 代码 */private String code;/** 结果 */private String msg; ApiCodeEnum(String code, String msg) {this.code = code;this.msg = msg; }public String getCode() {return code; }public String getMsg() {return msg; }}


13. ApiResult

@Data@NoArgsConstructor@AllArgsConstructorpublic class ApiResult {/** 代码 */private String code;/** 结果 */private String msg;}


14. ApiUtil  -------这个参考支付宝加密的算法写的.我直接Copy过来了。

public class ApiUtil {/** * 按参数名升续拼接参数 * @param request * @return */ public static String concatSignString(HttpServletRequest request) {Map<String, String> paramterMap = new HashMap<>(); request.getParameterMap().forEach((key, value) -> paramterMap.put(key, value[0]));// 按照key升续排序,然后拼接参数Set<String> keySet = paramterMap.keySet();String[] keyArray = keySet.toArray(new String[keySet.size()]); Arrays.sort(keyArray); StringBuilder sb = new StringBuilder();for (String k : keyArray) {// 或略掉的字段if (k.equals("sign")) {continue; }if (paramterMap.get(k).trim().length() > 0) {// 参数值为空,则不参与签名 sb.append(k).append("=").append(paramterMap.get(k).trim()).append("&"); } }return sb.toString(); } public static String concatSignString(Map<String, String> map) {Map<String, String> paramterMap = new HashMap<>(); map.forEach((key, value) -> paramterMap.put(key, value));// 按照key升续排序,然后拼接参数Set<String> keySet = paramterMap.keySet();String[] keyArray = keySet.toArray(new String[keySet.size()]); Arrays.sort(keyArray); StringBuilder sb = new StringBuilder();for (String k : keyArray) {if (paramterMap.get(k).trim().length() > 0) {// 参数值为空,则不参与签名 sb.append(k).append("=").append(paramterMap.get(k).trim()).append("&"); } }return sb.toString(); }/** * 获取方法上的@NotRepeatSubmit注解 * @param handler * @return */ public static NotRepeatSubmit getNotRepeatSubmit(Object handler) {if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod = (HandlerMethod) handler; Method method = handlerMethod.getMethod(); NotRepeatSubmit annotation = method.getAnnotation(NotRepeatSubmit.class);return annotation; }return null; }}


15. ApiResponse

@Data@Slf4jpublic class ApiResponse<T> {/** 结果 */private ApiResult result;/** 数据 */private T data;/** 签名 */private String sign;public static <T> ApiResponse success(T data) {return response(ApiCodeEnum.SUCCESS.getCode(), ApiCodeEnum.SUCCESS.getMsg(), data); }public static ApiResponse error(String code, String msg) {return response(code, msg, null); }public static <T> ApiResponse response(String code, String msg, T data) { ApiResult result = new ApiResult(code, msg); ApiResponse response = new ApiResponse(); response.setResult(result); response.setData(data);String sign = signData(data); response.setSign(sign);return response; }private static <T> String signData(T data) {// TODO 查询keyString key = "12345678954556"; Map<String, String> responseMap = null;try { responseMap = getFields(data); } catch (IllegalAccessException e) {return null; }String urlComponent = ApiUtil.concatSignString(responseMap);String signature = urlComponent + "key=" + key;String sign = MD5Util.encode(signature);return sign; }/** * @param data 反射的对象,获取对象的字段名和值 * @throws IllegalArgumentException * @throws IllegalAccessException */public static Map<String, String> getFields(Object data) throws IllegalAccessException, IllegalArgumentException {if (data == null) return null; Map<String, String> map = new HashMap<>(); Field[] fields = data.getClass().getDeclaredFields();for (int i = 0; i < fields.length; i++) { Field field = fields[i]; field.setAccessible(true);String name = field.getName();Object value = field.get(data);if (field.get(data) != null) { map.put(name, value.toString()); } }return map; }}


七: ThreadLocal


ThreadLocal是线程内的全局上下文。就是在单个线程中,方法之间共享的内存,每个方法都可以从该上下文中获取值和修改值。


实际案例:


在调用api时都会传一个token参数,通常会写一个拦截器来校验token是否合法,我们可以通过token找到对应的用户信息(User),如果token合法,然后将用户信息存储到ThreadLocal中,这样无论是在controller、service、dao的哪一层都能访问到该用户的信息。作用类似于Web中的request作用域。


传统方式我们要在方法中访问某个变量,可以通过传参的形式往方法中传参,如果多个方法都要使用那么每个方法都要传参;如果使用ThreadLocal所有方法就不需要传该参数了,每个方法都可以通过ThreadLocal来访问该值。


  • ThreadLocalUtil.set("key", value); 保存值


  • T value = ThreadLocalUtil.get("key"); 获取值


ThreadLocalUtil

public class ThreadLocalUtil<T> { private static final ThreadLocal<Map<String, Object>> threadLocal = new ThreadLocal() { @Override protected Map<String, Object> initialValue() { return new HashMap<>(4); } };

public static Map<String, Object> getThreadLocal(){ return threadLocal.get(); }
public static <T> T get(String key) { Map map = (Map)threadLocal.get(); return (T)map.get(key); }
public static <T> T get(String key,T defaultValue) { Map map = (Map)threadLocal.get(); return (T)map.get(key) == null ? defaultValue : (T)map.get(key); }
public static void set(String key, Object value) { Map map = (Map)threadLocal.get(); map.put(key, value); }
public static void set(Map<String, Object> keyValueMap) { Map map = (Map)threadLocal.get(); map.putAll(keyValueMap); }
public static void remove() { threadLocal.remove(); }
public static <T> Map<String,T> fetchVarsByPrefix(String prefix) { Map<String,T> vars = new HashMap<>(); if( prefix == null ){ return vars; } Map map = (Map)threadLocal.get(); Set<Map.Entry> set = map.entrySet();
for( Map.Entry entry : set){ Object key = entry.getKey(); if( key instanceof String ){ if( ((String) key).startsWith(prefix) ){ vars.put((String)key,(T)entry.getValue()); } } } return vars; }
public static <T> T remove(String key) { Map map = (Map)threadLocal.get(); return (T)map.remove(key); }
public static void clear(String prefix) { if( prefix == null ){ return; } Map map = (Map)threadLocal.get(); Set<Map.Entry> set = map.entrySet(); List<String> removeKeys = new ArrayList<>();
for( Map.Entry entry : set ){ Object key = entry.getKey(); if( key instanceof String ){ if( ((String) key).startsWith(prefix) ){ removeKeys.add((String)key); } } } for( String key : removeKeys ){ map.remove(key); } }}


<span style="color: rgb(0, 82, 255);font-family: -apple-system-font, BlinkMacSystemFont, " helvetica="" neue",="" "pingfang="" sc",="" "hiragino="" sans="" gb",="" "microsoft="" yahei="" ui",="" yahei",="" arial,="" sans-serif;letter-spacing:="" 0.544px;text-align:="" left;caret-color:="" rgb(51,="" 51,="" 51);font-size:="" 20px;"="">总结

这个是目前第三方数据接口交互过程中常用的一些参数与使用示例,希望对大家有点帮助。


当然如果为了保证更加的安全,可以加上RSA,RSA2,AES等等加密方式,保证了数据的更加的安全,但是唯一的缺点是加密与解密比较耗费CPU的资源.


-END-
公众号后台回复【架构】或者【架构整洁】有惊喜礼包!------END------

架构师交流群

 「顶级架构师」建立了读者架构师交流群,大家可以添加小编微信进行加群

扫描添加好友邀你进架构师群,加我时注明姓名+公司+职位】


版权申明:内容来源网络,版权归原作者所有。如有侵权烦请告知,我们会立即删除并表示歉意。谢谢。

猜你还想看

【实战篇】ElasticSearch的基本概念和集群分布式底层实现
经验篇:Java高并发之设计模式,设计思想!
经验篇:Java之戳中痛点之 synchronized 深度解析
【实战篇】彻底理解cookie、session、token

长按识别图片二维码关注,订阅更多精彩

顶级架构师,企业架构、系统架构、网站架构、大规模分布式架构、高可用架构等架构讨论,以及结合互联网技术的架构调整。欢迎有想法、乐于分享的架构师交流学习


: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存