航空旅客信息保护与安全防御:从数据泄露风险到系统化防护实践
每年航空出行高峰期,关于旅客隐私泄露、航班信息被恶意查询、行程数据被倒卖的新闻层出不穷。作为技术人员,我们在关注航空安全的同时,也常常忽略一个更基础的问题:支撑旅客服务的信息系统,本身是否足够安全?
当“航空旅客安全”的话题被讨论时,大家更多关注的是机场安检、飞行过程、应急处置这些物理层面的保障。但今天我想换一个视角,从技术防御的角度,完整梳理航空旅客个人信息在存储、传输、查询、展示各个环节可能面临的风险,并给出可落地的安全防护方案。无论你是航空公司业务系统的开发者、机场信息化部门的工程师,还是正在设计旅客服务平台的架构师,这篇文章都值得收藏备用。
本文将围绕旅客信息系统的威胁建模、敏感数据加密方案、接口访问控制、日志审计与异常检测、以及安全开发生命周期这几个核心方向展开,结合完整代码示例和配置片段,带你搭建一套覆盖“端 - 管 - 云”的旅客信息防护体系。
1. 背景:为什么旅客信息成为攻击目标
1.1 旅客数据的敏感性远超你的想象
先来看一个最简单的例子。旅客订座记录中通常包含以下字段:
- 姓名、身份证号、护照号
- 手机号、常用邮箱
- 航班号、座位号、行程日期
- 常旅客卡号、积分余额
- 支付信息(部分系统会保存卡号后四位)
这些字段单独看似乎没什么,但组合在一起就是完整的个人画像。攻击者拿到姓名和身份证号可以尝试撞库,拿到航班号和日期可以精确掌握某人的出行轨迹,拿到手机号可以定向发送钓鱼短信。更严重的是,如果攻击者能够查询任意旅客的行程信息,就可能实现“精准追踪”。
这正是航空旅客信息系统需要重点防护的原因:数据价值高、字段敏感、调用链长、参与方多。一套系统可能同时服务官方App、机场自助值机、第三方OTA平台、航信接口、地服系统等多个终端,攻击面非常大。
1.2 旅客数据泄露的典型路径
根据近年来公开的安全事件分析,旅客信息泄露路径主要有以下几种:
| 泄露路径 | 典型攻击方式 | 后果 |
|---|---|---|
| 接口越权 | 遍历旅客编号、订单号 | 批量获取他人行程 |
| SQL注入 | 拼接查询参数 | 拖库、删库 |
| 日志泄露 | 敏感字段未脱敏写入日志 | 运维人员或攻击者通过日志获取数据 |
| 第三方接口滥用 | 未鉴权或鉴权薄弱 | 通过开放接口批量查询 |
| 内部人员泄露 | 高权限账号滥用 | 定向查询特定旅客信息 |
这些路径说明,旅客信息安全不只是“数据库加个密”这么简单。它需要从网络边界、应用逻辑、数据存储、运维审计多个层面协同防御。
1.3 为什么要从技术层面关注
有些团队觉得“我们用的是内网,攻击者进不来”,有些团队认为“等保测评过了就安全了”。但实际情况是:航空旅客业务链条长,涉及大量第三方系统对接,任何一方的防护薄弱都可能成为突破口。作为开发者和运维人员,我们至少应该做到“即使数据被拿走,也无法直接读取明文”。
2. 环境准备与版本说明
本文的实战部分采用 Spring Boot + MyBatis Plus + Redis + Vue 作为示例技术栈,重点展示后端防护部分的实现思路。版本信息以常见稳定版为例,实际操作时请根据你的项目环境调整。
JDK:1.8+ Spring Boot:2.7.x MyBatis Plus:3.5.x Redis:6.x MySQL:8.0 Maven:3.6+本文涉及的代码都是核心片段,需要放入你现有的项目结构中。如果你还没有项目骨架,可以先用 Spring Initializr 创建一个最简单的 Web 项目,再逐步添加依赖。
建议的项目结构:
passenger-security-demo/ ├── src/main/java/com/example/security/ │ ├── controller/ │ ├── service/ │ ├── mapper/ │ ├── entity/ │ ├── config/ │ └── util/ ├── src/main/resources/ │ ├── application.yml │ └── mapper/ └── pom.xml3. 旅客信息系统的威胁分析与安全设计原则
3.1 充分信任原则 vs 零信任原则
很多传统系统的开发模式是“内网可信”。系统内部服务之间调用不鉴权、参数不校验、敏感数据不加密,一旦攻击者通过某个漏洞拿到内网权限,整个数据库就暴露了。
零信任原则要求我们对每一个访问请求都重新验证,无论它来自内网还是外网。具体到旅客信息系统:
- 接口调用必须鉴权,即使是内部服务之间
- 每个请求必须校验参数范围,防止越权
- 敏感数据存储时加密,不信任数据库层的访问控制
- 日志中不记录明文敏感字段,不信任日志文件的安全性
3.2 最小权限原则
最小权限原则同样关键。比如:
- 查询旅客信息的接口,只返回当前业务需要的字段
- 不同系统角色(值机、售票、客服)拥有不同的数据范围
- 运维账号不应具有业务数据的查询权限
- 第三方对接账号只能访问授权范围内的接口
3.3 纵深防御
不要指望某单一安全措施能够挡住所有攻击。合理的做法是“层层设防”:
- 网络层:通过防火墙、安全组限制来源IP
- 应用层:接口鉴权、参数校验、频率控制
- 数据层:敏感字段加密、数据库账号分权
- 运维层:操作审计、异常访问告警
4. 核心安全能力实现
4.1 敏感数据加密存储
4.1.1 选择加密方案
旅客信息中,身份证号、手机号等字段适合采用AES对称加密方案。为什么不用MD5?因为MD5是不可逆的,业务中经常需要还原明文展示给旅客或客服,所以必须使用可逆加密。
这里以 AES/GCM 模式为例,因为它比 ECB 模式更安全,而且带有认证标签,可以防止密文被篡改。
4.1.2 AES加解密工具类
// 文件路径:src/main/java/com/example/security/util/AesUtil.java package com.example.security.util; import javax.crypto.Cipher; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.Base64; public class AesUtil { private static final int GCM_TAG_LENGTH_BITS = 128; private static final int IV_LENGTH_BYTES = 12; public static String encrypt(String plaintext, String secretKey) throws Exception { byte[] iv = new byte[IV_LENGTH_BYTES]; SecureRandom secureRandom = new SecureRandom(); secureRandom.nextBytes(iv); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "AES"); GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv); cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec); byte[] cipherText = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); // 将 IV 和密文拼接后 Base64 编码 byte[] result = new byte[iv.length + cipherText.length]; System.arraycopy(iv, 0, result, 0, iv.length); System.arraycopy(cipherText, 0, result, iv.length, cipherText.length); return Base64.getEncoder().encodeToString(result); } public static String decrypt(String encryptedData, String secretKey) throws Exception { byte[] decoded = Base64.getDecoder().decode(encryptedData); byte[] iv = new byte[IV_LENGTH_BYTES]; System.arraycopy(decoded, 0, iv, 0, IV_LENGTH_BYTES); byte[] cipherText = new byte[decoded.length - IV_LENGTH_BYTES]; System.arraycopy(decoded, IV_LENGTH_BYTES, cipherText, 0, cipherText.length); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "AES"); GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv); cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); byte[] plainText = cipher.doFinal(cipherText); return new String(plainText, StandardCharsets.UTF_8); } }这里的重点在于GCM模式自带认证能力。如果密文在传输或存储过程中被篡改,解密时会抛出异常,而不是返回错误明文。这比传统的AES/CBC/PKCS5Padding方案更可靠。
4.1.3 数据库字段设计
加密后数据建议使用 VARCHAR 类型存储,字段长度要预留足够空间。加密前身份证号是18位,加密后可能是80到120个字符,需要根据实际情况扩容。
CREATE TABLE passenger_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT '姓名(明文展示)', id_card_encrypted VARCHAR(256) NOT NULL COMMENT '身份证号(AES加密)', phone_encrypted VARCHAR(128) NOT NULL COMMENT '手机号(AES加密)', flight_no VARCHAR(20) NOT NULL, seat_no VARCHAR(10), travel_date DATE NOT NULL, created_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;注意:姓名是否加密需要根据业务场景判断。如果查询列表时需要展示姓名,加密后无法按姓名模糊搜索,会造成较大的业务改造。比较常见的方案是“姓名明文、身份证号和手机号加密”,或者“姓名也加密但增加一个姓名的全文索引字段”。
4.1.4 加解密在业务层的应用
// 文件路径:src/main/java/com/example/security/service/PassengerService.java package com.example.security.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.example.security.entity.PassengerInfo; import com.example.security.mapper.PassengerInfoMapper; import com.example.security.util.AesUtil; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import javax.annotation.Resource; @Service public class PassengerService { @Resource private PassengerInfoMapper passengerInfoMapper; @Value("${security.aes-key}") private String aesKey; public PassengerInfo queryPassengerById(Long id) { PassengerInfo passengerInfo = passengerInfoMapper.selectById(id); if (passengerInfo == null) { return null; } // 解密敏感字段后才返回给前端 try { passengerInfo.setIdCard(AesUtil.decrypt(passengerInfo.getIdCardEncrypted(), aesKey)); passengerInfo.setPhone(AesUtil.decrypt(passengerInfo.getPhoneEncrypted(), aesKey)); } catch (Exception e) { throw new RuntimeException("解密失败,请检查密钥配置"); } return passengerInfo; } public PassengerInfo queryByFlightNo(String flightNo) { LambdaQueryWrapper<PassengerInfo> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(PassengerInfo::getFlightNo, flightNo); return passengerInfoMapper.selectOne(wrapper); } }这里需要特别提醒:解密操作尽量在服务端完成,不要在数据库层做。原因有两点:第一,数据库层解密需要把密钥配置在数据库服务器上,一旦数据库被攻破密钥也泄露;第二,在应用层解密,可以配合后续的权限校验、访问审计做统一控制。
4.2 接口访问控制与越权防护
4.2.1 Token认证
旅客服务接口通常采用 Token(令牌)认证机制。以最常见的方式为例:
- 旅客通过账号密码登录
- 服务端验证后生成 Token 并返回
- 后续请求携带 Token,服务端校验通过后处理
使用 Redis 存储 Token 时,可以设置过期时间,实现服务端主动失效。核心配置如下:
# 文件路径:src/main/resources/application.yml spring: redis: host: localhost port: 6379 timeout: 3000ms security: aes-key: "your-256-bit-secret-key-please-change!" token-expire-hours: 244.2.2 防止水平越权
水平越权是指攻击者通过修改请求参数(如用户ID、订单号)访问他人的数据。比如查询旅客接口:
// 文件路径:src/main/java/com/example/security/controller/PassengerController.java package com.example.security.controller; import com.example.security.common.Result; import com.example.security.entity.PassengerInfo; import com.example.security.service.PassengerService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.annotation.Resource; @RestController @RequestMapping("/api/passenger") public class PassengerController { @Resource private PassengerService passengerService; @GetMapping("/{id}") public Result<PassengerInfo> getPassenger(@PathVariable("id") Long id) { PassengerInfo passengerInfo = passengerService.queryPassengerById(id); return Result.success(passengerInfo); } }如果这里不校验当前登录用户是否拥有该旅客数据的访问权限,攻击者只需要将id从100改成101、102、103,就能遍历全库的旅客信息。这是一种非常典型的漏洞,也是航班信息被恶意查询的主要入口之一。
改进方案:在查询前增加数据归属校验。
@GetMapping("/{id}") public Result<PassengerInfo> getPassenger(@PathVariable("id") Long id) { // 假设从Token中解析出当前登录用户ID Long currentUserId = TokenHolder.getCurrentUserId(); PassengerInfo passengerInfo = passengerService.queryPassengerById(id); if (passengerInfo == null) { return Result.error("旅客信息不存在"); } // 校验数据归属:只能查看本人或本人订单关联的旅客信息 if (!passengerInfo.getUserId().equals(currentUserId)) { return Result.error("无权访问该旅客信息"); } // 脱敏处理 passengerInfo.setIdCard(maskIdCard(passengerInfo.getIdCard())); passengerInfo.setPhone(maskPhone(passengerInfo.getPhone())); return Result.success(passengerInfo); }4.2.3 接口频率控制
对于查询类接口,一定要做频率限制。攻击者即使拿到了合法Token,也可能通过高频调用批量拉取数据。这里使用简单的Redis计数器实现频率限制。
// 文件路径:src/main/java/com/example/security/config/RateLimitInterceptor.java package com.example.security.config; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import javax.annotation.Resource; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.util.concurrent.TimeUnit; @Component public class RateLimitInterceptor implements HandlerInterceptor { @Resource private StringRedisTemplate stringRedisTemplate; private static final int MAX_COUNT = 10; private static final long WINDOW_SECONDS = 60; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String userId = request.getHeader("X-User-Id"); if (userId == null || userId.isEmpty()) { response.setStatus(401); return false; } String key = "rate:limit:" + userId + ":" + request.getRequestURI(); Long count = stringRedisTemplate.opsForValue().increment(key); if (count != null && count == 1) { stringRedisTemplate.expire(key, WINDOW_SECONDS, TimeUnit.SECONDS); } if (count != null && count > MAX_COUNT) { response.setStatus(429); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":429,\"msg\":\"请求过于频繁,请稍后再试\"}"); return false; } return true; } }4.3 敏感信息脱敏展示
4.3.1 脱敏工具示例
即使是有权限查询的接口,也不要明文返回完整的身份证号和手机号。合理的做法是:默认返回脱敏数据,只有在特定场景(如值机、退改签)需要用户验证时才返回完整明文。
// 文件路径:src/main/java/com/example/security/util/DesensitizeUtil.java package com.example.security.util; public class DesensitizeUtil { public static String maskPhone(String phone) { if (phone == null || phone.length() < 7) { return phone; } return phone.substring(0, 3) + "****" + phone.substring(7); } public static String maskIdCard(String idCard) { if (idCard == null || idCard.length() < 10) { return idCard; } return idCard.substring(0, 4) + "**********" + idCard.substring(14); } public static String maskName(String name) { if (name == null || name.isEmpty()) { return name; } if (name.length() == 1) { return name; } if (name.length() == 2) { return name.charAt(0) + "*"; } StringBuilder sb = new StringBuilder(); sb.append(name.charAt(0)); for (int i = 0; i < name.length() - 2; i++) { sb.append("*"); } sb.append(name.charAt(name.length() - 1)); return sb.toString(); } }4.3.2 返回结果中统一处理
实际项目中强烈建议不要在业务代码里逐行调用脱敏工具,而应该通过统一响应包装或注解的方式处理。这样不会漏掉某个接口,也不会污染已有的业务逻辑。
以 Jackson 自定义序列化注解为例:
// 文件路径:src/main/java/com/example/security/config/SensitiveSerializeConfig.java package com.example.security.config; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.BeanProperty; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.ser.ContextualSerializer; import com.example.security.util.DesensitizeUtil; import java.io.IOException; public class SensitiveFieldSerializer extends JsonSerializer<String> implements ContextualSerializer { private DesensitizeType type; public SensitiveFieldSerializer() { } public SensitiveFieldSerializer(DesensitizeType type) { this.type = type; } @Override public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value == null) { gen.writeNull(); return; } switch (type) { case PHONE: gen.writeString(DesensitizeUtil.maskPhone(value)); break; case ID_CARD: gen.writeString(DesensitizeUtil.maskIdCard(value)); break; case NAME: gen.writeString(DesensitizeUtil.maskName(value)); break; default: gen.writeString(value); } } @Override public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException { if (property != null) { SensitiveField annotation = property.getAnnotation(SensitiveField.class); if (annotation != null) { return new SensitiveFieldSerializer(annotation.type()); } } return this; } public enum DesensitizeType { PHONE, ID_CARD, NAME } }在实体字段上添加注解:
public class PassengerInfo { private Long id; private String name; @SensitiveField(type = SensitiveFieldSerializer.DesensitizeType.ID_CARD) private String idCard; @SensitiveField(type = SensitiveFieldSerializer.DesensitizeType.PHONE) private String phone; // getter / setter 省略 }这样,接口返回时就会自动脱敏,无需在每个Controller方法里手动处理。
4.4 日志审计与敏感信息保护
4.4.1 日志脱敏的必要性
很多安全性事件不是从数据库泄露的,而是从日志文件泄露的。比如开发人员在调试时打印了完整的请求体,日志采集系统将明文身份证号推到ELK,运维人员或第三方日志服务商就能看到这些数据。
所以日志中必须禁止打印完整敏感字段。这里给出Logback自定义转换器示例:
// 文件路径:src/main/java/com/example/security/config/MaskingConverter.java package com.example.security.config; import ch.qos.logback.classic.pattern.MessageConverter; import ch.qos.logback.classic.spi.ILoggingEvent; import java.util.regex.Matcher; import java.util.regex.Pattern; public class MaskingConverter extends MessageConverter { private static final Pattern ID_CARD_PATTERN = Pattern.compile("(\\d{6})\\d{8}(\\d{3}[0-9Xx])"); private static final Pattern PHONE_PATTERN = Pattern.compile("(1[3-9]\\d)(\\d{4})(\\d{4})"); @Override public String convert(ILoggingEvent event) { String message = event.getFormattedMessage(); Matcher idMatcher = ID_CARD_PATTERN.matcher(message); if (idMatcher.find()) { message = idMatcher.replaceAll("$1********$2"); } Matcher phoneMatcher = PHONE_PATTERN.matcher(message); if (phoneMatcher.find()) { message = phoneMatcher.replaceAll("$1****$3"); } return message; } }然后在logback-spring.xml中配置:
<configuration> <conversionRule conversionWord="msg" converterClass="com.example.security.config.MaskingConverter" /> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/passenger-security.log</file> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern> </encoder> </appender> <root level="INFO"> <appender-ref ref="FILE" /> </root> </configuration>除了日志脱敏,还需要做访问审计。重要的旅客信息查询请求,应该记录:
- 查询人账号
- 查询时间
- 查询接口
- 查询参数(脱敏后的)
- 返回数据量
- 客户端IP
这些日志用于安全团队事后追溯。
4.5 SQL注入与常见Web漏洞防护
4.5.1 SQL注入防护
使用 MyBatis Plus 的 LambdaQueryWrapper 可以避免大部分SQL注入风险,但如果有自定义SQL,需要特别注意。下面的写法就是危险的:
<!-- 危险写法,禁止使用 --> <select id="queryByName" resultType="PassengerInfo"> SELECT * FROM passenger_info WHERE name = '${name}' </select>正确的做法是使用#{}预编译占位符:
<!-- 安全写法 --> <select id="queryByName" resultType="PassengerInfo"> SELECT * FROM passenger_info WHERE name = #{name} </select>4.5.2 输入校验
接口入参必须做合法性校验。比如查询接口的ID必须是正整数,手机号必须符合格式,航班号必须匹配规则。可以使用Hibernate Validator实现:
public class QueryPassengerRequest { @NotNull(message = "旅客ID不能为空") @Positive(message = "旅客ID必须为正数") private Long id; @Pattern(regexp = "^[A-Z0-9]{2}\\d{3,4}$", message = "航班号格式不正确") private String flightNo; // getter / setter 省略 }4.6 数据库账号与权限管理
4.6.1 分账号分权限
很多项目为了方便,所有服务共用同一个数据库账号,甚至直接使用root账号。这是非常危险的做法。合理的账号规划:
| 账号 | 用途 | 权限 |
|---|---|---|
| app_read | 查询服务 | SELECT |
| app_write | 写入服务 | SELECT, INSERT, UPDATE |
| admin | 运维管理 | 全部,仅限跳板机使用 |
| backup | 备份 | SELECT, LOCK TABLES |
创建账号的SQL示例:
-- 只读账号 CREATE USER 'app_read'@'%' IDENTIFIED BY 'password'; GRANT SELECT ON passenger_db.* TO 'app_read'@'%'; -- 读写账号 CREATE USER 'app_write'@'%' IDENTIFIED BY 'password'; GRANT SELECT, INSERT, UPDATE ON passenger_db.* TO 'app_write'@'%'; -- 限制来源IP CREATE USER 'admin'@'192.168.10.%' IDENTIFIED BY 'password'; GRANT ALL PRIVILEGES ON passenger_db.* TO 'admin'@'192.168.10.%'; FLUSH PRIVILEGES;4.6.2 数据备份与恢复演练
旅客数据不能丢失。数据库必须开启定期备份,而且要定期做恢复演练。不要等真的需要恢复时才发现备份文件是坏的。
推荐方案:
- 每天全量备份,每6小时增量备份
- 备份文件加密存储,保留至少30天
- 每月进行一次恢复测试
5. 完整实战:搭建一个安全的旅客信息查询服务
5.1 创建Spring Boot项目并添加依赖
在pom.xml中添加核心依赖:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>5.2 配置数据源和Redis
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/passenger_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: app_read password: your-password driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 security: aes-key: "abcdef0123456789abcdef0123456789" token-expire-hours: 24 white-list: - /api/auth/login - /api/auth/register5.3 统一响应结构
// 文件路径:src/main/java/com/example/security/common/Result.java package com.example.security.common; public class Result<T> { private int code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } public static <T> Result<T> error(int code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } // getter / setter 省略 }5.4 配置拦截器
// 文件路径:src/main/java/com/example/security/config/WebConfig.java package com.example.security.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import javax.annotation.Resource; import java.util.Arrays; @Configuration public class WebConfig implements WebMvcConfigurer { @Resource private AuthInterceptor authInterceptor; @Resource private RateLimitInterceptor rateLimitInterceptor; @Value("${security.white-list}") private String whiteList; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/api/**") .excludePathPatterns(Arrays.asList(whiteList.split(","))); registry.addInterceptor(rateLimitInterceptor) .addPathPatterns("/api/passenger/**"); } }5.5 运行验证
启动项目后,使用Postman或curl进行测试:
# 1. 登录获取Token curl -X POST http://localhost:8080/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"testuser","password":"123456"}' # 2. 携带Token查询旅客信息(正常情况) curl -X GET http://localhost:8080/api/passenger/1 \ -H "Authorization: Bearer eyJhbGciOi..." # 3. 不带Token访问(应返回401) curl -X GET http://localhost:8080/api/passenger/1 # 4. 高频访问(应返回429) for i in $(seq 1 15); do curl -X GET http://localhost:8080/api/passenger/1 \ -H "Authorization: Bearer eyJhbGciOi..." done预期的输出如下(脱敏后的数据):
{ "code": 200, "message": "success", "data": { "id": 1, "name": "张*", "idCard": "1101**********1234", "phone": "138****5678", "flightNo": "CA1234", "seatNo": "12A", "travelDate": "2025-06-01" } }6. 常见问题与排查思路
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
解密时报错AEADBadTagException | 密文被篡改,或者密钥不一致 | 检查加密时的密钥与解密密钥是否一致;检查数据在传输过程中是否被截断 |
| 手机号/身份证号无法模糊搜索 | 加密存储导致无法使用LIKE查询 | 方案一:增加用于搜索的摘要字段(如后4位);方案二:使用Elasticsearch等搜索引擎,先解密后同步索引(注意权限控制) |
| 接口被刷导致数据库压力大 | 缺少频率限制或限流策略 | 接入Redis限流,同时考虑服务网关层限流 |
| 日志中仍出现明文身份证 | 日志脱敏Converter未生效 | 检查Logback配置中的conversionRule是否配置正确;检查是否还有其他Appender未经过转换器 |
| 用户反馈查询他人订单成功 | 接口缺少数据归属校验 | 在业务代码中增加数据归属判断,确保只能查询当前登录用户有权限的数据 |
| Redis中Token失效不统一 | 多节点部署时Redis未共享 | 使用统一的Redis集群;设置合理的过期时间 |
7. 最佳实践与工程建议
7.1 从项目启动阶段就引入安全设计
旅客信息系统不应该在开发完成后再“打补丁”。安全设计需要前置:
- 需求评审阶段:明确哪些字段是敏感字段,哪些接口需要权限控制
- 设计阶段:确定加密方案、脱敏方案、日志方案
- 开发阶段:代码走查时重点检查越权和SQL注入风险
- 测试阶段:加入安全测试用例,包括越权访问、恶意参数、高频调用
- 上线阶段:检查生产环境密钥管理、数据库账号权限、网络访问控制
7.2 密钥管理
不要把AES密钥写死在代码里,也不要直接放在配置文件并提交到Git。推荐使用配置中心或密钥管理服务,至少做到“环境隔离、定期轮换”。
- 开发环境、测试环境、生产环境使用不同的密钥
- 密钥至少每90天轮换一次
- 密钥泄露时能够快速更新并重新加密存量数据
- 访问密钥管理服务的权限需要独立审计
7.3 第三方接口对接的注意事项
航空旅客业务经常需要与第三方系统对接。此时要注意:
- 每个第三方系统分配独立的API Key,而不是复用内部账号
- 第三方的调用范围限制在最少的接口和字段
- 对第三方的调用频率进行独立监控
- 对接文档中明确数据保护责任边界
7.4 定期做安全评估
安全不是一次性的工作。建议:
- 每季度做一次接口越权测试
- 每半年做一次渗透测试
- 每次大版本发布前做依赖安全扫描(如OWASP Dependency Check)
- 持续关注CVE公告,及时升级存在安全漏洞的组件
7.5 团队意识和培训
再好的技术防护也挡不住内部人员的有意或无意的泄露。建议建立这样的机制:
- 高权限账号申请需要审批
- 敏感数据查询操作需要记录审计日志
- 离职员工账号即时禁用
- 定期对开发和运维人员进行数据安全培训
8. 总结与下一步学习建议
这篇文章从航空旅客信息系统的实际威胁出发,整理了敏感数据加密存储、接口鉴权与越权防护、脱敏展示、日志审计、SQL注入防护、数据库权限管理等一系列防护手段。核心思路是“纵深防御”和“最小权限”相结合,在每一层都增加攻击成本,而不是依赖某一个单一防御点。
对于刚入门的同学,建议先从掌握AES加密的基本用法、接口Token认证和简单的脱敏工具开始,把它们应用到自己的项目中。对于有一定经验的开发者,可以进一步研究:
- Spring Security OAuth2 授权码模式在多端场景下的应用
- 基于注解的细粒度权限控制(如@PreAuthorize)
- 大数据量下敏感字段的高性能解密方案
- 基于AI的异常访问检测与用户行为分析
最后想说一点:旅客信息安全的本质不是“不被人看到”,而是“即使被人看到,也无法解读出真实内容;即使解读出来,也能追踪到是谁、通过什么方式、在什么时间看到了这些数据”。如果这篇文章能帮你建立起自己的旅客信息安全防护体系,欢迎收藏备用。