金融系统安全设计 Checklist:一个后端的自我修养
大约 19 分钟
金融系统安全设计 Checklist:一个后端的自我修养
金融系统不同于普通应用——安全不是一个 feature,而是地基。本文系统梳理金融后端开发中不可忽视的安全设计要点。
1. 金融系统的安全要求总览
1.1 为什么金融系统更"敏感"
金融系统面临的安全威胁远超普通应用:
┌────────────────────────────────────────────────────────────┐
│ 金融系统安全威胁全景 │
├────────────────────────────────────────────────────────────┤
│ │
│ 【外部威胁】 │
│ ├── 黑客攻击(SQL 注入、XSS、DDoS) │
│ ├── 欺诈交易(伪造请求、金额篡改) │
│ ├── 数据窃取(拖库、中间人攻击) │
│ └── API 滥用(爬取、撞库) │
│ │
│ 【内部威胁】 │
│ ├── 内部人员滥用权限 │
│ ├── 运维人员误操作 │
│ ├── 开发人员硬编码敏感信息 │
│ └── 离职员工未回收权限 │
│ │
│ 【合规要求】 │
│ ├── PCI DSS(支付卡行业数据安全标准) │
│ ├── 等保 2.0(网络安全等级保护) │
│ ├── GDPR(通用数据保护条例) │
│ └── 《个人信息保护法》 │
│ │
└────────────────────────────────────────────────────────────┘
1.2 安全设计的核心原则
零信任(Zero Trust)
└── 不信任任何来源,即使是内网请求也要验证
最小权限(Least Privilege)
└── 每个组件只拥有完成职责所需的最小权限
纵深防御(Defense in Depth)
└── 多层防御,单层被突破也不会导致整体沦陷
安全默认(Secure by Default)
└── 默认配置就是安全的,需要显式放开才降低安全性
默认拒绝(Default Deny)
└── 未明确允许的访问一律拒绝
2. 传输安全:HTTPS 强制与证书管理
2.1 HTTPS 强制配置
# Spring Boot - application.yml
server:
port: 8443
ssl:
enabled: true
key-store: classpath:keystore.p12
key-store-password: ${SSL_KEY_STORE_PASSWORD}
key-store-type: PKCS12
key-alias: tomcat
# 强制 HTTPS
http2:
enabled: true
# 同时开启 HTTP 自动跳转 HTTPS
// 方式一:Spring Security 强制 HTTPS
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 所有请求必须走 HTTPS
.requiresChannel(channel -> channel
.anyRequest().requiresSecure()
)
// ... 其他配置
;
return http.build();
}
// 方式二:Tomcat 级别重定向
@Bean
public WebServerFactoryCustomizer<TomcatServletWebServerFactory> httpsRedirect() {
return factory -> factory.addAdditionalTomcatConnectors(
createHttpConnector()
);
}
private Connector createHttpConnector() {
Connector connector = new Connector(TomcatServletWebServerFactory.DEFAULT_PROTOCOL);
connector.setScheme("http");
connector.setPort(8080);
connector.setSecure(false);
connector.setRedirectPort(8443);
return connector;
}
2.2 HSTS(HTTP Strict Transport Security)
HSTS 告诉浏览器"永远不要用 HTTP 访问我":
// Spring Security HSTS 配置
http.headers(headers -> headers
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true) // 包含子域名
.maxAgeInSeconds(31536000) // 1 年
.preload(true) // 允许加入浏览器预加载列表
)
);
// 对应的响应头:
// Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
2.3 证书管理最佳实践
# 1. 使用 Let's Encrypt 自动获取和续期(推荐)
certbot certonly --standalone -d api.example.com
# 2. 证书监控:过期预警
# 在过期前 30 天发送告警
# 3. 私钥保护
# - 私钥文件权限 600
# - 不同环境使用不同证书
# - 私钥永远不入代码仓库
# - 使用 HSM(硬件安全模块)存储生产私钥
# 4. 证书钉扎(Certificate Pinning) - 移动端 App
# 防止中间人攻击,App 只信任特定的证书
2.4 TLS 配置加固
// 禁用不安全的 TLS 协议和加密套件
@Bean
public WebServerFactoryCustomizer<TomcatServletWebServerFactory> tomcatCustomizer() {
return factory -> factory.addConnectorCustomizers(connector -> {
if (connector.getProtocolHandler() instanceof AbstractHttp11Protocol<?> protocol) {
// 只启用 TLS 1.2 和 1.3(禁用 TLS 1.0/1.1)
protocol.setSslEnabledProtocols(new String[]{"TLSv1.2", "TLSv1.3"});
// 指定安全的加密套件
protocol.setCiphers("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,"
+ "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,"
+ "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256");
}
});
}
3. 认证安全:多因素认证与会话管理
3.1 多因素认证(MFA)设计
认证强度分级:
Level 1: 密码(知识因素)
└── 适合:低敏感操作(查看公开信息)
Level 2: 密码 + 短信/邮箱验证码(知识 + 持有)
└── 适合:中等敏感操作(登录、修改个人信息)
Level 3: 密码 + TOTP(知识 + 持有)
└── 适合:高敏感操作(转账、修改安全设置)
Level 4: 密码 + 生物识别(知识 + 固有)
└── 适合:最高敏感操作(大额交易、管理员操作)
TOTP(基于时间的一次性密码)实现:
@Service
public class TotpService {
private static final int CODE_LENGTH = 6;
private static final int TIME_STEP_SECONDS = 30;
/**
* 生成 TOTP 密钥(用户绑定 Google Authenticator 时使用)
*/
public String generateSecretKey() {
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[20]; // 160 bits
random.nextBytes(bytes);
return Base32.encode(bytes); // Base32 编码,方便用户手动输入
}
/**
* 生成 Google Authenticator 绑定二维码的 URI
*/
public String generateQrCodeUri(String secret, String username, String issuer) {
return String.format(
"otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&period=%d",
URLEncoder.encode(issuer, StandardCharsets.UTF_8),
URLEncoder.encode(username, StandardCharsets.UTF_8),
secret,
URLEncoder.encode(issuer, StandardCharsets.UTF_8),
CODE_LENGTH,
TIME_STEP_SECONDS
);
}
/**
* 验证 TOTP 验证码
*/
public boolean verifyCode(String secret, String code) {
long currentTimeStep = System.currentTimeMillis() / 1000 / TIME_STEP_SECONDS;
// 允许前后一个时间窗口(共 90 秒),避免时钟偏差
for (long i = -1; i <= 1; i++) {
String expectedCode = generateTotp(secret, currentTimeStep + i);
if (expectedCode.equals(code)) {
return true;
}
}
return false;
}
/**
* 核心算法:TOTP = HOTP(K, T)
* 其中 T = (当前Unix时间 - T0) / X,X 是时间步长
*/
private String generateTotp(String secret, long timeStep) {
byte[] key = Base32.decode(secret);
// 将 timeStep 转为 8 字节大端序
ByteBuffer buffer = ByteBuffer.allocate(8);
buffer.putLong(timeStep);
// HMAC-SHA1(key, timeStep)
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(key, "HmacSHA1"));
byte[] hash = mac.doFinal(buffer.array());
// 动态截断(Dynamic Truncation)
int offset = hash[hash.length - 1] & 0x0F;
int binary = ((hash[offset] & 0x7F) << 24)
| ((hash[offset + 1] & 0xFF) << 16)
| ((hash[offset + 2] & 0xFF) << 8)
| (hash[offset + 3] & 0xFF);
// 取模得到 6 位数字
int otp = binary % (int) Math.pow(10, CODE_LENGTH);
return String.format("%0" + CODE_LENGTH + "d", otp);
}
}
3.2 登录安全防护
@Service
@RequiredArgsConstructor
@Slf4j
public class LoginSecurityService {
private final StringRedisTemplate redisTemplate;
private static final int MAX_LOGIN_ATTEMPTS = 5;
private static final long LOCKOUT_DURATION_MINUTES = 30;
/**
* 登录失败处理:记录失败次数,超过阈值锁定账户
*/
public void handleLoginFailure(String username, String ip) {
// ─── 1. 账户级别锁定 ───
String accountKey = "login:fail:account:" + username;
Long attempts = redisTemplate.opsForValue().increment(accountKey);
redisTemplate.expire(accountKey, Duration.ofMinutes(LOCKOUT_DURATION_MINUTES));
if (attempts != null && attempts >= MAX_LOGIN_ATTEMPTS) {
log.warn("账户 {} 因连续 {} 次登录失败被锁定", username, MAX_LOGIN_ATTEMPTS);
// 可以发送告警通知用户
}
// ─── 2. IP 级别限制 ───
String ipKey = "login:fail:ip:" + ip;
redisTemplate.opsForValue().increment(ipKey);
redisTemplate.expire(ipKey, Duration.ofMinutes(15));
// ─── 3. 记录审计日志 ───
log.info("登录失败: username={}, ip={}, 累计失败={}", username, ip, attempts);
}
/**
* 检查账户是否被锁定
*/
public boolean isAccountLocked(String username) {
String key = "login:fail:account:" + username;
String value = redisTemplate.opsForValue().get(key);
if (value != null) {
int attempts = Integer.parseInt(value);
return attempts >= MAX_LOGIN_ATTEMPTS;
}
return false;
}
/**
* 登录成功后清除失败记录
*/
public void handleLoginSuccess(String username) {
String key = "login:fail:account:" + username;
redisTemplate.delete(key);
}
}
3.3 会话管理
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session
// 限制同一账号的并发会话数(防止账号共享)
.maximumSessions(3)
// 超过限制时的行为:
// true → 阻止新会话(推荐:阻止新的登录)
// false → 踢掉最旧的会话
.maxSessionsPreventsLogin(true)
// 会话过期跳转
.expiredUrl("/login?expired")
)
// 防止会话固定攻击:登录成功后创建新 Session
.sessionManagement(session -> session
.sessionFixation().migrateSession()
);
return http.build();
}
}
// 自定义 Session 并发控制
@Component
public class CustomSessionRegistry {
// 用户名 → Session ID 集合
private final Map<String, Set<String>> userSessions = new ConcurrentHashMap<>();
public void registerSession(String username, String sessionId) {
userSessions.computeIfAbsent(username, k -> ConcurrentHashMap.newKeySet())
.add(sessionId);
}
public void removeSession(String username, String sessionId) {
Set<String> sessions = userSessions.get(username);
if (sessions != null) {
sessions.remove(sessionId);
}
}
/**
* 强制下线指定用户的所有会话
*/
public void expireAllSessions(String username) {
Set<String> sessions = userSessions.remove(username);
if (sessions != null) {
for (String sessionId : sessions) {
// 调用 SessionRegistry 使会话失效
// sessionRegistry.getSessionInformation(sessionId).expireNow();
}
}
}
}
4. 数据安全:加密存储与脱敏展示
4.1 敏感数据加密存储
分层的加密策略:
┌──────────────────────────────────────────────────────┐
│ 敏感数据加密层级 │
├──────────────────────────────────────────────────────┤
│ │
│ Level 1: 密码类(不可逆) │
│ ├── 用户登录密码 → BCrypt / Argon2 │
│ └── 支付密码 → BCrypt / Argon2 │
│ │
│ Level 2: 高敏感数据(可逆,需解密查看) │
│ ├── 身份证号 → AES-256-GCM │
│ ├── 银行卡号 → AES-256-GCM │
│ └── 手机号 → AES-256-GCM(可选,视合规要求) │
│ │
│ Level 3: 中等敏感(可逆,用于数据关联) │
│ ├── 姓名 → AES-256-GCM 或 确定性加密 │
│ └── 地址 → AES-256-GCM │
│ │
│ Level 4: 一般数据(可逆,用于展示) │
│ ├── 邮箱 → AES-256-GCM(可用于发送通知) │
│ └── 昵称 → 明文或加密 │
│ │
└──────────────────────────────────────────────────────┘
AES-256-GCM 加密实现(推荐,自带认证):
@Component
public class AesEncryptionService {
private static final String ALGORITHM = "AES/GCM/NoPadding";
private static final int GCM_IV_LENGTH = 12; // 96 bits
private static final int GCM_TAG_LENGTH = 128; // bits
private final SecretKey secretKey;
public AesEncryptionService(@Value("${encryption.aes-key}") String base64Key) {
byte[] keyBytes = Base64.getDecoder().decode(base64Key);
if (keyBytes.length != 32) {
throw new IllegalArgumentException("AES-256 需要 32 字节密钥");
}
this.secretKey = new SecretKeySpec(keyBytes, "AES");
}
/**
* 加密
* @return Base64(IV + 密文)
*/
public String encrypt(String plaintext) {
try {
// 生成随机 IV(每次加密使用不同 IV)
byte[] iv = new byte[GCM_IV_LENGTH];
SecureRandom.getInstanceStrong().nextBytes(iv);
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec);
byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
// 将 IV 和密文拼接在一起
ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + ciphertext.length);
byteBuffer.put(iv);
byteBuffer.put(ciphertext);
return Base64.getEncoder().encodeToString(byteBuffer.array());
} catch (Exception e) {
throw new RuntimeException("加密失败", e);
}
}
/**
* 解密
*/
public String decrypt(String encryptedBase64) {
try {
byte[] data = Base64.getDecoder().decode(encryptedBase64);
// 分离 IV 和密文
ByteBuffer byteBuffer = ByteBuffer.wrap(data);
byte[] iv = new byte[GCM_IV_LENGTH];
byteBuffer.get(iv);
byte[] ciphertext = new byte[byteBuffer.remaining()];
byteBuffer.get(ciphertext);
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec);
byte[] plaintext = cipher.doFinal(ciphertext);
return new String(plaintext, StandardCharsets.UTF_8);
} catch (AEADBadTagException e) {
throw new SecurityException("数据被篡改或密钥不匹配", e);
} catch (Exception e) {
throw new RuntimeException("解密失败", e);
}
}
}
数据库层面:使用 JPA AttributeConverter 自动加密:
@Converter
public class SensitiveDataConverter implements AttributeConverter<String, String> {
private static AesEncryptionService encryptionService;
// 通过 Spring 注入(需要特殊处理,因为 Converter 不是 Spring Bean)
public static void setEncryptionService(AesEncryptionService service) {
encryptionService = service;
}
@Override
public String convertToDatabaseColumn(String attribute) {
if (attribute == null) return null;
return encryptionService.encrypt(attribute); // 存到数据库前加密
}
@Override
public String convertToEntityAttribute(String dbData) {
if (dbData == null) return null;
return encryptionService.decrypt(dbData); // 从数据库读取后解密
}
}
// 使用
@Entity
public class User {
@Convert(converter = SensitiveDataConverter.class)
@Column(columnDefinition = "TEXT")
private String idCard; // 身份证号:自动加密存储
@Convert(converter = SensitiveDataConverter.class)
private String bankCard; // 银行卡号:自动加密存储
}
4.2 数据脱敏展示
@Component
public class DataMaskingUtil {
/**
* 手机号脱敏:138****1234
*/
public static String maskPhone(String phone) {
if (phone == null || phone.length() != 11) return phone;
return phone.substring(0, 3) + "****" + phone.substring(7);
}
/**
* 身份证号脱敏:320***********1234
*/
public static String maskIdCard(String idCard) {
if (idCard == null || idCard.length() < 8) return idCard;
return idCard.substring(0, 3) + "***********" + idCard.substring(idCard.length() - 4);
}
/**
* 银行卡号脱敏:6222 **** **** 0123
*/
public static String maskBankCard(String cardNo) {
if (cardNo == null || cardNo.length() < 8) return cardNo;
return cardNo.substring(0, 4) + " **** **** " + cardNo.substring(cardNo.length() - 4);
}
/**
* 姓名脱敏:张**
*/
public static String maskName(String name) {
if (name == null || name.length() <= 1) return name;
return name.charAt(0) + "*".repeat(name.length() - 1);
}
/**
* 邮箱脱敏:j***@example.com
*/
public static String maskEmail(String email) {
if (email == null || !email.contains("@")) return email;
String[] parts = email.split("@");
String name = parts[0];
if (name.length() <= 2) {
return name.charAt(0) + "***@" + parts[1];
}
return name.charAt(0) + "***" + name.charAt(name.length() - 1) + "@" + parts[1];
}
}
Jackson 序列化时自动脱敏:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@JacksonAnnotationsInside
@JsonSerialize(using = MaskingSerializer.class)
public @interface MaskData {
MaskType value();
}
public enum MaskType {
PHONE, ID_CARD, BANK_CARD, NAME, EMAIL
}
public class MaskingSerializer extends JsonSerializer<String> {
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider provider)
throws IOException {
// 根据注解类型进行脱敏
MaskData annotation = (MaskData) gen.getOutputContext().getCurrentValue()
.getClass()
.getDeclaredField(gen.getOutputContext().getCurrentName())
.getAnnotation(MaskData.class);
String maskedValue = value;
if (annotation != null && value != null) {
switch (annotation.value()) {
case PHONE: maskedValue = DataMaskingUtil.maskPhone(value); break;
case ID_CARD: maskedValue = DataMaskingUtil.maskIdCard(value); break;
case BANK_CARD: maskedValue = DataMaskingUtil.maskBankCard(value); break;
case NAME: maskedValue = DataMaskingUtil.maskName(value); break;
case EMAIL: maskedValue = DataMaskingUtil.maskEmail(value); break;
}
}
gen.writeString(maskedValue);
}
}
// 使用
public class UserVO {
private Long id;
private String username;
@MaskData(MaskType.PHONE)
private String phone; // 自动脱敏为 138****1234
@MaskData(MaskType.ID_CARD)
private String idCard; // 自动脱敏
}
5. 接口安全:防重放、防暴力破解与限流
5.1 防重放攻击
重放攻击:攻击者截获合法的 API 请求,然后原样重新发送。
@Component
public class ReplayAttackDefender {
private final StringRedisTemplate redisTemplate;
/**
* 基于 Nonce(一次性随机数) + Timestamp 的防重放
*/
public boolean isReplay(HttpServletRequest request) {
String nonce = request.getHeader("X-Nonce");
String timestamp = request.getHeader("X-Timestamp");
// ─── 1. 基础校验 ───
if (nonce == null || timestamp == null) {
return true; // 缺少必要参数,视为可疑
}
// ─── 2. 时间戳校验:超过 5 分钟的请求视为无效 ───
long requestTime = Long.parseLong(timestamp);
long currentTime = System.currentTimeMillis();
if (Math.abs(currentTime - requestTime) > 5 * 60 * 1000) {
return true; // 请求时间戳偏离过大
}
// ─── 3. Nonce 唯一性校验 ───
// 将 nonce 存入 Redis,设置过期时间为 5 分钟
String key = "nonce:" + nonce;
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(key, "1", Duration.ofMinutes(5));
if (Boolean.FALSE.equals(success)) {
return true; // nonce 已被使用,判定为重放
}
return false; // 非重放请求
}
}
5.2 防暴力破解
@Component
public class BruteForceProtector {
private final StringRedisTemplate redisTemplate;
/**
* 滑动窗口限流:每分钟最多 10 次登录尝试
*/
public boolean isBlocked(String key, int maxAttempts, int windowSeconds) {
String redisKey = "brute:" + key;
Long count = redisTemplate.opsForValue().increment(redisKey);
if (count == 1) {
redisTemplate.expire(redisKey, Duration.ofSeconds(windowSeconds));
}
return count != null && count > maxAttempts;
}
/**
* 渐进式锁定:失败次数越多,锁定时间越长
*/
public long getLockoutDuration(int failureCount) {
if (failureCount <= 3) return 0; // 无锁定
if (failureCount == 4) return 1; // 1 分钟
if (failureCount == 5) return 5; // 5 分钟
if (failureCount == 6) return 15; // 15 分钟
if (failureCount <= 10) return 30; // 30 分钟
return 24 * 60; // 24 小时
}
}
// 在过滤器中拦截
@Component
public class BruteForceFilter extends OncePerRequestFilter {
private final BruteForceProtector protector;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 仅对登录接口生效
if (request.getRequestURI().equals("/api/auth/login")) {
String identifier = request.getRemoteAddr(); // 或用户名
if (protector.isBlocked(identifier, 10, 60)) {
response.setStatus(429);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":429,\"message\":\"请求过于频繁,请稍后再试\"}");
return;
}
}
chain.doFilter(request, response);
}
}
5.3 API 限流
// 使用 Bucket4j + Redis 实现分布式限流
@Service
public class RateLimitService {
private final RedissonClient redissonClient;
/**
* 基于令牌桶算法的分布式限流
*
* @param key 限流标识(如 userId 或 IP)
* @param capacity 桶容量(突发允许的最大请求数)
* @param refillRate 填充速率(每秒填充的令牌数)
* @return true = 允许,false = 限流
*/
public boolean tryAcquire(String key, long capacity, long refillRate) {
RRateLimiter limiter = redissonClient.getRateLimiter("ratelimit:" + key);
// 初始化(仅首次执行)
limiter.trySetRate(RateType.OVERALL, capacity, Duration.ofSeconds(1));
return limiter.tryAcquire();
}
}
// 声明式限流注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RateLimit {
String key() default ""; // 限流 Key,支持 SpEL
long capacity() default 10; // 每秒容量
long refillRate() default 10; // 每秒填充
String message() default "请求过于频繁";
}
@Aspect
@Component
public class RateLimitAspect {
private final RateLimitService rateLimitService;
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
// 解析 SpEL 表达式获取 Key
String key = parseKey(rateLimit.key(), joinPoint);
if (!rateLimitService.tryAcquire(key, rateLimit.capacity(), rateLimit.refillRate())) {
throw new TooManyRequestsException(rateLimit.message());
}
return joinPoint.proceed();
}
private String parseKey(String keyExpression, ProceedingJoinPoint joinPoint) {
// 使用 Spring ExpressionParser 解析 SpEL
// 例如: "#userId" → 从方法参数中提取
if (StringUtils.isEmpty(keyExpression)) {
// 默认用方法签名 + IP
return joinPoint.getSignature().toShortString();
}
// ... SpEL 解析逻辑
return keyExpression;
}
}
// 使用
@RestController
public class TransferController {
@PostMapping("/api/transfer")
@RateLimit(key = "#request.userId", capacity = 1, refillRate = 1,
message = "请勿重复提交转账请求")
public ApiResponse transfer(@RequestBody TransferRequest request) {
// 同一个用户每秒最多 1 次转账
}
}
6. 日志安全:审计日志与信息遮蔽
6.1 审计日志
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AuditLog {
String operation(); // 操作类型:LOGIN、TRANSFER、UPDATE_USER
String description(); // 操作描述
boolean recordParams() default true; // 是否记录参数
boolean recordResult() default false; // 是否记录结果
}
@Aspect
@Component
@RequiredArgsConstructor
public class AuditLogAspect {
private final AuditLogService auditLogService;
@Around("@annotation(auditLog)")
public Object around(ProceedingJoinPoint joinPoint, AuditLog auditLog) throws Throwable {
long startTime = System.currentTimeMillis();
// 获取当前用户
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth != null ? auth.getName() : "anonymous";
// 获取请求信息
ServletRequestAttributes attributes = (ServletRequestAttributes)
RequestContextHolder.getRequestAttributes();
String ip = attributes != null ? attributes.getRequest().getRemoteAddr() : "unknown";
String params = auditLog.recordParams()
? sanitizeParams(joinPoint.getArgs()) // ← 脱敏
: "[HIDDEN]";
Object result = null;
String status = "SUCCESS";
String errorMessage = null;
try {
result = joinPoint.proceed();
return result;
} catch (Exception e) {
status = "FAILURE";
errorMessage = e.getMessage();
throw e;
} finally {
long elapsed = System.currentTimeMillis() - startTime;
// 异步写入审计日志(不阻塞业务)
auditLogService.logAsync(AuditLogEntry.builder()
.username(username)
.ip(ip)
.operation(auditLog.operation())
.description(auditLog.description())
.params(params)
.status(status)
.errorMessage(errorMessage)
.elapsedMs(elapsed)
.timestamp(LocalDateTime.now())
.build());
}
}
/**
* 敏感参数脱敏:自动识别含有敏感信息的参数并脱敏
*/
private String sanitizeParams(Object[] args) {
if (args == null || args.length == 0) return "";
try {
ObjectMapper mapper = new ObjectMapper();
// 序列化时对 password、token、secret 等字段替换为 ***
String json = mapper.writeValueAsString(args);
return json
.replaceAll("\"password\"\\s*:\\s*\"[^\"]*\"", "\"password\":\"***\"")
.replaceAll("\"token\"\\s*:\\s*\"[^\"]*\"", "\"token\":\"***\"")
.replaceAll("\"secret\"\\s*:\\s*\"[^\"]*\"", "\"secret\":\"***\"");
} catch (Exception e) {
return "[SERIALIZATION_ERROR]";
}
}
}
6.2 Logback 日志脱敏
<!-- logback-spring.xml -->
<configuration>
<!-- 自定义脱敏转换器 -->
<conversionRule conversionWord="mask"
converterClass="com.example.config.SensitiveDataConverter" />
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<!-- 使用自定义 pattern,%mask 会自动脱敏 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger - %mask%n</pattern>
</encoder>
</appender>
</configuration>
// 自定义 Logback MessageConverter
public class SensitiveDataConverter extends MessageConverter {
// 需要脱敏的正则模式
private static final Pattern[] SENSITIVE_PATTERNS = {
// 手机号
Pattern.compile("(1[3-9]\\d)\\d{4}(\\d{4})"),
// 身份证号
Pattern.compile("(\\d{6})\\d{8}(\\d{4}[\\dXx])"),
// 银行卡号
Pattern.compile("(\\d{4})\\d{8,12}(\\d{4})"),
// 密码字段
Pattern.compile("(\"password\"\\s*[:=]\\s*\"?)[^\"&,\\s}]+"),
// Token
Pattern.compile("(eyJ[A-Za-z0-9_-]+)\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+"),
};
@Override
public String convert(ILoggingEvent event) {
String message = event.getFormattedMessage();
message = message
.replaceAll(SENSITIVE_PATTERNS[0].pattern(), "$1****$2") // 手机号
.replaceAll(SENSITIVE_PATTERNS[1].pattern(), "$1********$2") // 身份证
.replaceAll(SENSITIVE_PATTERNS[2].pattern(), "$1 **** **** $2") // 银行卡
.replaceAll(SENSITIVE_PATTERNS[3].pattern(), "$1***") // 密码
.replaceAll(SENSITIVE_PATTERNS[4].pattern(), "$1.***.***"); // JWT
return message;
}
}
6.3 日志安全规则
✅ 应该记录:
- 所有认证事件(登录、登出、刷新 Token)
- 所有敏感操作(转账、修改密码、权限变更)
- 所有异常的访问(403、401)
- 系统启动/关闭事件
- 配置变更
❌ 不应该记录:
- 密码明文
- 完整的银行卡号、身份证号
- 完整的 JWT Token
- 安全问题的答案
- 支付密码/PIN码
- 短信验证码
⚠️ 日志安全:
- 日志文件权限 640
- 定期轮转和归档
- 日志中敏感信息自动脱敏
- 审计日志不可删除(防止内鬼销毁证据)
- 日志输出到独立的安全服务器
7. SQL 注入、XSS、CSRF 防御
7.1 SQL 注入防御
// ❌ 危险:字符串拼接
String sql = "SELECT * FROM users WHERE username = '" + username + "'";
// 输入: ' OR '1'='1' --
// 结果: SELECT * FROM users WHERE username = '' OR '1'='1' --'
// ✅ 方式一:PreparedStatement(参数化查询)
String sql = "SELECT * FROM users WHERE username = ?";
PreparedStatement ps = connection.prepareStatement(sql);
ps.setString(1, username);
// ✅ 方式二:MyBatis 中使用 #{}(不是 ${})
// #{} → 参数化查询,安全
@Select("SELECT * FROM users WHERE username = #{username}")
User findByUsername(String username);
// ❌ ${} → 直接拼接,危险!
@Select("SELECT * FROM users WHERE username = '${username}'") // 危险!
// ✅ 方式三:JPA Criteria API
Specification<User> spec = (root, query, cb) ->
cb.equal(root.get("username"), username);
// ✅ 方式四:动态排序/表名时,使用白名单
private static final Set<String> ALLOWED_COLUMNS = Set.of("id", "username", "createTime");
private static final Set<String> ALLOWED_DIRECTIONS = Set.of("ASC", "DESC");
public List<User> findWithOrder(String orderBy, String direction) {
if (orderBy == null || !ALLOWED_COLUMNS.contains(orderBy)) {
throw new IllegalArgumentException("无效的排序字段: " + orderBy);
}
if (direction == null || !ALLOWED_DIRECTIONS.contains(direction.toUpperCase())) {
throw new IllegalArgumentException("无效的排序方向: " + direction);
}
// 此时可以安全拼接
return mapper.findWithOrder(orderBy, direction.toUpperCase());
}
7.2 XSS 防御
// ─── 输入过滤 ───
@Component
public class XssFilter {
public String sanitize(String input) {
if (input == null) return null;
return input
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'")
.replace("/", "/");
}
}
// ─── JSON 反序列化时过滤 ───
@Component
public class XssStringDeserializer extends JsonDeserializer<String> {
@Override
public String deserialize(JsonParser p, DeserializationContext ctxt)
throws IOException {
String value = p.getValueAsString();
if (value == null) return null;
// 使用 OWASP Java HTML Sanitizer
return PolicyFactoryInstance.POLICY.sanitize(value);
}
}
// 在字段上使用
public class CreateUserRequest {
@JsonDeserialize(using = XssStringDeserializer.class)
private String username; // 自动过滤 XSS
@JsonDeserialize(using = XssStringDeserializer.class)
private String nickname;
}
// ─── 响应头设置 ───
http.headers(headers -> headers
.xssProtection(xss -> xss
.headerValue(XXssProtectionHeaderWriter.HeaderValue.ENABLED_MODE_BLOCK)
)
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; script-src 'self'")
)
);
7.3 CSRF 防御
// ─── 场景一:传统 Web 应用(使用 Session) ───
// Spring Security 默认启用 CSRF 保护
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
);
// 前端需要在请求头中携带 CSRF Token
// 方式 1: 在 <form> 中隐藏字段(Thymeleaf 自动处理)
// 方式 2: AJAX 请求中设置 X-CSRF-TOKEN 头
fetch('/api/users', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="_csrf"]').content,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
// ─── 场景二:前后端分离(JWT) ───
// JWT 本身不是 CSRF 漏洞的自动解药!
// 仍然需要防范 CSRF
// 方案 A: 自定义请求头验证(最简单)
http.csrf(csrf -> csrf
.requireCsrfProtectionMatcher(request -> {
// 如果有自定义头 X-Requested-With,不做 CSRF 检查
// 因为跨域请求无法设置自定义头(浏览器同源策略)
String header = request.getHeader("X-Requested-With");
return !"XMLHttpRequest".equals(header);
})
);
// 方案 B: SameSite Cookie(最推荐)
// 结合 Refresh Token 存储在 HttpOnly + SameSite=Strict Cookie
ResponseCookie cookie = ResponseCookie.from("refresh_token", token)
.sameSite("Strict") // ← 阻止跨站请求携带 Cookie
.httpOnly(true)
.secure(true)
.build();
// 方案 C: Double Submit Cookie Pattern
// 服务端设置一个随机 Token 在 Cookie 和非 HttpOnly Cookie 中
// 前端读取非 HttpOnly Cookie 的值,放到请求头
// 服务端比较两个值是否一致
8. 安全检查清单(可直接使用)
8.1 传输层安全
8.2 认证安全
8.3 授权安全
8.4 数据安全
8.5 接口安全
8.6 代码安全
8.7 日志与监控
8.8 基础设施
总结
金融系统安全是一个系统工程,不是加几个注解就能解决的。作为后端开发者,心中要时刻有这根弦:
你的每个疏忽,都可能导致用户的财产损失。
记住以下核心原则:
- 零信任——不信任任何输入,即使是"内部"的
- 最小权限——每个模块、每个用户只拥有最小必要权限
- 纵深防御——多层防护,即使一层被突破也不致命
- 安全默认——默认配置就是安全的配置
- 持续改进——安全是过程,不是结果
建议将本文的 Checklist 打印出来贴在你的工位上,每次代码 Review 时逐条对照。