对外提供接口签名sign计算、验签实例
一、简述
1、对外提供接口,为保护参数不被修改,保护数据的安全性,需要在客户端调用时接口添加签名、服务端对接口进行验签;
2、本实例中authKey是不参与通信,整个过程中authKey是不参与通信的,所以只要保证authKey不泄露,即使请求参数被其他原因泄露,请求也不会被伪造。
二、签名设计及计算过程
1.己方系统提供
clientId: 由于己方系统可能会对外不同角色方提供接口(第三方系统app、第三方系统PC、定时任务调用等),所以针对不同角色类型的外部系统做了配置化处理,
每个clientId对应某一外部角色类型方,并在后台库中存储了该clientId对应的authKey、authorization及其具有的权限信息,配置信息写入redis中,后期从redis中获取这些信息;
authKey: 某一clientId对应的authKey,专门进行加签、验签操作,为接口安全考虑,不参与通信;
authorization: 接口header中携带的token,因为系统中接口权限验证需要校验token,所以为了匹配整个系统中的访问权限验证需要这个参数;
2.url参数如下:
signTimestamp 签名失效时间,时间戳,单位毫秒,13位【必填】
sign 签名
3.签名的计算规则如下:
a、对所有入参【注意:参数值非空的才会参与签名的计算】clientId、authKey、authorization、signTimestamp 按照字段名的 ASCII 码从小到大排序(字典序)后,
使用 URL 键值对的格式(即 key1=value1&key2=value2…)拼接成字符串 str;
b、对 str 进行 md5 运算,再将得到的字符串所有字符转换为大写,得到 sign 值
4.下面定义了一段生成 sign 字符串的示范过程:
1)、POST请求
URL:http://localhost:7777/xxxxx-service/testResource/test?param3=456
header: key :Authorization value :201295823105949696
body:
{
“param1”:“参数1”,
“param2”:“参数2”,
“param5”:[“www”,“wfawefwe”,“jagjergre”]
}
2)、获取当前时间戳
signTimestamp:1615458960605
3)、按照a规则,排序后的字符串 str:
authKey=303e6bd7-472d-11ea-a802-fa163ecd8c7a&authorization=201295823105949696¶m1=参数1¶m2=参数2¶m3=456¶m5=[“哈哈哈”,“呜呜呜”,“急急急”]&signTimestamp=1615458960605
4)、按照b规则得到签名 sign:
DigestUtils.md5DigestAsHex(“authKey=303e6bd7-472d-11ea-a802-fa163ecd8c7a&authorization=201295823105949696¶m1=参数1¶m2=参数2¶m3=456¶m5=[“哈哈哈”,“呜呜呜”,“急急急”]&signTimestamp=1615458960605”).toUpperCase()
输出:“EBD4B596A4DDDFB6ACBCFAF3E5C6BE6A”
5)、最终请求如下:
URL:http://localhost:7777/xxxxx-service/testResource/test?param3=456&signTimestamp=1615458960605&sign=EBD4B596A4DDDFB6ACBCFAF3E5C6BE6A
header: key :Authorization value :201295823105949696
body:
{
“param1”:“参数1”,
“param2”:“参数2”,
“param5”:[“www”,“wfawefwe”,“jagjergre”]
}
三、UML图
四、代码实例
1、生成签名
String signTimestamp = SignUtil.getSignTimestamp();
String getTestDataUrlStr = "/xxxxx-service/roomTest/getTestData?signTimestamp="+signTimestamp+"&sign="+SignUtil.getSign(token,authKey,signTimestamp);
/**
* @author
* @Description 生成签名
* @Date
*/
public class SignUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(SignUtil.class);
//生成签名;
public static String getSign(String token,String authKey,String signTimestamp){
//获取签名;
SortedMap<String, String> allParams = new TreeMap<>();
allParams.put("authorization",token);
allParams.put("authKey",authKey);
//String signTimestampStr = signTimestamp;
allParams.put("signTimestamp",signTimestamp);
StringBuilder stringBuilder = new StringBuilder(150);
for (Map.Entry<String, String> entry : allParams.entrySet()) {
if (!StringUtils.isEmpty(entry.getValue())) {
stringBuilder.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
}
}
String paramsStr = stringBuilder.toString();
if (StringUtils.isNotBlank(paramsStr)) {
paramsStr = paramsStr.substring(0, paramsStr.length() - 1);
}
return DigestUtils.md5DigestAsHex(paramsStr.getBytes()).toUpperCase();
}
public static String getSignTimestamp(){
//取得指定时区的时间(东八区)
TimeZone zone = TimeZone.getTimeZone("GMT-8:00");
Calendar cal = Calendar.getInstance(zone);
long currentSecond = cal.getTime().getTime();
String currentTimestamp = String.valueOf(currentSecond);
return currentTimestamp;
}
2、网关服务 过滤器校验token是否存在及有效性
/**
* 请求认证过滤
*/
@Component
@Slf4j
public class AuthFilter implements GlobalFilter, Ordered {
/**
* 基于LRU暂存token对应的用户信息和权限
*/
public static final ConcurrentLinkedHashMap<String, TokenValueInMap> cacheMap =
new ConcurrentLinkedHashMap.Builder<String, TokenValueInMap>()
.maximumWeightedCapacity(20000)
.weigher(Weighers.singleton())
.build();
/**
* 基于记录无效token,防止无效token多次,以提升系统性能
*/
private static final ConcurrentLinkedHashMap<String, Long> cacheInValidToken =
new ConcurrentLinkedHashMap.Builder<String, Long>()
.maximumWeightedCapacity(2000)
.weigher(Weighers.singleton())
.build();
@Autowired
RedisService<HashMap, String> redisService;
@Autowired
RedisProperties redisProperties;
private static ThreadPoolExecutor threadPoolExecutorForSendToRedis;
static {
threadPoolExecutorForSendToRedis = new ThreadPoolExecutor(
//线程池维护线程的最少数量
100,
//线程池维护线程的最大数量
10000,
//线程池维护线程所允许的空闲时间
120, TimeUnit.SECONDS,
//线程池所使用的缓冲队列
new ArrayBlockingQueue<Runnable>(500),
//加入失败,则在调用的主线程上执行
new ThreadPoolExecutor.CallerRunsPolicy());
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
HttpResult httpResult = HttpStatus.Unauthorized;
//备注:针对白名单请求,如果具有有效的token,则将有效的token(即:自身能够正常解析 + redis中未失效)也一并解析
try {
//从请求头部获取token
String token = getTokenFromRequest(request);
//是否是匿名请求,即:请求路径中是否含有unAuth
boolean isHasUnAuth = false;
//对于未携带token的请求,需要判断是否能够直接放行,通过PassConfig中的白名单进行控制
AntPathMatcher antPathMatcher = new AntPathMatcher();
long count = PassConfig.WITELIST.stream().filter(pattern -> antPathMatcher.match(pattern, request.getPath().value())).count();
if (count > 0) {
isHasUnAuth = true;
if (StringUtils.isBlank(token)) {
//放行前校验请求中的签名
this.preChainFilterProcess(request, "");
//放行 未携带token的unAuth请求
return chain.filter(exchange);
}
}
//访问需要授权的接口时未携带token
if (!isHasUnAuth && StringUtils.isBlank(token)) {
throw new Exception("请先登录系统");
}
String authorities = null;
Map<String, Object> userMap = null;
//获取token对应的用户信息
// tokenValueInMap为null,表示token无效,需要重新登录
TokenValueInMap tokenValueInMap = getTokenValue(token);
if (null != tokenValueInMap) {
authorities = tokenValueInMap.tokenValueInRedis.getAuthority();
userMap = tokenValueInMap.tokenValueInRedis.getClaims();
}
//非白名单内的请求,且携带自身解析有效的token,但是token在redis中不存在,直接抛出异常,需要用户重新登录
if (!isHasUnAuth && null == tokenValueInMap) {
throw new Exception("登录已超时, 请重新登录");
}
//白名单内的请求,且携带自身解析有效的token,但是token在redis中不存在,可以放行
if (isHasUnAuth && null == tokenValueInMap) {
this.preChainFilterProcess(request, "");
return chain.filter(exchange);
}
//token自动续期
final Map<String, Object> userMapTmp = userMap;
threadPoolExecutorForSendToRedis.execute(() -> {
this.resetTokenExpireTime(token, userMapTmp);
});
ServerHttpRequest httpRequest = request.mutate()
.header(User.CONTEXT_USER_ID, userMap.get(JwtTokenUtils.USERID).toString())
.header(User.CONTEXT_USER_NAME, userMap.get(JwtTokenUtils.USERNAME).toString())
.header(User.CONTEXT_USER_LOGIN_TYPE, userMap.get(JwtTokenUtils.USERLOGINTYPE).toString())
.header(User.CONTEXT_CLIENT_ID, userMap.get(JwtTokenUtils.CLIENTID).toString())
.header(Company.CONTEXT_COMPANY_ID, null == userMap.get(JwtTokenUtils.COMPANYID) ? ""
: userMap.get(JwtTokenUtils.COMPANYID).toString())
.header(Company.CONTEXT_COMPANY_NAME, null == userMap.get(JwtTokenUtils.COMPANYNAME) ? ""
: URLEncoder.encode(userMap.get(JwtTokenUtils.COMPANYNAME).toString(), "UTF-8"))
.header(Dept.CONTEXT_DEPT_ID, null == userMap.get(JwtTokenUtils.DEPTID) ? ""
: userMap.get(JwtTokenUtils.DEPTID).toString())
.header(Dept.CONTEXT_DEPT_NAME, null == userMap.get(JwtTokenUtils.DEPTNAME) ? ""
: URLEncoder.encode(userMap.get(JwtTokenUtils.DEPTNAME).toString(), "UTF-8"))
.header(User.CONTEXT_USER_AUTHORITIES, authorities)
.header(User.CONTEXT_INVITATION_CODE, null == userMap.get(JwtTokenUtils.INVITATIONCODE) ? ""
: userMap.get(JwtTokenUtils.INVITATIONCODE).toString()) //邀请码
.header(User.CONTEXT_USER_TOKEN, token).build();
this.preChainFilterProcess(request, userMap.get(JwtTokenUtils.USERLOGINTYPE).toString());
return chain.filter(exchange.mutate().request(httpRequest).build());
} catch (BaseException baseException) {
if (!BaseException.DEFAULT_CODE.equals(baseException.getCode())) {
httpResult.setStatus(baseException.getCode());
}
return processException(httpResult, baseException, exchange);
} catch (Exception ex) {
return processException(httpResult, ex, exchange);
}
}
/**
* 认证时的异常处理
*
* @param httpResult
* @param ex
* @param response
* @return
*/
private Mono<Void> processException(HttpResult httpResult, Exception ex, ServerWebExchange exchange) {
ServerHttpResponse response = exchange.getResponse();
httpResult.setMessage(ex.getMessage());
String result = JSONObject.toJSON(httpResult).toString();
response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
response.setStatusCode(org.springframework.http.HttpStatus.OK);
return exchange.getResponse()
.writeWith(Flux.just(exchange.getResponse().bufferFactory().wrap(result.getBytes())));
}
/**
* 过滤器执行的优先级,值越小优先级越高
*
* @return
*/
@Override
public int getOrder() {
return -5;
}
/**
* 从请求总获取token
*
* @param request
* @return
*/
private String getTokenFromRequest(ServerHttpRequest request) {
String token = request.getHeaders().getFirst(JwtTokenUtils.HEADER_AUTH);
//由于websocket比较特殊,涉及http upgrade的过程
//无法在header中携带token,只能在请求路径中携带token
//如果请求以"/websocket-server/chat/"开头,就认为是websocket类型的请求,token放在请求的最后,通过"/"截取,以获取token
if (request.getURI().getPath().startsWith("/websocket-server/chat/")) {
token = request.getURI().getPath().substring(request.getURI().getPath().lastIndexOf("/") + 1);
}
return token;
}
/**
* 自动续期
* 重新设置token的过期时间
*
* @param token
* @param userMap
* @return
*/
private void resetTokenExpireTime(String token, Map<String, Object> userMap) {
String userId = userMap.get(JwtTokenUtils.USERID).toString();
String userLoginType = userMap.get(JwtTokenUtils.USERLOGINTYPE).toString();
//为了系统安全,通过员工超级密码生成的token不会自动续期,默认12小时后过期
if (UserLoginTypeConstants.EmployeeLoginPCBySuperPassword.equals(userLoginType)) {
return;
}
if (LoginDeviceTypeConstants.APP.equals(LoginRelatedHelper.getLoginDeviceType(userLoginType))) {
//登录方式为app,自动续期12天
boolean isOK = redisService.expire(token, redisProperties.getAppTokenExpireMilliSeconds());
if (isOK) { //从性能上考虑,只有上一步执行成功,才会执行下一步
redisService.expire(userLoginType + userId, redisProperties.getAppTokenExpireMilliSeconds());
}
} else if (LoginDeviceTypeConstants.PC.equals(LoginRelatedHelper.getLoginDeviceType(userLoginType))) {
//登录方式为pc或第三方模拟登录,自动续期8小时ø
boolean isOK = redisService.expire(token, redisProperties.getPcTokenExpireMilliSeconds());
if (isOK) { //从性能上考虑,只有上一步执行成功,才会执行下一步
redisService.expire(userLoginType + userId, redisProperties.getPcTokenExpireMilliSeconds());
}
} else {
//针对虚拟用户的调用,由于token是不失效的,所以不用续期
}
}
/**
* 执行chain.filter之前进行的预处理
*
* @param request
* @param userLoginType
*/
private void preChainFilterProcess(ServerHttpRequest request, String userLoginType) {
this.veritySign(request, userLoginType);
}
/**
* 校验请求中的签名
*
* @param request
* @param userLoginType
*/
private void veritySign(ServerHttpRequest request, String userLoginType) {
userLoginType = null == userLoginType ? "" : userLoginType;
//暂时认定通过第三方系统 虚拟用户登录 的请求中必须要携带sign和signTimestamp
//【待开发】等前端改造完后再开放对所有用户请求的签名校验
if (userLoginType.startsWith(UserTypeConstants.VirtualUser)) {
String sign = request.getQueryParams().getFirst("sign");
String signTimestamp = request.getQueryParams().getFirst("signTimestamp");
if (StringUtils.isBlank(sign)) {
throw new BaseException(HttpStatus.SignError.getStatus(), "请求中的签名 sign 不能为空");
}
if (StringUtils.isBlank(signTimestamp)) {
throw new BaseException(HttpStatus.SignError.getStatus(), "请求中签名的时间戳 signTimestamp 不能为空");
}
}
}
/**
* 先从缓存中获取token对应的用户和权限,如果存在 且 没有过期,则直接返回,否则需要到redis中去获取,再写入map
* 由于这里对token进行了缓存,所以在分布式场景中,客户退出登录后,token还在缓存中,这样就无法做到立即实现单点控制,但是考虑到服务器的tps,目前暂时采取这种折中的方式
*
* @param token
* @return
*/
private TokenValueInMap getTokenValue(String token) {
// 先从缓存中获取token对应的权限,如果存在 且 没有过期,则直接返回,否则需要到redis中去获取,再写入map
TokenValueInMap authorityInMapValue = cacheMap.get(token);
// 60*60*8 * 1000 = 28800 * 1000 = 28800000 (map中的值8小时后过期)
if (null == authorityInMapValue || (authorityInMapValue.timeMillis + 28800000) < System.currentTimeMillis()) {
//如果判定是无效token,则直接返回
if (cacheInValidToken.containsKey(token)) {
Long timeMillis = cacheInValidToken.get(token);
// 60*60*24 * 1000 = 86400 * 1000 = 86400000 (24小时)
if (timeMillis + 86400000 > System.currentTimeMillis()) {
return null;
}
}
String tokenValueInRedis = redisService.getValue(token);
if (StringUtils.isNotBlank(tokenValueInRedis)) {
TokenValueInRedis tokenValue = JSON.parseObject(tokenValueInRedis, new TypeReference<TokenValueInRedis>() {
});
authorityInMapValue = new TokenValueInMap(tokenValue, System.currentTimeMillis());
cacheMap.put(token, authorityInMapValue);
} else {
cacheInValidToken.put(token, System.currentTimeMillis());
}
}
return authorityInMapValue;
}
}
3.authclient服务拦截器进行验签
/**
* 校验签名拦截器
* 放在所有拦截器的最前面
*/
@Slf4j
@Component
@Setter
public class VerifySignatureInterceptor extends HandlerInterceptorAdapter {
/**
* 由外部实例化VerifySignatureInterceptor对象后传入
*/
private RedisService redisService;
public static final String Authorization = "Authorization";
public static final String AuthKey = "authKey";
/**
* 签名校验
*
* @param request
* @param response
* @param handler
* @return
* @throws IOException
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
try {
//如果是访问内部资源文件,则直接放行
if (!(handler instanceof HandlerMethod)) {
return true;
}
if (!(request instanceof RequestWrapper)) {
return true;
}
RequestWrapper requestWrapper = (RequestWrapper) request;
//请求中包含签名参数 sign 和 signTimestamp 时,才会校验签名的正确性
SortedMap<String, String> urlParams = HttpUtils.getUrlParams(requestWrapper);
String sign = SignUtils.getSign(urlParams);
String signTimestamp = SignUtils.getSignTimestamp(urlParams);
if (!StringUtils.isEmpty(sign) && !StringUtils.isEmpty(signTimestamp)) {
//从请求中获取所有参数,并组装成验证签名所需要的map
SortedMap<String, String> allParams = HttpUtils.getAllParams(requestWrapper);
this.verifyRequestBySign(requestWrapper, allParams);
}
return true;
} catch (Exception ex) {
ex.printStackTrace();
// this.returnJson(response, ex);
CommonExceptionHandler.sendErrorByResponse(response, ex);
return false;
}
}
/**
* 根据签名校验请求的有效性,根据所有入参进行签名计算
*
* @param allParams
*/
private void verifyRequestBySign(HttpServletRequest request, SortedMap<String, String> allParams) {
String sign = SignUtils.getSign(allParams);
String signTimestamp = SignUtils.getSignTimestamp(allParams);
if (!StringUtils.isEmpty(sign) && !StringUtils.isEmpty(signTimestamp)) {
// 写入token
String token = request.getHeader(Authorization);
allParams.put(Authorization.toLowerCase(), null == token ? "" : token);
//从redis中获取签名用的key,并放入SortedMap中
boolean isHasClientID = false;
String clientId = request.getHeader(User.CONTEXT_CLIENT_ID);
if (StringUtils.isEmpty(clientId)) {
clientId = allParams.get("clientId");
}
if (!StringUtils.isEmpty(clientId)) {
Object authClientObj = this.redisService.hashGet(AuthConstants.PREFIX_AUTH_CLIENT, clientId);
if (!StringUtils.isEmpty(authClientObj)) {
JSONObject jsonObject = JSON.parseObject(authClientObj.toString());
allParams.put(AuthKey, jsonObject.getString(AuthKey));
isHasClientID = true;
}
}
if (!isHasClientID) {
throw new BaseException(HttpStatus.SignError.getStatus(), "请求参数中或token中缺少ClientID的值");
}
log.info("==allParams====allParams={}"+JSON.toJSONString(allParams));
// 对参数进行签名验证
boolean isSigned = SignUtils.verifySign(allParams, 30);
if (!isSigned) {
throw new BaseException(HttpStatus.SignError.getStatus(), "请求的签名有误,可能是由于请求过期或参数被篡改");
}
}
}
}
/**
* 签名工具类
*/
@Slf4j
public class SignUtils {
/**
* 根据入参校验
*
* @param params
* @return
*/
public static boolean verifySign(SortedMap<String, String> params, int timeoutSecond) {
String urlSign = SignUtils.getSign(params);
String signTimestamp = SignUtils.getSignTimestamp(params);
//如果请求中没有签名和时间戳,则直接放行,用于开发内部调测,或各环境中服务间的RPC调用
if (StringUtils.isBlank(urlSign) || StringUtils.isBlank(signTimestamp)) {
return true;
}
log.info("请求中的签名 : {}", urlSign);
// 把参数加密
String paramsSign = getParamsSign(params);
log.info("==paramsSign====paramsSign={}" + JSON.toJSONString(paramsSign));
log.info("计算后的签名 : {}", paramsSign);
return !StringUtils.isBlank(paramsSign)
&& urlSign.equals(paramsSign)
&& checkRequestUrlIsValid(params, timeoutSecond);
}
/**
* 获取签名数据
*
* @param params
* @return
*/
public static String getSign(SortedMap<String, String> params) {
return params.get("sign");
}
/**
* 获取签名的时间戳
*
* @param params
* @return
*/
public static String getSignTimestamp(SortedMap<String, String> params) {
return params.get("signTimestamp");
}
/**
* 将请求的参数进行MD5加密
*
* @param params
* @return
*/
public static String getParamsSign(SortedMap<String, String> params) {
// 要先去掉 Url 里的 Sign
params.remove("sign");
StringBuilder stringBuilder = new StringBuilder(150);
for (Map.Entry<String, String> entry : params.entrySet()) {
//value非空,才会参与签名计算
if (!org.springframework.util.StringUtils.isEmpty(params.get(entry.getKey()))) {
stringBuilder.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
}
}
//清除最后一位的符号:&
String paramsStr = stringBuilder.toString();
if (StringUtils.isNotBlank(paramsStr)) {
paramsStr = paramsStr.substring(0, paramsStr.length() - 1);
}
return DigestUtils.md5DigestAsHex(paramsStr.getBytes()).toUpperCase();
}
/**
* 校验请求中的时间戳是否过期
*
* @param params
* @return
*/
public static boolean checkRequestUrlIsValid(SortedMap<String, String> params, int timeoutSecond) {
String signTimestamp = SignUtils.getSignTimestamp(params);
if (StringUtils.isBlank(signTimestamp)) {
return false;
}
Long signTimestampLong = 0L;
try {
signTimestampLong = Long.valueOf(signTimestamp);
} catch (Exception ex) {
return false;
}
//取得指定时区的时间(东八区)
TimeZone zone = TimeZone.getTimeZone("GMT-8:00");
Calendar cal = Calendar.getInstance(zone);
long currentTimestamp = cal.getTime().getTime();
long internalSecond = ((currentTimestamp - signTimestampLong) / 1000);
log.info("internalSecond:" + internalSecond);
//超出时间间隔,则认定请求过期
if (internalSecond > timeoutSecond) {
return false;
}
return true;
}
}