外部应用调用我方接口时通常需要做安全校验,这里记录一种验签方式,基于interceptor实现。
先看看接口的定义:
@ApiOperation(value = "同步第三方商品数据")
@PostMapping(value = "/sync")
@ExternalAPI(sourceSystem = SourceSystemEnum.SUNAC)
public ResponseHeaderVO<ThirdpartResponseSkuMappingVO> syncSpuData(@RequestBody @Validated ThirdpartGoodsDTO dto) {
try {
return thirdpartGoodsAppService.syncThirdpartGoods(dto.getData());
} catch (Exception e) {
log.error("call syncGoods error...", e);
return new ResponseHeaderVO<>(RestConstants.SYSTEM_ERROR, e.getMessage(), null);
}
}
1 token权限校验
定义拦截器拦截接口请求,做token校验
@Slf4j
@Component
public class RequestInterceptor implements HandlerInterceptor {
private AuthAppService authService;
private PermissionsAppService permissionsService;
private SignProperties signProperties;
private static final int CREDIT_CODE_LENGTH = 18;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
//获取请求头中的token信息,没有则适配默认值
String token = fetchToken(request);
//校验token'
boolean checked = authService.checkToken(token);
setUserLanguage(request);
checked = handleExternalApi(handlerMethod, token, checked);
// ignoreSecurity请求过滤
handleIgnoreSecurity(handlerMethod, checked);
Permissions permissions = handlerMethod.getMethodAnnotation(Permissions.class);
if (null == permissions) {
return true;
}
// 角色权限校验
permissionsService.check(permissions.value());
} else {
log.error("preHandle:handlerMethod type error:{}", handler.getClass().getName());
}
return true;
}
private boolean handleExternalApi(HandlerMethod handlerMethod,
String token,
boolean checked) {
if (checked) {
return true;
}
boolean isExternalApi = handlerMethod.hasMethodAnnotation(ExternalAPI.class);
if (!isExternalApi) {
return false;
}
ExternalAPI externalAPI = handlerMethod.getMethodAnnotation(ExternalAPI.class);
if (externalAPI == null) {
return false;
}
SourceSystemEnum sourceSystemEnum = externalAPI.sourceSystem();
if (SourceSystemEnum.ERP_PCMS.equals(sourceSystemEnum)) {
throw new BizsException(RestConstants.TOKEN_ERROR, RestConstants.TOKEN_ERROR_MSG);
} else if (SourceSystemEnum.B2B.equals(sourceSystemEnum)) {
return handleB2b(token, externalAPI);
} else {
return SourceSystemEnum.SUNAC.equals(sourceSystemEnum);
}
}
private boolean handleB2b(String token, ExternalAPI externalAPI) {
// token验证失败, 验供应商token
String decryptedSupplierNo = getDecryptedSupplierNo(token);
if (StringUtils.isNotBlank(decryptedSupplierNo)) {
return true;
}
if (externalAPI.strictMode()) {
return false;
}
// 没有的话必须传18位的统一社会信用代码
int lenz = StringUtils.length(token);
if (lenz != CREDIT_CODE_LENGTH) {
throw new BizsException(RestConstants.TOKEN_ERROR, RestConstants.TOKEN_ERROR_MSG);
}
if (!StringUtils.isAlphanumeric(token)) {
throw new BizsException(RestConstants.TOKEN_ERROR, RestConstants.TOKEN_ERROR_MSG);
}
return true;
}
private String getDecryptedSupplierNo(String token) {
String decryptedSupplierNo = null;
try {
decryptedSupplierNo = SignUtils.getDecryptContent(token, signProperties.getYgSupplierKey());
} catch (Exception ex) {
log.trace(ex.getMessage(), ex);
}
return decryptedSupplierNo;
}
private void handleIgnoreSecurity(HandlerMethod handlerMethod, boolean checked) {
boolean ignoreSecurity = handlerMethod.hasMethodAnnotation(IgnoreSecurity.class);
if (!ignoreSecurity && !checked) {
throw new BizsException(RestConstants.TOKEN_ERROR, RestConstants.TOKEN_ERROR_MSG);
}
}
private String fetchToken(HttpServletRequest request) {
String token = request.getHeader(BaseConstants.DEFAULT_TOKEN_NAME);
if (StringUtils.isEmpty(token)) {
token = request.getParameter(BaseConstants.DEFAULT_TOKEN_NAME);
}
return token;
}
private void setUserLanguage(HttpServletRequest request) {
String language= request.getHeader(BaseConstants.SYSTEM_MESSAGE_LANGUAGE);
if (StringUtils.isEmpty(language)) {
language="zh";
}
UserLocal.USER.saveLocal(new Locale(language));
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
ModelAndView modelAndView) {
//清空threadLocal数据
UserLocal.USER.remove();
}
@Autowired
public void setAuthService(AuthAppService authService) {
this.authService = authService;
}
@Autowired
public void setPermissionsService(PermissionsAppService permissionsService) {
this.permissionsService = permissionsService;
}
@Autowired
public void setSignProperties(SignProperties signProperties) {
this.signProperties = signProperties;
}
}
关于token权限校验基于redis实现:
@Component
@Slf4j
public class AuthAppService{
@Autowired
private RedisManager redisManager;
public String createToken(UserInfoVO userInfoVO) {
//创建token
String token = SHA256Util.sha256(userInfoVO.toString() + DateUtil.getTimeNow(new Date()));
//token存储到redis中 设置过期时间为30分钟
String redisKey = RedisConstants.REDIS_KEY_PSI_USERTOEKN.concat(":").concat(token);
redisManager.set(redisKey, JSON.toJSONString(userInfoVO), RedisConstants.REDIS_EDM_USERTOEKN_EXPTIME);
return token;
}
public boolean checkToken(String token) {
if (StringHelper.isEmpty(token)) {
return false;
}
String redisKey = RedisConstants.REDIS_KEY_PSI_USERTOEKN.concat(":").concat(token);
String redisValue = (String) redisManager.get(redisKey);
if (StringUtils.isEmpty(redisValue)) {
return false;
}
UserInfoVO userInfoVO = JSON.parseObject(redisValue, UserInfoVO.class);
if (userInfoVO == null) {
return false;
}
UserLocal.USER.saveToken(token);
UserLocal.USER.saveUserInfo(userInfoVO);
//刷新token时间
updateTokenExpireTime(token);
return true;
}
public void deleteToken(String token) {
String redisKey = RedisConstants.REDIS_KEY_PSI_USERTOEKN.concat(":").concat(token);
redisManager.del(redisKey);
}
public void updateTokenExpireTime(String token) {
String redisKey = RedisConstants.REDIS_KEY_PSI_USERTOEKN.concat(":").concat(token);
redisManager.expire(redisKey, RedisConstants.REDIS_EDM_USERTOEKN_EXPTIME);
}
public void updateToken(String token, UserInfoVO userInfoVO) {
String redisKey = RedisConstants.REDIS_KEY_PSI_USERTOEKN.concat(":").concat(token);
redisManager.set(redisKey, RedisConstants.REDIS_EDM_USERTOEKN_EXPTIME);
}
}
2 web配置
定义拦截器后需要配置拦截地址等,定义如下:
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
private static final String WEB_JARS = "/webjars/**";
private static final String WEB_EXCEL = "/excel/**";
private static final String[] CLASSPATH_RESOURCE_LOCATIONS = {
"classpath:/META-INF/resources/", "classpath:/resources/",
"classpath:/static/", "classpath:/public/"};
private static final String[] SWAGGER_PATTERN = {"/swagger-resources/**",
"/v2/api-docs", "/swagger-ui.html", "/swagger-ui.html/**"};
@Autowired
private RequestInterceptor requestInterceptor;
/**
* request interceptors
*
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(requestInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(WEB_JARS)
.excludePathPatterns(WEB_EXCEL)
.excludePathPatterns(SWAGGER_PATTERN);
}
/**
* swagger resource
*
* @param registry resource handler
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler(SWAGGER_PATTERN).addResourceLocations(CLASSPATH_RESOURCE_LOCATIONS);
registry.addResourceHandler(WEB_EXCEL)
.addResourceLocations("classpath:/static/excel/");
registry.addResourceHandler(WEB_JARS)
.addResourceLocations(
"classpath:/META-INF/resources/webjars/");
}
}
3 签名验证
关于接口中的注解@ExternalAPI(sourceSystem = SourceSystemEnum.SUNAC)
这里采用自定义注解的方式,根据接口的source来源可以指定不同调用方的签名,从而达到验签的目的
/**
* 标记为外部调用的api
* 这些api可以支持token验证,也可以使用签名的方式验证
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ExternalAPI {
SourceSystemEnum sourceSystem() default SourceSystemEnum.ERP_PCMS;
boolean strictMode() default false;
}
定义注解后,通过对注解方法的代理,可以获取不同source对应的签名,从而对请求参数进行签名验证
@Slf4j
@Aspect
@Component
public class ExternalAPIAspect {
private SignProperties signProperties;
/**这里用 @Around(value = "@annotation(externalAPI)"),对注解方法做增强处理*/
@Around(value = "@annotation(externalAPI)")
public Object before(ProceedingJoinPoint pjp, ExternalAPI externalAPI) throws Throwable {
SourceSystemEnum sourceSystem = externalAPI.sourceSystem();
if (!SourceSystemEnum.SUNAC.equals(sourceSystem)) {
return pjp.proceed();
}
Object[] args = pjp.getArgs();
if (args == null || args.length != 1) {
return pjp.proceed();
}
Object arg = args[0];
if (!(arg instanceof DTOForExternalAPI)) {
return pjp.proceed();
}
DTOForExternalAPI dto = (DTOForExternalAPI) arg;
//校验用户是否一致
if (!Objects.equals(dto.getUserNo(), signProperties.getSunacAppId())) {
return new ResponseHeaderVO<String>(ResultEnum.INVALID_SIGN_ERROR.getCode(),
ResultEnum.INVALID_SIGN_ERROR.getMsg(), null);
}
try {
//使用特定的算法校验用户签名是否一致
SignUtils.verifyExternalApiCall(dto, signProperties.getSunacAppKey());
} catch (BizsException ex) {
return new ResponseHeaderVO<String>(ex.getErrorCode(), ex.getErrorMsg(), null);
}
return pjp.proceed();
}
@Autowired
public void setSignProperties(SignProperties signProperties) {
this.signProperties = signProperties;
}
}
具体的验证方式采用MD5加密:
public static void verifyExternalApiCall(DTOForExternalAPI dto, String apiKey) {
if (StringUtils.isBlank(dto.getSign())) {
throw new BizsException(ResultEnum.INVALID_SIGN_FIELD_SIGN_NULL_ERROR);
}
String checksum = encodeForExternalAPI(dto.getUserNo(), dto.getTimestamp(), apiKey);
if (!Objects.equals(dto.getSign(), checksum)) {
throw new BizsException(ResultEnum.INVALID_SIGN_ERROR);
}
}
public static String encodeForExternalAPI(String userNo, String timestamp, String apiKey) {
long ts;
if (StringUtils.isBlank(timestamp)) {
throw new BizsException(ResultEnum.INVALID_SIGN_FIELD_TIMESTAMP_NULL_ERROR);
} else {
try {
ts = Long.parseLong(timestamp);
} catch (Exception ex) {
throw new BizsException(ResultEnum.INVALID_SIGN_FIELD_TIMESTAMP_NULL_ERROR);
}
}
long tsDiff = Math.abs(System.currentTimeMillis() - ts);
if (tsDiff > DateUtils.MILLIS_PER_HOUR) {
throw new BizsException(ResultEnum.INVALID_SIGN_TIMESTAMP_EXPIRED_ERROR);
}
return MD5.md5Encode(userNo + apiKey + ts, "UTF-8");
}
ExternalAPIAspect类中的SignProperties是在配置中指定的签名信息,最终要与接口请求数据中的签名信息比较是否一致,实现验签功能。
/**
* 签名配置
* 读取配置信息,获取签名信息
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "sign")
public class SignProperties {
private String sunacAppId;
private String sunacAppKey;
}
最后我们看看请求数据json:
{
"userNo":"PTNR9019961250",
"timestamp":1634031946432,
"sign":"b4810f2e1b4e053841affbf24bedaa6f",
"data":{
"source" : "SUNAC",
"spuList" :
[{
"spuNo": "spu123",
"spuName": "测试spu123",
"categoryCode": "123456",
"spuState": "1",
"specList": [
{
"unifiedSocialCreditCode": "91440101BA59JDD17W",
"spuImg": "https://test_url/test_image.jpg,https://test_url/test_image2.jpg",
"spuRemark": "spu描述blabla"
}
],
"skuList": [
...
就是通过校验请求头中的签名从而实现接口的安全验证。