Spring Boot 3.x金融系统安全实战:JWT双Token、接口防刷与敏感数据加密,面试直接拿满分

@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)。


总结与展望

今天我们深入了金融安全的三块硬骨头:

  1. JWT双Token机制:Access Token短期有效+Refresh Token可
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值