有时候要把服务暴露给第三方去调用,为了防止接口不被授权访问,一般采用接口签名的方式去保护接口。
一 场景分析
什么时候需要接口签名?接口签名是一种重要的安全机制,用于确保 API 请求的真实性、数据的完整性以及防止重放攻击。
当需要保护 API 接口不被未授权访问、确保传输数据在过程中不被篡改,或者需要防止恶意用户利用 API 进行攻击时,就需要使用接口签名。
来举几个需要做接口签名的例子:
开放 API 给第三方使用:当你的 API 需要对外开放,让第三方应用或服务调用时,接口签名可以验证请求方的身份,确保只有拥有有效签名的请求才能被接受。
数据完整性校验:在数据传输过程中,接口签名可以确保数据不被篡改。通过将请求数据与密钥一起进行哈希运算,生成签名值,接收方收到数据后可以用相同的方法生成签名值进行对比,如果一致则数据未被篡改。
防止重放攻击:通过在签名中加入时间戳或随机数等动态元素,接口签名可以防止攻击者截获并重复发送有效的 API 请求。
接口防刷:为了防止接口被恶意调用,通常会采用一些防刷策略,比如限制请求频率、使用验证码等。接口签名可以作为防刷策略的一部分,确保请求的合法性。
敏感操作验证:对于涉及敏感数据或重要操作的 API,如支付、转账等,接口签名提供了额外的安全保障,确保请求的安全性。
API 安全合规:在某些行业,如金融、医疗等,法律法规可能要求对 API 进行严格的安全控制,接口签名是满足这些合规要求的一种方式。
二 签名步骤
一般来说,接口签名的步骤是这样的:
构造待签名字符串:将请求方法、请求 URI、请求参数(包括查询参数和请求体中的参数)、时间戳等关键信息按照一定的规则拼接成待签名字符串。
生成签名:使用客户端持有的私钥(或密钥)对待签名字符串进行加密(或哈希运算),生成签名。
发送请求:将生成的签名作为请求的一部分(如请求头)发送给服务器。
验证签名:服务器收到请求后,使用相同的规则构造待签名字符串,并使用对应的公钥(或密钥)进行验证。如果签名验证通过,则处理请求;否则,拒绝请求。
实现接口签名时,需要注意密钥管理、时间戳检查、错误处理和日志记录等安全实践。
三 代码实践
基于SpringBoot3,演示一个接口签名案例。首先我们需要一个签名和验签的工具类。这里我们采用 HmacSHA1 算法。
HmacSHA1 是一种基于 SHA-1 哈希算法的加密哈希消息认证码(Hash-based Message Authentication Code,简称 HMAC)算法。HMAC 是一种用于验证数据完整性和认证消息发送者身份的机制。它结合了加密哈希函数和加密密钥,从而提供了一种安全的方式来确认数据的完整性和真实性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | public class SignUtils { /** * 使用 HmacSHA1 算法进行签名 * @param secretKey 密钥 * @param data 数据 * @return */ public static String signWithHmacSha1(String secretKey, String data) { try { SecretKeySpec signingKey = new SecretKeySpec(secretKey.getBytes( "UTF-8" ), "HmacSHA1" ); Mac mac = Mac.getInstance( "HmacSHA1" ); mac.init(signingKey); return Base64.getEncoder().encodeToString(mac.doFinal(data.getBytes( "UTF-8" ))); } catch (NoSuchAlgorithmException | InvalidKeyException | UnsupportedEncodingException e) { e.printStackTrace(); } return null ; } /** * 验证签名 * @param secretKey 密钥 * @param data 数据 * @param hmac 签名 * @return */ public static boolean verify(String secretKey, String data, String hmac) { String calculatedHmac = signWithHmacSha1(secretKey, data); return calculatedHmac.equals(hmac); } } |
这个类很简单,一个用来生成签名的方法,这个方法按理说可以封装到 SDK 中给到调用者,或者告诉调用者思路,由调用者自行实现。第二个方法则是一个签名验证的方法,对用户传来的签名信息进行校验。
接下来需要一个 App 信息的查询类:
1 2 3 4 5 6 7 8 | @Service public class AppService { private static final Map<String, String> APP_INFO = Map.of( "app1" , "sign1" , "app2" , "sign2" ); public String getAppKey(String appId) { return APP_INFO.get(appId); } } |
这个类的作用是这样的:比如想要接入微信公众号后台,需要先在微信公众号后台配置自己的应用信息,配置完成后,微信公众号会给一个 appId 和 appSecret,微信平台会把这两个信息存入到数据库中,将来用户请求来的时候,用户会携带上 appId,但是不会携带 appSecret,微信公众号可以根据用户携带的 appId 去数据库中查询到 appSecret,然后进行验签。
直接模拟了两个 appId 和 appSecret 存入到 Map 中,这里提供一个根据 appId 查询 appSecret 的函数。接下来我们定义一个拦截器,在拦截器中对签名进行验证:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | public class SignInterceptor implements HandlerInterceptor { public final static Logger logger = LoggerFactory.getLogger(SignInterceptor. class ); AppService appService; public SignInterceptor(AppService appService) { this .appService = appService; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String appId = request.getHeader( "appId" ); String timestamp = request.getHeader( "timestamp" ); String sign = request.getHeader( "sign" ); if (StringUtils.hasText(appId) && StringUtils.hasText(timestamp) && StringUtils.hasText(sign)) { if (LocalDateTime.now().compareTo(LocalDateTime.ofInstant(Instant.ofEpochMilli(Long.parseLong(timestamp)), ZoneId.systemDefault()).plusMinutes(1L)) < 0 ) { String originalSign = appId + "-" + appService.getAppKey(appId) + "-" + timestamp; if (SignUtils.verify(appService.getAppKey(appId), originalSign, sign)) { return true ; } else { logger.error( "签名验证失败" ); } } else { logger.error( "签名已过期" ); } } else { logger.error( "签名信息不完整" ); } response.setStatus( 401 ); return false ; } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { HandlerInterceptor. super .postHandle(request, response, handler, modelAndView); } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { HandlerInterceptor. super .afterCompletion(request, response, handler, ex); } } |
请求头中主要有三个信息:
应用 id
时间戳
生成的签名
我们先从请求头中取出来这三个信息,检查是否为空;
然后判断一下这个时间戳,要求必须是 1 分钟之内的请求,这个判断目的主要是为了防止重放攻击。
接下来,根据 appId,以及根据 appId 查询出来的 appSecret,以及 timestamp,组成一个字符串,调用验签方法进行验证,如果验证通过,就说明请求没问题。
配置一下,让这个拦截器生效:
1 2 3 4 5 6 7 8 9 10 11 | @Configuration public class WebConfig implements WebMvcConfigurer { @Autowired AppService appService; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor( new SignInterceptor(appService)) .addPathPatterns( "/app/**" ); //只拦截需要接口验签的请求 } } |
要如何生成签名呢?例子:
1 2 3 4 5 6 7 8 | @AutowiredAppService appService; @Testvoid contextLoads() { String appId = "app1" ; long timeMillis = System.currentTimeMillis(); String appSecret = appService.getAppKey(appId); String sign = SignUtils.signWithHmacSha1(appSecret, appId + "-" + appSecret + "-" + timeMillis); System.out.println( "timeMillis = " + timeMillis); System.out.println( "sign = " + sign); } |