鉴权有很多方案,如:SpringSecurity、Shiro、拦截器、过滤器等等。如果只是对一些URL进行认证鉴权的话,我们完全没必要引入SpringSecurity或Shiro等框架,使用拦截器或过滤器就足以实现需求。本文介绍如何使用过滤器Filter实现URL签名认证鉴权。
青锋产品介绍青锋目前开源架构如下:
- springboot layui thymeleaf版本
- springboot layui jsp版本
- springboot vue ant design 前后端分离版本
- springcloud vue/react ant design 前后端分离双版本。
- springboot家谱系统
去码云搜索:青锋(名字不要打错)或者去github搜索:青锋(最新版本以gitee码云为主)
参数说明应用:appId
应用秘钥:appSecret
时间戳:timestamp
随机字符串:noncestr
签名字符串:signature
客户端使用说明- 客户端可以为前后端分离的vue或者react。可以为:Android、iOS或者uniapp等移动端应用,都可以采用认证签名授权访问接口。
- 我这边主要的业务是给第三方应用提供对接接口,因为三方不需要登录,但是又不能随便让他们请求接口,就采用签名授权的方式对外公布我们的接口。
- 客户端需要生成签名,生成规则:
- signature = Md5(timestamp noncestr appSecret body).toUpper()
- 签名的规则是时间戳 随机字符串 应用授权秘钥 请求body实体。当然规则很多,大家可以根据自己的实际情况进行设置,客户端如何加密生成签名,服务端就根据响应的方式解密即可。
传递参数说明:
例如请求地址:http://127.0.0.1:8080/qingfeng/port/findInfo
请求header:Authorization = appId="8qj0y778",timestamp="9897969594",signature="9E26BB3F746DCD982435221AA03DA400",noncestr=xxx
请求实体:{"name":"张三","type":"1"}
服务端解密
服务端通过使用Filter实现签名认证鉴权,服务端获取客户传递的timestamp noncestr body生成签名,然后比对传递的签名是否一致。
package com.qingfeng.framework.servlet;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import javax.servlet.http.HttpServletResponse;
import com.fasterxml.jackson.core.JsonEncoding;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.qingfeng.common.service.AuthenService;
import com.qingfeng.util.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
/**
* @ProjectName SignAutheFilter
* @author qingfeng
* @version 1.0.0
* SpringBoot使用拦截器实现签名认证(鉴权)
* @WebFilter注解指定要被过滤的URL
* 一个URL会被多个过滤器过滤时,还可以使用@Order(x)来指定过滤request的先后顺序,x数字越小越先过滤
* @createTime 2021/8/26 14:35
*/
@WebFilter(urlPatterns = { "/port/*","/myport/*" },filterName = "securityRequestFilter")
public class SignAutheFilter implements Filter {
private static Logger logger = LoggerFactory.getLogger(SignAutheFilter.class);
@Autowired
private AuthenService authenService;
@Value("${permittedIps}")
private String permittedIps;
// @Value("${appId}")
// private String appId;
// @Value("${appSecret}")
// private String appSecret;
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
String msg = "";
try {
String authorization = request.getHeader("Authorization");
System.out.println("getted Authorization is ---> " authorization);
String[] info = authorization.split(",");
// 获取客户端Ip
String ip = IpUtils.getIpAddr(request);
System.out.println("getted ip is ---> " ip);
/*
* 读取请求体中的数据(字符串形式)
* 注:由于同一个流不能读取多次;如果在这里读取了请求体中的数据,那么@RequestBody中就不能读取到了
* 会抛出异常并提示getReader() has already been called for this request
* 解决办法:先将读取出来的流数据存起来作为一个常量属性.然后每次读的时候,都需要先将这个属性值写入,再读出.
* 即每次获取的其实是不同的流,但是获取到的数据都是一样的.
* 这里我们借助HttpServletRequestWrapper类来实现
* 注:此方法涉及到流的读写、耗性能;
*/
MyRequestWrapper mrw = new MyRequestWrapper(request);
String bodyString = mrw.getBody();
System.out.println("getted requestbody data is ---> " bodyString);
// 获取几个相关的字符
// 由于authorization类似于
// cardid="1234554321",timestamp="9897969594",signature="a69eae32a0ec746d5f6bf9bf9771ae36"
// 这样的,所以逻辑是下面这样的
int cardidIndex = info[0].indexOf("=") 2;
String appid = info[0].substring(cardidIndex, info[0].length() - 1);
System.out.println("appid is ---> " appid);
int timestampIndex = info[1].indexOf("=") 2;
String timestamp = info[1].substring(timestampIndex, info[1].length() - 1);
int signatureIndex = info[2].indexOf("=") 2;
String signature = info[2].substring(signatureIndex, info[2].length() - 1);
//根据appid查询秘钥
PageData param = new PageData();
param.put("appId",appid);
PageData p = authenService.findInfoForAppId(param);
String tmptString = "";
if(Verify.verifyIsNotNull(p)){
System.out.println(timestamp "," p.get("appSecret") "," bodyString);
tmptString = MD5.md5Upper(timestamp "," p.get("appSecret") "," bodyString);
System.out.println(signature ":" tmptString);
}else{
msg = "应用ID(appId)不存在。";
}
// 判断该ip是否合法
boolean containIp = false;
if(permittedIps.contains("0.0.0.0")){
containIp = true;
}else{
for (String string : permittedIps.split(",")) {
if (string.equals(ip)) {
containIp = true;
break;
}
}
}
// 再判断Authorization内容是否正确,进而判断是否最终放行
boolean couldPass = containIp && tmptString.equals(signature);
if (couldPass) {
// 放行
System.out.println("====================放行================");
chain.doFilter(mrw, response);
return;
}else{
msg = "签名错误或请求IP未授权。";
}
ServletOutputStream out = response.getOutputStream();
Json json = new Json();
json.setSuccess(false);
json.setMsg("403 Forbidden:" msg);
response.setContentType("text/html;charset=utf-8");
ObjectMapper objMapper = new ObjectMapper();
JsonGenerator jsonGenerator = objMapper.getJsonFactory()
.createJsonGenerator(response.getOutputStream(),
JsonEncoding.UTF8);
jsonGenerator.writeObject(json);
jsonGenerator.flush();
jsonGenerator.close();
} catch (Exception e) {
logger.error(e.getMessage(), e);
Json json = new Json();
json.setSuccess(false);
json.setMsg("403 Forbidden:" e.getMessage());
response.setContentType("text/html;charset=utf-8");
ObjectMapper objMapper = new ObjectMapper();
JsonGenerator jsonGenerator = objMapper.getJsonFactory()
.createJsonGenerator(response.getOutputStream(),
JsonEncoding.UTF8);
jsonGenerator.writeObject(json);
jsonGenerator.flush();
jsonGenerator.close();
}
}
@Override
public void destroy() {
}
}
/**
* 辅助类 ---> 变相使得可以多次通过(不同)流读取相同数据
*
* @author qingfeng
* @createTime 2021/8/26 14:35
*/
class MyRequestWrapper extends HttpServletRequestWrapper {
private final String body;
public String getBody() {
return body;
}
public MyRequestWrapper(final HttpServletRequest request) throws IOException {
super(request);
StringBuilder sb = new StringBuilder();
String line;
BufferedReader reader = request.getReader();
while ((line = reader.readLine()) != null) {
sb.append(line);
}
body = sb.toString();
}
@Override
public ServletInputStream getInputStream() throws IOException {
final ByteArrayInputStream bais = new ByteArrayInputStream(body.getBytes());
return new ServletInputStream() {
/*
* 重写ServletInputStream的父类InputStream的方法
*/
@Override
public int read() throws IOException {
return bais.read();
}
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener listener) {
}
};
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(this.getInputStream()));
}
}
如果对请求的IP进行验证,可以在配置文件中加入:
# ip白名单(多个使用逗号分隔)
permitted-ips = 169.254.205.177, 169.254.133.33, 10.8.109.31, 0:0:0:0:0:0:0:1
在验证过滤器中进行验证,代码如下(业务代码可以根据自己的情况进行设置):
//注入id集合
@Value("${permitted-ips}")
private String[] permittedIps;
// 获取客户端ip
String ip = IpUtil.getIpAddr(request);
logger.info("getted ip is ---> " ip);
// 判断该ip是否合法
boolean containIp = false;
for (String string : permittedIps) {
if (string.equals(ip)) {
containIp = true;
break;
}
}
请求参数:access_key、nonce、timestamp、业务参数列表...、sign签名
签名:access_key nonce timestamp 业务参数列表 secret_key,业务参数列表按key自然排序,上各参数以&拼接后进行md5加密,md5转为大写作为sign签名
access_key:用户身份标识,前后端约定
nonce:随机字符串,请求唯一标识,每个请求的nonce均被缓存在服务端(redis),并设置10分钟有效期,防止缓存不断累积
timestamp:当前时间戳,用于判断请求与当前时间差,与nonce对应,设置10分钟有效期,超过时间请求失效,既防止请求长期有效又允许客户端和服务端之间存在10分钟时间差
secret_key:密钥,用于生成sign签名,前后端约定,不能泄漏
服务端:拦截请求(拦截器或AOP均可,配合注解拦截指定接口,便于区分拦截不同接口),根据参数重构md5签名,依次检查access_key、nonce、timestamp、sign的一致性和准确性
简化,不缓存可不携带nonce,将timestamp有效期设置在2~3分钟
,