@TOC
Spring Boot 3.x金融系统安全实战:JWT双Token、接口防刷与敏感数据加密,面试直接拿满分
面试官问你:"金融系统的接口怎么防刷?JWT Token泄露了怎么办?敏感数据存数据库安全吗?"
你说:"用Spring Security拦截一下,Token过期时间设短一点,数据库加个密就行。"
面试官笑笑:"下一位。"
上两篇文章我们搞定了JWT鉴权和RBAC权限模型的落地,但那些只是安全体系的"骨架"。今天的主题是三个能让你的方案直接拿满分的"血肉":JWT双Token无感刷新、接口防刷的滑动窗口算法、敏感数据的AES+RSA混合加密。这些不是八股文,是真实金融系统每天都在跑的硬核方案。
1. JWT双Token机制:告别重新登录的尴尬
1.1 痛点场景
你正在操作转账,刚填完金额点确认,系统提示"Token已过期,请重新登录"。跳转登录页、输入密码、重新操作——这体验,用户直接卸载。
有人会说:"那把Token过期时间设长一点不就行了?" 行,你设个24小时,Token一旦泄露,攻击者有整整一天时间作恶。
真正的解决方案是:双Token机制,Access Token短期有效(如15分钟),Refresh Token长期有效(如7天),当Access Token过期时,前端自动用Refresh Token换一个新的Access Token,用户完全无感知。
1.2 核心设计思路
整个流程分三步走:
第一步:用户登录成功后,服务端同时返回两个Token,Access Token(AT)给前端用,Refresh Token(RT)加密存到数据库,前端只存RID(Refresh Token对应的唯一标识)。
第二步:前端每次请求带AT,AT过期返回401时,前端拦截器自动用RID去换新的AT。
第三步:如果RT也过期或被吊销,才真正跳转登录页。
关键设计:Access Token不存数据库,完全靠签名验证,减轻服务端压力。Refresh Token存数据库,可以随时吊销。
1.3 完整代码实现
TokenPair实体类:
package com.security.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Refresh Token的唯一标识,暴露给前端
@Column(unique = true, nullable = false)
private String tokenId;
// 真实的Refresh Token,加密存储,永远不暴露给前端
@Column(nullable = false, length = 500)
private String encryptedToken;
@Column(nullable = false)
private String username;
// 设备指纹信息,防止Token被移植到其他设备使用
private String deviceInfo;
@Column(nullable = false)
private LocalDateTime expiryDate;
private boolean revoked = false;
// 省略getter/setter和构造方法
public RefreshToken(String tokenId, String encryptedToken,
String username, long ttlSeconds, String deviceInfo) {
this.tokenId = tokenId;
this.encryptedToken = encryptedToken;
this.username = username;
this.deviceInfo = deviceInfo;
this.expiryDate = LocalDateTime.now().plusSeconds(ttlSeconds);
this.revoked = false;
}
}
为什么用tokenId而不直接把Refresh Token给前端?因为Refresh Token本身就是一把钥匙,直接暴露风险太大。tokenId只是一个UUID,通过它配合设备指纹才能换到真正的Token。
JWT Token生成服务:
package com.security.service;
import com.security.entity.RefreshToken;
import com.security.repository.RefreshTokenRepository;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.*;
@Service
public class TokenService {
@Value("${jwt.access.secret}")
private String accessSecret;
@Value("${jwt.access.ttl:900}")
private long accessTtl; // 15分钟
@Value("${jwt.refresh.ttl:604800}")
private long refreshTtl; // 7天
private final RefreshTokenRepository refreshTokenRepository;
public TokenService(RefreshTokenRepository refreshTokenRepository) {
this.refreshTokenRepository = refreshTokenRepository;
}
/**
* 登录成功后生成双Token
*/
public Map<String, String> generateTokenPair(String username,
String deviceInfo) {
// 1. 生成Access Token(短有效期)
String accessToken = generateAccessToken(username);
// 2. 生成Refresh Token(长有效期)
String tokenId = UUID.randomUUID().toString();
String rawRefreshToken = generateRandomToken(64);
// 3. Refresh Token加密后存数据库
String encryptedRefreshToken = AesEncryptor.encrypt(rawRefreshToken);
RefreshToken entity = new RefreshToken(
tokenId, encryptedRefreshToken, username, refreshTtl, deviceInfo
);
refreshTokenRepository.save(entity);
// 4. 返回结果:AT和RID(注意不是RT本身)
Map<String, String> result = new HashMap<>();
result.put("accessToken", accessToken);
result.put("refreshTokenId", tokenId);
result.put("expiresIn", String.valueOf(accessTtl));
return result;
}
/**
* 使用RefreshTokenId换新的Access Token
*/
public Map<String, String> refreshAccessToken(String refreshTokenId,
String deviceInfo) {
// 1. 从数据库查找RefreshToken
RefreshToken stored = refreshTokenRepository
.findByTokenIdAndRevokedFalse(refreshTokenId)
.orElseThrow(() -> new RuntimeException("Refresh token not found"));
// 2. 检查是否过期
if (stored.getExpiryDate().isBefore(java.time.LocalDateTime.now())) {
throw new RuntimeException("Refresh token expired, please login again");
}
// 3. 验证设备指纹(防止Token被移植到其他设备)
if (!deviceInfo.equals(stored.getDeviceInfo())) {
// 设备不匹配,可能是Token被盗,直接吊销
stored.setRevoked(true);
refreshTokenRepository.save(stored);
throw new RuntimeException("Device mismatch, token revoked");
}
// 4. Token轮转:旧的RefreshToken失效,生成新的
// 这是为了防止RefreshToken泄露后被重复使用
stored.setRevoked(true);
refreshTokenRepository.save(stored);
// 5. 生成新的TokenPair返回
return generateTokenPair(stored.getUsername(), deviceInfo);
}
private String generateAccessToken(String username) {
SecretKey key = Keys.hmacShaKeyFor(
accessSecret.getBytes(StandardCharsets.UTF_8)
);
Date now = new Date();
Date expiry = new Date(now.getTime() + accessTtl * 1000);
return Jwts.builder()
.setSubject(username)
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(key)
.compact();
}
private String generateRandomToken(int length) {
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[length];
random.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(bytes);
}
}
前端拦截器逻辑(Vue 3 Axios示例):
// request.js
import axios from 'axios';
import router from '@/router';
let isRefreshing = false;
let failedQueue = [];
// 处理队列中的失败请求
const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
axios.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
// AT过期返回401,尝试用RT刷新
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// 正在刷新中,把请求加入队列等待
return new Promise((resolve, reject) => {
failedQueue.push({resolve, reject});
}).then(token => {
originalRequest.headers['Authorization'] = `Bearer ${token}`;
return axios(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const rid = localStorage.getItem('refreshTokenId');
const deviceInfo = navigator.userAgent; // 实际项目中用更精准的设备指纹
const { data } = await axios.post('/api/auth/refresh', {
refreshTokenId: rid,
deviceInfo: deviceInfo
});
// 更新存储的Token
localStorage.setItem('token', data.accessToken);
localStorage.setItem('refreshTokenId', data.refreshTokenId);
// 处理队列中的请求
processQueue(null, data.accessToken);
originalRequest.headers['Authorization'] =
`Bearer ${data.accessToken}`;
return axios(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
// RT也过期了,跳转登录页
localStorage.clear();
router.push('/login');
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
避坑重点:刷新Token时一定要加锁(isRefreshing标识),否则多个并发请求会同时触发刷新,产生大量无效的刷新请求。还要维护一个失败队列,等刷新完成后按顺序重试这些请求。
1.4 Token轮转为什么必须做
来看一个攻击场景:攻击者通过XSS拿到了你的RefreshTokenId,赶在你之前用它换了新的AT。如果服务端不失效旧的RefreshToken,攻击者可以一直刷新,你反而成了"被踢下线"的那个。
Token轮转机制:每次使用RefreshToken刷新时,旧的直接标记为revoked,同时生成一个新的RefreshToken。这样,原本持有旧Token的攻击者就无法继续刷新了。谁的RefreshToken是最新的,谁就是合法用户。
2. 接口防刷实战:滑动窗口算法的正确姿势
2.1 问题分析
固定窗口限流有"临界点"漏洞。比如限制每分钟100次请求,用户在12:00:59秒发了100个请求,12:01:00秒又发100个。实际2秒内发了200个,系统全接了。
金融系统不能用固定窗口,得用滑动窗口。简单说就是把时间窗口划得更细,看当前时间往前推N分钟内的请求总数。
Redis的ZSET是实现滑动窗口的最佳选择:member是请求的唯一标识,score是时间戳。
2.2 滑动窗口限流器实现
package com.security.ratelimit;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.concurrent.TimeUnit;
@Component
public class SlidingWindowRateLimiter {
private final RedisTemplate<String, String> redisTemplate;
public SlidingWindowRateLimiter(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 滑动窗口限流
* @param key 限流标识(如:用户ID+接口路径)
* @param limit 窗口内允许的最大请求数
* @param windowSeconds 窗口大小(秒)
* @return true表示允许,false表示被限流
*/
public boolean isAllowed(String key, int limit, int windowSeconds) {
long now = Instant.now().getEpochSecond();
long windowStart = now - windowSeconds;
String redisKey = "rate_limit:" + key;
// 1. 移除窗口外的过期记录
redisTemplate.opsForZSet()
.removeRangeByScore(redisKey, 0, windowStart);
// 2. 统计当前窗口内的请求数
Long currentCount = redisTemplate.opsForZSet()
.count(redisKey, windowStart, now);
// 3. 判断是否超限
if (currentCount != null && currentCount >= limit) {
return false;
}
// 4. 添加当前请求记录(用纳秒时间戳避免member重复)
String member = now + "-" + System.nanoTime();
redisTemplate.opsForZSet().add(redisKey, member, now);
// 5. 设置Key过期时间(窗口时间+1秒的缓冲)
redisTemplate.expire(redisKey, windowSeconds + 1, TimeUnit.SECONDS);
return true;
}
}
为什么用ZSET而不是简单计数器?因为ZSET能精确删除窗口外的旧数据,而计数器只能等Key整体过期,无法区分窗口内和窗口外的请求。
2.3 注解式限流拦截器
写完限流器还要让它能用起来。搞个注解,在需要防刷的接口上标注就行:
package com.security.annotation;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
// 窗口大小(秒)
int window() default 60;
// 限制次数
int limit() default 100;
// 限流标识前缀
String prefix() default "default";
}
AOP拦截器:
package com.security.aspect;
import com.security.annotation.RateLimit;
import com.security.ratelimit.SlidingWindowRateLimiter;
import com.security.util.IpUtil;
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
@Aspect
@Component
public class RateLimitAspect {
private final SlidingWindowRateLimiter rateLimiter;
public RateLimitAspect(SlidingWindowRateLimiter rateLimiter) {
this.rateLimiter = rateLimiter;
}
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint point,
RateLimit rateLimit) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.currentRequestAttributes()).getRequest();
// 构建限流Key:注解prefix + 客户端IP(实际项目建议用用户ID)
String ip = IpUtil.getClientIp(request);
String key = rateLimit.prefix() + ":" + ip;
if (!rateLimiter.isAllowed(key, rateLimit.limit(),
rateLimit.window())) {
throw new RuntimeException("请求过于频繁,请稍后再试");
}
return point.proceed();
}
}
实际使用:
@RestController
@RequestMapping("/api/transfer")
public class TransferController {
// 转账接口,1分钟内最多3次
@PostMapping
@RateLimit(prefix = "transfer", window = 60, limit = 3)
public Result transfer(@RequestBody TransferRequest request) {
// 转账业务逻辑
return Result.success();
}
// 查询接口,1分钟内最多100次
@GetMapping("/balance")
@RateLimit(prefix = "balance", window = 60, limit = 100)
public Result queryBalance() {
// 查询余额逻辑
return Result.success();
}
}
这种方式,不同接口的限流策略互不影响,改配置加注解就行,不用动业务代码。
3. 敏感数据加密:AES+RSA混合加密方案
3.1 为什么不能只用一种加密
AES加密速度快,适合大量数据,但密钥管理是个问题——密钥存哪儿?写死在代码里?那代码泄露密钥也泄露。每个环境用不同密钥?那密钥分发又成了问题。
RSA是非对称加密,公钥加密私钥解密,天然解决密钥分发问题。但RSA加解密速度慢,不适合大量数据。
最佳实践是混合加密:用RSA加密AES密钥,用AES加密业务数据。这样既解决了密钥分发问题,又保证了加解密性能。
3.2 完整加密工具类
package com.security.encryption;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
public class HybridEncryptor {
private static final String AES_ALGORITHM = "AES/GCM/NoPadding";
private static final int AES_KEY_SIZE = 256;
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128;
/**
* 生成RSA密钥对
*/
public static KeyPair generateRSAKeyPair() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
}
/**
* 加密敏感数据(混合加密入口)
* @param plainText 原始数据
* @param publicKey RSA公钥(用于加密AES密钥)
* @return 加密结果:RSA加密的AES密钥 + AES加密的数据
*/
public static EncryptedData encrypt(String plainText,
PublicKey publicKey) throws Exception {
// 1. 生成随机AES密钥
SecretKey aesKey = generateAESKey();
// 2. 生成随机IV
byte[] iv = new byte[GCM_IV_LENGTH];
SecureRandom random = new SecureRandom();
random.nextBytes(iv);
// 3. 用AES加密业务数据
Cipher aesCipher = Cipher.getInstance(AES_ALGORITHM);
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
aesCipher.init(Cipher.ENCRYPT_MODE, aesKey, spec);
byte[] encryptedData = aesCipher.doFinal(
plainText.getBytes(java.nio.charset.StandardCharsets.UTF_8)
);
// 4. 用RSA公钥加密AES密钥
Cipher rsaCipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
rsaCipher.init(Cipher.ENCRYPT_MODE, publicKey);
byte[] encryptedAESKey = rsaCipher.doFinal(aesKey.getEncoded());
// 5. 打包返回
EncryptedData result = new EncryptedData();
result.setEncryptedAesKey(Base64.getEncoder().encodeToString(encryptedAESKey));
result.setIv(Base64.getEncoder().encodeToString(iv));
result.setEncryptedData(Base64.getEncoder().encodeToString(encryptedData));
return result;
}
/**
* 解密敏感数据
* @param encryptedData 加密数据包
* @param privateKey RSA私钥
* @return 原始明文
*/
public static String decrypt(EncryptedData encryptedData,
PrivateKey privateKey) throws Exception {
// 1. RSA解密AES密钥
byte[] encryptedAesKey = Base64.getDecoder()
.decode(encryptedData.getEncryptedAesKey());
Cipher rsaCipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
rsaCipher.init(Cipher.DECRYPT_MODE, privateKey);
byte[] aesKeyBytes = rsaCipher.doFinal(encryptedAesKey);
SecretKey aesKey = new javax.crypto.spec.SecretKeySpec(
aesKeyBytes, "AES"
);
// 2. 取出IV
byte[] iv = Base64.getDecoder().decode(encryptedData.getIv());
// 3. AES解密数据
Cipher aesCipher = Cipher.getInstance(AES_ALGORITHM);
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
aesCipher.init(Cipher.DECRYPT_MODE, aesKey, spec);
byte[] decrypted = aesCipher.doFinal(
Base64.getDecoder().decode(encryptedData.getEncryptedData())
);
return new String(decrypted, java.nio.charset.StandardCharsets.UTF_8);
}
private static SecretKey generateAESKey() throws Exception {
KeyGenerator generator = KeyGenerator.getInstance("AES");
generator.init(AES_KEY_SIZE);
return generator.generateKey();
}
/**
* 加密数据包装类
*/
public static class EncryptedData {
private String encryptedAesKey; // RSA加密的AES密钥
private String iv; // AES的IV向量
private String encryptedData; // AES加密的业务数据
// getter/setter省略
}
}
为什么选择AES-GCM模式而不是CBC?GCM是有认证的加密模式,不仅能保证机密性,还能检测数据是否被篡改。你不想攻击者改了密文,解密出一段合法但不正确的内容吧?
3.3 敏感字段自动加解密
数据库存密文、业务层用明文,这个转换不能靠手动调用。用MyBatis的TypeHandler自动处理:
package com.security.config;
import com.security.encryption.HybridEncryptor;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.sql.*;
public class EncryptedFieldHandler extends BaseTypeHandler<String> {
// 实际项目中从配置中心或密钥管理服务获取
private static PublicKey publicKey;
private static PrivateKey privateKey;
static {
try {
// 初始化密钥(实际项目从密钥管理服务加载)
java.security.KeyPair keyPair = HybridEncryptor.generateRSAKeyPair();
publicKey = keyPair.getPublic();
privateKey = keyPair.getPrivate();
} catch (Exception e) {
throw new RuntimeException("Failed to init encryption keys", e);
}
}
@Override
public void setNonNullParameter(PreparedStatement ps, int i,
String parameter, JdbcType jdbcType)
throws SQLException {
try {
// 写入数据库前加密
HybridEncryptor.EncryptedData encrypted =
HybridEncryptor.encrypt(parameter, publicKey);
// 序列化为JSON存储
String encryptedJson = toJson(encrypted);
ps.setString(i, encryptedJson);
} catch (Exception e) {
throw new SQLException("Encryption failed", e);
}
}
@Override
public String getNullableResult(ResultSet rs, String columnName)
throws SQLException {
String encryptedJson = rs.getString(columnName);
if (encryptedJson == null) return null;
try {
HybridEncryptor.EncryptedData data = fromJson(encryptedJson);
return HybridEncryptor.decrypt(data, privateKey);
} catch (Exception e) {
throw new SQLException("Decryption failed", e);
}
}
// getNullableResult by index 和 CallableStatement的实现类似,此处省略
// toJson和fromJson用Jackson序列化,此处省略
}
实体类配置:
@Entity
@Table(name = "user_financial_info")
public class UserFinancialInfo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 银行卡号自动加解密
@Column(name = "bank_card_number")
@org.apache.ibatis.type.MappedTypes(String.class)
@org.apache.ibatis.type.MappedJdbcTypes(JdbcType.VARCHAR)
private String bankCardNumber;
// 身份证号自动加解密
@Column(name = "id_card")
private String idCard;
// 普通字段不加密
private String bankName;
}
这样业务代码完全不用关心加解密逻辑,MyBatis在读写数据库时自动处理。开发人员操作的都是明文,数据库存的都是密文。
4. 审计日志:金融合规的最后一道防线
4.1 审计日志的要求
金融监管很严格,要求所有敏感操作必须留痕:谁、什么时间、操作了什么、操作前的数据是什么、操作后的数据是什么。而且日志不能被篡改。
开源方案ELK能存日志,但不防篡改。直接用数据库表存,用触发器或AOP写入。
4.2 审计日志AOP实现
package com.security.audit;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auditable {
String action(); // 操作类型:TRANSFER, WITHDRAW, UPDATE_INFO
String description(); // 操作描述
boolean recordParams() default true; // 是否记录入参
boolean recordResult() default false; // 是否记录返回值
}
审计实体:
package com.security.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "audit_logs")
public class AuditLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String username; // 操作人
@Column(nullable = false)
private String action; // 操作类型
private String description; // 操作描述
@Column(columnDefinition = "TEXT")
private String requestParams; // 请求参数(JSON)
@Column(columnDefinition = "TEXT")
private String responseData; // 响应数据(JSON)
private String clientIp; // 客户端IP
private String userAgent; // 用户代理
@Column(nullable = false)
private LocalDateTime operateTime; // 操作时间
private Long costTime; // 耗时(毫秒)
private Boolean success; // 是否成功
@Column(columnDefinition = "TEXT")
private String errorMessage; // 错误信息
// 数据摘要,用于防篡改校验
@Column(length = 64)
private String dataHash;
// getter/setter和方法省略
/**
* 计算数据摘要,防止日志被篡改
*/
public void calculateHash() {
String data = username + action + description +
requestParams + operateTime.toString();
this.dataHash = SHA256.hash(data);
}
}
AOP切面:
package com.security.aspect;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.security.annotation.Auditable;
import com.security.entity.AuditLog;
import com.security.repository.AuditLogRepository;
import com.security.util.IpUtil;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.scheduling.annotation.Async;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
@Aspect
@Component
public class AuditAspect {
private final AuditLogRepository auditLogRepository;
private final ObjectMapper objectMapper;
public AuditAspect(AuditLogRepository auditLogRepository,
ObjectMapper objectMapper) {
this.auditLogRepository = auditLogRepository;
this.objectMapper = objectMapper;
}
@Around("@annotation(auditable)")
public Object around(ProceedingJoinPoint point,
Auditable auditable) throws Throwable {
// 1. 构建审计日志基础信息
AuditLog log = new AuditLog();
log.setUsername(SecurityContextHolder.getContext()
.getAuthentication().getName());
log.setAction(auditable.action());
log.setDescription(auditable.description());
log.setOperateTime(java.time.LocalDateTime.now());
// 获取请求信息
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
log.setClientIp(IpUtil.getClientIp(attributes.getRequest()));
log.setUserAgent(attributes.getRequest().getHeader("User-Agent"));
}
// 2. 记录请求参数
if (auditable.recordParams()) {
Object[] args = point.getArgs();
// 过滤掉HttpServletRequest等非业务参数
Object[] filteredArgs = filterNonBusinessArgs(args);
log.setRequestParams(objectMapper.writeValueAsString(filteredArgs));
}
// 3. 执行目标方法
long startTime = System.currentTimeMillis();
try {
Object result = point.proceed();
log.setSuccess(true);
log.setCostTime(System.currentTimeMillis() - startTime);
if (auditable.recordResult()) {
log.setResponseData(objectMapper.writeValueAsString(result));
}
return result;
} catch (Exception e) {
log.setSuccess(false);
log.setErrorMessage(e.getMessage());
log.setCostTime(System.currentTimeMillis() - startTime);
throw e;
} finally {
// 4. 计算防篡改摘要
log.calculateHash();
// 5. 异步保存审计日志,不影响主流程性能
saveAuditLog(log);
}
}
@Async
public void saveAuditLog(AuditLog log) {
auditLogRepository.save(log);
}
private Object[] filterNonBusinessArgs(Object[] args) {
return java.util.Arrays.stream(args)
.filter(arg -> !(arg instanceof jakarta.servlet.http.HttpServletRequest))
.filter(arg -> !(arg instanceof jakarta.servlet.http.HttpServletResponse))
.toArray();
}
}
SHA256工具类:
package com.security.entity;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
public class SHA256 {
public static String hash(String data) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(data.getBytes(StandardCharsets.UTF_8));
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString();
} catch (Exception e) {
throw new RuntimeException("SHA256 hash failed", e);
}
}
}
4.3 使用示例
@RestController
@RequestMapping("/api/finance")
public class FinanceController {
@PostMapping("/transfer")
@Auditable(
action = "TRANSFER",
description = "用户转账操作",
recordParams = true,
recordResult = true
)
@RateLimit(prefix = "transfer", window = 60, limit = 3)
public Result transfer(@RequestBody TransferRequest request) {
// 转账业务
return Result.success();
}
}
关键细节:审计日志用@Async异步写入,不能因为记录日志拖慢主业务流程。有人担心异步丢失——相比丢失日志,丢失用户资金更可怕。异步写入已经足够可靠,真不放心可以用MQ做缓冲。
5. 性能对比与综合配置
5.1 加密方案性能对比
| 方案 | 加密1KB数据耗时 | 加密1MB数据耗时 | 适用场景 | |------|---------------|---------------|---------| | 纯RSA加密 | 15ms | 15000ms | 极少数据量 | | 纯AES加密 | 0.5ms | 50ms | 大量数据,密钥管理麻烦 | | AES+RSA混合 | 1ms | 55ms | 生产环境推荐方案 |
混合加密的RSA只用来加密32字节的AES密钥,所以性能损耗极小。AES加密业务数据的速度和纯AES几乎一样。
5.2 application.yml完整配置
jwt:
access:
secret: ${JWT_ACCESS_SECRET:your-256-bit-secret-key-here-must-be-long}
ttl: 900 # 15分钟
refresh:
ttl: 604800 # 7天
encryption:
rsa:
public-key-path: /etc/secrets/public.pem
private-key-path: /etc/secrets/private.pem
audit:
async:
core-pool-size: 2
max-pool-size: 5
queue-capacity: 500
logging:
level:
com.security.audit: DEBUG
生产环境安全提醒:JWT密钥绝对不能硬编码在配置文件中,必须通过环境变量或密钥管理服务注入。证书文件也要设置严格的访问权限(chmod 600)。
总结与展望
今天我们深入了金融安全的三块硬骨头:
- JWT双Token机制:Access Token短期有效+Refresh Token可
1万+

被折叠的 条评论
为什么被折叠?



