背景
Leader:现在组内新建系统的API网关,你设计时要充分考虑接口的安全,防止被篡改和暴力攻击。
Coder:好的,安全方面我们是有充分考虑的,通过验签防止入参被篡改,结合时间戳防止暴力攻击。
知识储备
基于Token的鉴权方式是无状态的,服务端不再需要存储Session信息,是分布式系统的主要鉴权方案。
1、开发者认证
开发者认证也就是登录校验,网关校验用户上送的appId和appSecret,如果跟redis中保存的一致,则生成access_token和refresh_token并返回给用户,用户暂存该token,访问其它API接口时需要上送access_token。
2、API接口公共请求参数
参数 | 参数名 | 备注 |
---|---|---|
appId | 应用ID | 标识调用方的身份 |
access_token | token | 示例是通过UUID生成 |
sign | 签名 | |
timestamp | 时间戳 | 用于防御重放攻击 |
nonce | 随机数 | 用于防御重放攻击 |
3、重放攻击
重放攻击是指攻击者发送目的主机已接收过的数据,以达到欺骗系统的目的,主要用于身份认证过程,破坏认证的正确性。
我们主要通过验证时间戳和随机数来防御重放攻击。
1)验证timestamp
判断时间戳timestamp是否超过nonceTimeout秒,超时则判别为重放攻击。
2)验证nonce
验证随机数nonce在redis中是否存在,如果存在,则判别为重放攻击,否则将nonce记录在redis中(示例中key的生成规则是:"NS"+appId+nonce),失效时间为nonceTimeout秒。
4、鉴权
验证access_token在redis中是否存在,若已过期,则无权访问API接口。用户可以刷新token,只要refresh_token在redis中存在,则网关重新生成access_token和refresh_token。通常refresh_token保存的时间较access_token久。
5、验签
1)将业务参数和timestamp、nonce、appid按键值对字典排序后通过&拼接,例如:appid=appid&key1=value1&key2=value2&nonce=random×atmp=1629777776799,得到stringA;
2)stringA再拼接appsecret,例如:stringA&appsecret=appsecret,得到stringB;
3)最后将stringB通过md5加密并转大写,即uppercase(md5(stringB)),得到签名sign,跟用户请求API接口时上送的签名对比,如果相同,则验签通过;也可以采用SHA256WithRSA签名算法,调用方生成一对RSA公私钥,调用方用私钥加签,服务方用公钥验签。
实操
我们通过实现过滤器接口完成API接口鉴权验签动作。
过滤器是在请求进入Tomcat容器后,但请求进入servlet之前进行预处理的。请求结束返回也是在servlet处理完后,返回给前端之前。
进入servlet之前,主要是两个参数:ServletRequest,ServletResponse,我们可以通过ServletRequest得到HttpServletRequest,此时就可以对web服务器管理的所有web资源:例如Jsp, Servlet, 静态图片文件或静态html文件等进行拦截,从而实现一些特殊的功能。例如实现URL级别的权限访问控制、过滤敏感词汇、压缩响应信息、字符集统一等一些高级功能。它主要用于对用户请求进行预处理,也可以对HttpServletResponse进行后处理。使用Filter的完整流程:Filter对用户请求进行预处理,接着将请求交给Servlet进行处理并生成响应,最后Filter再对服务器响应进行后处理。它是随你的web应用启动而启动的,只初始化一次,以后就可以拦截相关请求,只有当你的web应用停止或重新部署的时候才销毁。(每次热部署后,都会销毁)。
@WebFilter(urlPatterns = "/api/*")
public class PreFilter implements Filter {
private static Logger logger = Logger.getLogger("PreFilter");
private Long nonceTimeout = 300l;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
logger.info("进入过滤器处理");
HttpServletRequest request = (HttpServletRequest) servletRequest;
RedisUtil redisUtil = new RedisUtil(stringRedisTemplate);
ServletOutputStream out = servletResponse.getOutputStream();
String outString = null;
try {
String appId = request.getHeader("appId");
String accessToken = request.getHeader("access_token");
String sign = request.getHeader("sign");
String timestamp = request.getHeader("timestamp");
String nonce = request.getHeader("nonce");
Map<String, String> paramMap = new HashMap<>();
InputStream in = request.getInputStream();
String body = StreamUtils.copyToString(in, Charset.forName("UTF-8"));
/*
参数来自请求body
*/
JSONObject json = JSONObject.parseObject(body);
if (json != null && !json.isEmpty()) {
logger.info("body = " + json);
for (String key : json.keySet()) {
paramMap.put(key, json.getString(key));
}
}
/*
参数来自请求url的QueryString
*/
String query = request.getQueryString();
if (query != null) {
logger.info("queryString = " + URLDecoder.decode(query, "UTF-8"));
String[] arr = query.split("&");
for (String pair : arr) {
String[] ele = pair.split("=");
if (ele.length == 2) {
paramMap.put(ele[0], ele[1]);
}
}
}
paramMap.put("appid", appId);
paramMap.put("timestamp", timestamp);
paramMap.put("nonce", nonce);
String stringA = AuthUtil.concatParam(paramMap);
Long now = System.currentTimeMillis();
/*
判断时间戳是否超过nonceTimeout秒,超时则判别为重放功击
*/
if (timestamp != null && (now - Long.parseLong(timestamp) < nonceTimeout * 1000)) {
/*
验证nonce在redis中是否存在,如果存在,则判别为重放功击,否则将nonce记录在redis中(key为:"NS"+appId+nonce),失效时间为nonceTimeout秒
*/
if (redisUtil.exists("NS" + appId + nonce)) {
outString = String.format("{\"code\": 429, \"message\": \"Too Many Requests\"}");
out.write(outString.getBytes());
out.flush();
} else {
redisUtil.set("NS" + appId + nonce, nonce, nonceTimeout);
/*
验证access_token是否存在
*/
if (redisUtil.exists(accessToken) && redisUtil.exists(appId)) {
String redis_appid = (String) redisUtil.get(accessToken);
String redis_appsecret = (String) redisUtil.get(redis_appid);
String _sign = AuthUtil.getSign(stringA, redis_appsecret);
/*
验证签名是否通过,若通过,则开发者认证也会验证通过
*/
if (_sign.equals(sign)) {
filterChain.doFilter(servletRequest, servletResponse);
} else {
outString = String.format("{\"code\": 403, \"message\": \"Forbidden\"}");
out.write(outString.getBytes());
out.flush();
}
} else {
outString = String.format("{\"code\": 401, \"message\": \"Unauthorized\"}");
out.write(outString.getBytes());
out.flush();
}
}
} else {
outString = String.format("{\"code\": 400, \"message\": \"Bad Request\"}");
out.write(outString.getBytes());
out.flush();
}
} catch (Exception e) {
e.printStackTrace();
outString = String.format("{\"code\": 500, \"message\": \"Internal Server Error\"}");
out.write(outString.getBytes());
out.flush();
}
}
}
需要引入以下依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>
调用登录接口获取token后,再调用 http://127.0.0.1:8080/api/sayhello?userNo=2,返回结果正常。
待token失效后,再请求 http://127.0.0.1:8080/api/sayhello?userNo=2,返回结果是未授权。
在Postman的Pre-request Script中将nonce写死为123456,首次请求成功,再次请求则报错“Too Many Requests”,即多次nonce送值相同时,识别出重放攻击。
在Postman的Pre-request Script中将签名sign的值变更,请求报错“Forbidden”,即验证签名不通过。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。