一、使用背景
过去对于接口的验证我一般都是直接在登录时为用户发放token,用户在随后的操作中携带了token则允许请求。
但是这样的验证方式存在有一定的问题,如果token被泄露被他人获取,那么就会有非法请求的风险。其他人可以使用这个token自行调用接口进行请求,传入非法参数甚至进行注入攻击等,可能会造成严重的问题。
即存在以下安全问题:
- 请求身份是否合法
- 请求参数是否被篡改
为了防止这种情况的发生,我们验证接收到的数据与客户端发送的数据一致,且让接口只能被客户端请求。
二、实现方案
既然要实现接口只能被客户端请求,那么我们不难想到可以与客户端达成某些约定,让客户端按一定的规则发送请求。
只需要服务端与客户端约定一套密钥,客户端在发送请求时拼接上私钥后使用单向加密算法进行加密,服务端收到后使用相同的私钥和加密算法进行加密后验证是否与前端客户端传递的值相同。
这样的方案可以使得用户即使拿到了token没有私钥的话加密后的数据与服务端加密后的肯定不相同而无法通过验证。篡改参数同样会产生相同的问题,从而保证了接口的安全性。
三、具体流程
- 按照请求参数名的字母升序排列非空请求参数(包含accesskey),使用URL键值对的格式(即key1=value1&key2=value2…)拼接成字符串A;
- 在字符串A最后拼接上secretkey得到字符串B;
- 对B进行MD5运算,并将得到的字符串所有字符转换为大写,得到sign值;
- 携带参数accesskey和sign进行请求
四、优化
以上方式虽然解决了非法用户请求和请求参数被篡改的问题,但是还存在着重复使用请求参数伪造二次请求的隐患。
为了解决这个问题我们不难想到请求时生成一个唯一的标识符,那么服务端在接收到请求之后只需要验证是否已经接受过改请求的标识即可。
不过以上方式还存在一个问题就是服务端要保存那么多的请求标识并不现实,所以我们可以设置一个过期时间,过期了就将标识删除。并让请求的时候携带一个时间戳,超过时间的请求直接打回。
五、代码实现
假设请求接口:test?param1=hello¶m2=world
那么前端需要发送请求:test?accesskey=xxx¶m1=hello¶m2=world&nonce=随机唯一标识×tamp=当前时间戳&sign=xxx
sign = MD5(“accesskey=xxx&nonce=随机唯一标识¶m1=hello¶m2=world&secretkey=xxx×tamp=当前时间戳”).toUpperCase()
注意请求接口中的参数可以不按顺序,而用于生成sign的字符串中的各个参数必须按照一定的顺序(与后端约定)
后端验签:
@Component
public class ApiVerifyUtil {
public static final String SECRET_KEY = "secretkey";
public static final String ACCESS_KEY = "accesskey";
public static final String TIMESTAMP_KEY = "timestamp";
public static final String NONCE_KEY = "nonce";
public static final String SIGN_KEY = "sign";
private static final HashMap<String, String> KEY_PAIR;
static {
KEY_PAIR = new HashMap<>();
KEY_PAIR.put("app1", "password1"); // 为客户端分配的密钥
KEY_PAIR.put("app2", "password2");
}
@Autowired
private RedisTemplate<String, String> redisTemplateBean;
private static RedisTemplate<String, String> redisTemplate;
@PostConstruct
public void init() {
redisTemplate = redisTemplateBean;
}
public static final Integer OK = 0;
public static final Integer PARAMS_ERROR = 1;
public static final Integer LACK_ACCESSKEY = 2;
public static final Integer ACCESSKEY_INVALID = 3;
public static final Integer LACK_NONCE = 4;
public static final Integer LACK_TIMESTAMP = 5;
public static final Integer LACK_SIGN = 6;
public static final Integer REQUEST_TIMEOUT = 7;
public static final Integer REQUEST_REPEATED = 8;
public static final Integer REQUEST_INVALID = 9;
// 超时时间(ms)
public static final long TIMEOUT = 1000 * 60 * 15;
public static final String AND = "&";
public static final String EQUALS = "=";
public static Integer verify(HashMap<String, String> params) {
if (params == null || params.isEmpty()) {
return PARAMS_ERROR;
}
// accessKey为空或不合法(即不存在对应的密钥)或请求参数不全则直接打回
String accessKey, secretKey, timestamp, nonce, sign;
if ((accessKey = params.get(ACCESS_KEY)) == null) {
return LACK_ACCESSKEY;
}
if ((secretKey = KEY_PAIR.get(accessKey)) == null) {
return ACCESSKEY_INVALID;
}
if ((timestamp = params.get(TIMESTAMP_KEY)) == null) {
return LACK_TIMESTAMP;
} else if (Long.parseLong(timestamp) - System.currentTimeMillis() > TIMEOUT) {
// 超过15分钟
return REQUEST_TIMEOUT;
}
if ((nonce = params.get(NONCE_KEY)) == null) {
return LACK_NONCE;
} else {
Set<String> nonceSet = redisTemplate.opsForSet().members("nonce");
if (nonceSet != null && nonceSet.contains(nonce)) {
return REQUEST_REPEATED;
}
}
if ((sign = params.get(SIGN_KEY)) == null) {
return LACK_SIGN;
}
params.remove(SIGN_KEY);
// 加密需要拼接上密钥
params.put(SECRET_KEY, secretKey);
ArrayList<String> keyList = new ArrayList<>(params.keySet());
// 按顺序构造
Collections.sort(keyList);
StringBuilder sb = new StringBuilder();
for (String key : keyList) {
sb.append(key).append("=").append(params.get(key)).append("&");
}
String newSign = sb.toString();
newSign = MD5.create().digestHex16(newSign.substring(0, newSign.length() - 1).toUpperCase());
if (newSign.equals(sign)) {
redisTemplate.opsForSet().add("nonce", nonce);
return OK;
}
return REQUEST_INVALID;
}
public static HashMap<String, String> getParams(HttpServletRequest httpServletRequest) {
String queryString = httpServletRequest.getQueryString();
String[] split = queryString.split(AND);
HashMap<String, String> params = new HashMap<>();
for (String param : split) {
params.put(param.split(EQUALS)[0], param.split(EQUALS)[1]);
}
return params;
}
public static HashMap<String, String> getParams(String queryString) {
String[] split = queryString.split(AND);
HashMap<String, String> params = new HashMap<>();
for (String param : split) {
params.put(param.split(EQUALS)[0], param.split(EQUALS)[1]);
}
return params;
}
}
六、后续优化
- 加入接口被调用的阈值限制,对接口访问频率设置一定阈值,对超过阈值的请求进行屏蔽及预警。
- 白名单机制:指定一些可以访问我们暴露接口的域名,不在白名单里面的域名发过来的请求,直接拒绝。