在微服务架构中,系统之间的调用往往需要保证 安全性。如果缺乏有效的防护机制,接口极易遭受伪造请求攻击。
接口签名校验 就是一种常见的安全手段,可以有效避免参数篡改、重放攻击。
本文将基于 Spring Boot 3 + Sa-Tokensa-token-sign 模块,手把手带你实现接口签名校验,并扩展到数据库存储,支持动态接入。

签名校验流程图

   Client                              Server
      |                                    |
      | appid, params, sign, timestamp, nonce |
      | -----------------------------------> |
      |                                    | 1. 从数据库加载密钥配置
      |                                    | 2. 验证 timestamp 是否在有效期内
      |                                    | 3. 验证 nonce 是否重复
      |                                    | 4. 计算签名并比对
      |                                    | 5. Redis 缓存配置(12小时)
      | <----------------------------------|
      |           Response                 |

Sa-Token 签名模块简介

sa-token-sign 模块开箱即用,提供了:

✅ 支持 MD5 / SHA256 / SHA512

✅ 内置 timestamp / nonce 校验

✅ 支持 多应用配置

✅ 提供 @SaCheckSign 注解,零侵入接入

✅ 支持 自定义配置源(数据库)

项目依赖

<!-- Sa-Token Starter -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-spring-boot3-starter</artifactId>
    <version>1.44.0</version>
</dependency>

<!-- API 参数签名 -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-sign</artifactId>
    <version>1.44.0</version>
</dependency>

<!-- Sa-Token 与 Redis 集成(用于缓存签名配置) -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-redis-template</artifactId>
    <version>1.44.0</version>
</dependency>

数据库设计

1 表结构

create table t_app_sign_config
(
    id                  bigint auto_increment comment '主键ID' primary key,
    app_id              varchar(64)  not null comment '应用ID',
    secret_key          varchar(128) not null comment '密钥',
    digest_algo         varchar(32)  default 'md5' not null comment '签名算法: md5 / sha256 / sha512',
    timestamp_disparity bigint       default 900000 null comment '时间戳允许误差(毫秒) 默认15分钟',
    create_by           varchar(50)  null comment '创建人',
    create_time         datetime     default CURRENT_TIMESTAMP null comment '创建时间',
    update_by           varchar(50)  null comment '更新人',
    update_time         datetime     default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP comment '更新时间',
    constraint uk_app_id unique (app_id)
) comment '应用签名配置表';

2 初始化数据

INSERT INTO t_app_sign_config (app_id, secret_key, digest_algo, timestamp_disparity, create_by)
VALUES ('AppId1', '601b8ddd3037c782476e4be8102f6a07', 'md5', 900000, 'admin');

INSERT INTO t_app_sign_config (app_id, secret_key, digest_algo, timestamp_disparity, create_by)
VALUES ('AppId2', '954911e93f7e14fe1e09a713bf96b0da', 'md5', 900000, 'admin');

后端实现

1 实体类

@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_app_sign_config")
public class AppSignConfig extends BaseEntity {

    /**
     * 应用ID
     */
    private String appId;

    /**
     * 密钥
     */
    private String secretKey;

    /**
     * md5 / sha256 / sha512
     */
    private String digestAlgo;

    /**
     * 时间戳误差(秒)
     */
    private Long timestampDisparity;
}

2 Service 层(含 Redis 缓存)

public interface IAppSignConfigService extends IService<AppSignConfig> {
    AppSignConfig getByAppId(String appId);
}
@Service
public class AppSignConfigServiceImpl extends ServiceImpl<AppSignConfigRepository, AppSignConfig>
        implements IAppSignConfigService {

    private static final String CACHE_PREFIX = "rbom-sync:api:signconfig:";

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Override
    public AppSignConfig getByAppId(String appId) {
        String cacheKey = CACHE_PREFIX + appId;
        String cache = redisTemplate.opsForValue().get(cacheKey);
        if (cache != null) {
            return JsonUtils.fromJson(cache, AppSignConfig.class);
        }
        AppSignConfig appSignConfig = lambdaQuery()
                .eq(AppSignConfig::getAppId, appId)
                .one();
        // 缓存配置(12小时)
        redisTemplate.opsForValue()
                .set(cacheKey, JsonUtils.toJson(appSignConfig), Duration.ofHours(12));
        return appSignConfig;
    }
}

3 自定义签名配置加载器

为什么能够动态加载签名信息,这一步很重要

@Component
public class MySignConfigLoader {

    private static final Logger logger = LoggerFactory.getLogger(MySignConfigLoader.class);

    @Autowired
    private IAppSignConfigService appSignConfigService;

    @PostConstruct
    public void init() {
        logger.info("初始化自定义签名配置加载器...");
        // 覆盖 SaSignMany 的查找逻辑
        SaSignMany.findSaSignConfigMethod = (appid) -> {
            try {
                logger.debug("查找应用签名配置,appid: {}", appid);
                AppSignConfig appSignConfig = appSignConfigService.getByAppId(appid);

                if (appSignConfig == null) {
                    logger.warn("未找到应用签名配置,appid: {}", appid);
                    throw new RuntimeException("appid 不存在: " + appid);
                }
                SaSignConfig config = new SaSignConfig();
                config.setSecretKey(appSignConfig.getSecretKey());
                config.setDigestAlgo(appSignConfig.getDigestAlgo());
                config.setTimestampDisparity(appSignConfig.getTimestampDisparity());
                logger.debug("成功加载应用签名配置,appid: {}, algorithm: {}",
                        appid, appSignConfig.getDigestAlgo());
                return config;
            } catch (Exception e) {
                logger.error("加载应用签名配置失败,appid: {}, error: {}", appid, e.getMessage(), e);
                throw new BusinessException("加载应用签名配置失败: " + e.getMessage(), e);
            }
        };
        logger.info("自定义签名配置加载器初始化完成");
    }
}

4 拦截器配置

@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 开启 Sa-Token 注解拦截
        registry.addInterceptor(new SaInterceptor()).addPathPatterns("/**");
    }
}

5 Controller 使用

@RestController
@RequestMapping("/sync")
public class SyncController {

    @Autowired
    private IModelService modelService;

    // 从请求参数中动态获取 appid
    @SaCheckSign(appid = "#{appid}")
    @GetMapping("/model")
    public ResponseEntity<Res> model() {
        List<Model> models = modelService.list();
        return ResponseEntity.ok(Res.success(models));
    }
}

6 application.properties 配置

# Sa-Token 配置
sa-token.token-name=***-sync:satoken

客户端调用示例

1 Java 客户端(OkHttp)

OkHttpClient client = new OkHttpClient().newBuilder().build();

String appid = "AppId1";
String nonce = UUID.randomUUID().toString();
long timestamp = System.currentTimeMillis();

// 拼接签名字符串
String raw = "appid=" + appid + "&nonce=" + nonce + "&timestamp=" + timestamp
             + "&key=601b8ddd3037c782476e4be8102f6a07";
// 生成 MD5 签名
String sign = DigestUtils.md5DigestAsHex(raw.getBytes(StandardCharsets.UTF_8));

// 发送请求
Request request = new Request.Builder()
        .url("http://***:8080/sync/model"
             + "?appid=" + appid
             + "&sign=" + sign
             + "&nonce=" + nonce
             + "&timestamp=" + timestamp)
        .get()
        .build();

Response response = client.newCall(request).execute();
System.out.println(response.body().string());

6.2 curl 示例

curl "http://***:8080/sync/model?appid=MDM&sign=***&nonce=***&timestamp=***"

架构设计亮点

设计点 说明
数据库存储 密钥配置持久化,支持动态扩展新应用
Redis 缓存 缓存签名配置 12 小时,减少数据库查询
自定义加载器 通过 @PostConstruct 覆盖默认配置加载逻辑
动态 appid 使用 SpEL 表达式 #{appid} 从请求参数获取
异常处理 统一的日志记录和异常封装
唯一约束 数据库 app_id 唯一索引防止重复

常见问题

Q1: 如何添加新的应用?

直接在数据库 t_app_sign_config 表插入新记录即可,无需重启服务。

Q2: 缓存失效后如何刷新?

缓存 TTL 为 12 小时,过期后自动从数据库重新加载。

Q3: timestamp_disparity 的含义?

表示时间戳允许的误差范围(毫秒),默认 900000ms(15 分钟),防止重放攻击。

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐