Java 生鲜电商平台 - API 接口设计之 token、timestamp、sign 具体架构与实现
链接 : www.cnblogs.com/jurendage/p/12653865.html

一:token 简介
Token:访问令牌access token, 用于接口中, 用于标识接口调用者的身份、凭证,减少用户名和密码的传输次数。一般情况下客户端(接口调用方)需要先向服务器端申请一个接口调用的账号,服务器会给出一个appId和一个key, key用于参数签名使用,注意key保存到客户端,需要做一些安全处理,防止泄露。
API Token(接口令牌): 用于访问不需要用户登录的接口,如登录、注册、一些基本数据的获取等。获取接口令牌需要拿appId、timestamp和sign来换,sign=加密(timestamp+key)
USER Token(用户令牌): 用于访问需要用户登录之后的接口,如:获取我的基本信息、保存、修改、删除等操作。获取用户令牌需要拿用户名和密码来换
二:timestamp 简介
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 简介
四:防止重复提交
注意:所有的安全措施都用上的话有时候难免太过复杂,在实际项目中需要根据自身情况作出裁剪,比如可以只使用签名机制就可以保证信息不会被篡改,或者定向提供服务的时候只用Token机制就可以了。如何裁剪,全看项目实际情况和对接口安全性的要求。
五:使用流程
接口调用方(客户端)向接口提供方(服务器)申请接口调用账号,申请成功后,接口提供方会给接口调用方一个appId和一个key参数 客户端携带参数appId、timestamp、sign去调用服务器端的API token,其中sign=加密(appId + timestamp + key) 客户端拿着api_token 去访问不需要登录就能访问的接口 当访问用户需要登录的接口时,客户端跳转到登录页面,通过用户名和密码调用登录接口,登录接口会返回一个usertoken, 客户端拿着usertoken 去访问需要登录才能访问的接口
六:示例代码
1. dependency
org.springframework.boot spring-boot-starter-data-redis redis.clients jedis 2.9.0 org.springframework.boot spring-boot-starter-web
2. RedisConfiguration
@Configurationpublic class RedisConfiguration {@Beanpublic JedisConnectionFactory jedisConnectionFactory(){return new JedisConnectionFactory();}/*** 支持存储对象* @return*/@Beanpublic RedisTemplateredisTemplate(){ RedisTemplateredisTemplate = 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 ApiResponseapiToken(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查询数据库获取appSecretAppInfo 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 ApiResponseuserToken(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. 保存TokenAppInfo 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. 保存tokenValueOperationsoperations = 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"};@Autowiredprivate TokenInterceptor tokenInterceptor;@Overridepublic void addInterceptors(InterceptorRegistry registry) {super.addInterceptors(registry);registry.addInterceptor(tokenInterceptor).addPathPatterns("/api/**").excludePathPatterns(excludePathPatterns);}}5. TokenInterceptor@Componentpublic class TokenInterceptor extends HandlerInterceptorAdapter {@Autowiredprivate RedisTemplate redisTemplate;/**** @param request* @param response* @param handler 访问的目标方法* @return* @throws Exception*/@Overridepublic 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是否存在ValueOperationstokenRedis = redisTemplate.opsForValue(); TokenInfo tokenInfo = tokenRedis.get(token);Assert.notNull(tokenInfo, "token错误");// 4. 校验签名(将所有的参数加进来,防止别人篡改参数) 所有参数看参数名升续排序拼接成url// 请求参数 + token + timestamp + nonceString 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) {ValueOperationssignRedis = 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()));elseresultString = 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) {MapparamterMap = new HashMap<>(); request.getParameterMap().forEach((key, value) -> paramterMap.put(key, value[0]));// 按照key升续排序,然后拼接参数SetkeySet = 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(Mapmap) { MapparamterMap = new HashMap<>(); map.forEach((key, value) -> paramterMap.put(key, value));// 按照key升续排序,然后拼接参数SetkeySet = 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{ /** 结果 */private ApiResult result;/** 数据 */private T data;/** 签名 */private String sign;public staticApiResponse 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 staticApiResponse 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 staticString signData(T data) { // TODO 查询keyString key = "12345678954556";MapresponseMap = 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 MapgetFields(Object data) throws IllegalAccessException, IllegalArgumentException { if (data == null) return null;Mapmap = 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{ private static final ThreadLocal@Overrideprotected MapinitialValue() { return new HashMap<>(4);}};public static MapgetThreadLocal(){ return threadLocal.get();}public staticT get(String key) { Map map = (Map)threadLocal.get();return (T)map.get(key);}public staticT 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(MapkeyValueMap) { Map map = (Map)threadLocal.get();map.putAll(keyValueMap);}public static void remove() {threadLocal.remove();}public staticMap fetchVarsByPrefix(String prefix) { Mapvars = new HashMap<>(); if( prefix == null ){return vars;}Map map = (Map)threadLocal.get();Setset = 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 staticT 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();Setset = map.entrySet(); ListremoveKeys = 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);}}}
总结
这个是目前第三方数据接口交互过程中常用的一些参数与使用示例,希望对大家有点帮助。
当然如果为了保证更加的安全,可以加上RSA,RSA2,AES等等加密方式,保证了数据的更加的安全,但是唯一的缺点是加密与解密比较耗费CPU的资源。
之前博主分享了很多资源,有的已经删除了(你懂得),如果有的你当时没有领到还想领得就可以加我微信,我在发给你,你需要得资源也可以给我说,我尽力给你找~
评论
