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

Java实战:基于RSA的接口签名验证完整实现与安全设计 在分布式系统与微服务架构中,保障API通信的安全性是核心挑战。接口签名验证作为一种关键的安全机制,其核心原理在于利用非对称加密算法(如RSA)确保请求的完整性与来源可信性。该技术通过客户端使用私钥对请求参数摘要进行签名,服务端使用公钥验签,有效防御了数据篡改与身份伪造。其技术价值在于为服务间通信提供了强身份认证和数据完整性校验,是构建安全微服务、开放平台及支付系统的基石。典型的应用场景包括支付接口回调、开放API授权以及内部服务间的安全调用。本文以RSA算法为例,深入剖析了密钥管理、签名生成与验证的完整流程 阅读详情

@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可
Java随机数生成器选型与实战:从Math.random到SecureRandom全解析 随机数生成是软件系统中基础却关键的技术能力,涉及伪随机数生成器(PRNG)原理、线程安全机制、密码学安全性及JVM底层实现。理解Linear Congruential Generator(LCG)算法、种子(seed)控制、熵源依赖等核心概念,有助于规避高并发重复、安全密钥可预测、容器化阻塞等典型故障。在Java生态中,Math.random()、java.util.Random、ThreadLocalRandom和SecureRandom分别适用于单线程测试、多线程高性能场景、可重现逻辑与密码学安全需求。 阅读详情

相关推荐

Transformer 常见面试题深度解析:从原理到工程实践

先给结论,再展开推导:面试官时间有限,先一句话回答核心,再补充细节;公式与直觉并重:能推导dk\sqrt{d_k}dk​​的方差来源,也能用"防止 softmax 饱和"的直觉解释;主动关联工程实践:提到 FlashAttention、KV Cache、GQA 等,体现落地经验;诚实面对不确定:不清楚的细节不要编造,可以说"这部分我了解思路,具体实现需要再确认";准备一个完整项目故事:能讲清"遇到什么问题 → 如何定位 → 用什么方案 → 效果如何量化"。

张晨光老师的播客 276

Spring Boot微服务安全实战与面试指南

微服务架构下的安全防护是Java开发者必须掌握的核心技能。从基础的Spring Security配置到OAuth2、JWT等认证协议,安全机制的设计直接影响系统可靠性。本文通过电商项目实战案例,详解如何实现服务间安全通信、权限控制等关键功能,特别针对面试常见考点如JWT安全实践、OAuth2模式选择等提供解决方案。内容涵盖Spring Boot 3.x最新安全特性、mTLS在服务网格中的应用以及生产环境中的纵深防御体系,帮助开发者构建符合金融级要求的安全架构。

weixin_42525385的博客 238

【信息科学与工程学】计算机科学与自动化——第一百九十八篇 高并发高可用的知识点01

以上补充约 110 行,涵盖分布式协调、服务网格细节、数据库高级特性、性能分析工具、混沌工程、安全渗透、合规审计、研发效能、新兴技术、架构模式、实用工具及经典论文。涵盖数据库索引与锁、NoSQL、前端性能、安全攻防、压测工具深入、监控深入、云原生存储、服务治理扩展、代码质量、DevOps 文化、行业标准、软技能及未来趋势。好的,继续补充更多高并发、高可用、高可靠的知识与技术,涵盖更深层的数据库内部机制、分布式事务、服务治理、运维自动化、安全与前沿技术等领域。

weixin_49199313的博客 735

【中级Java面试总结】

本文整理了Java开发面试中的常见技术问题,内容涵盖数据库优化、并发控制、设计模式、多线程、Redis、RabbitMQ等多个技术领域。主要包括:数据库查询优化方案、高并发场景下的座位预约设计、防止重复提交的方法、工厂模式解耦原理、线程池配置策略、Redis集群部署方案、消息队列可靠性保证等。同时涉及发散性问题如AI工具应用、系统集成注意事项,以及前端数据传递和性能优化方案。最后分享了针对不同面试官(HR/技术/领导)的自我介绍技巧和群面应对策略,强调项目经验与软实力的差异化展示。全文系统梳理了Java开发

qq_47769481的博客 563

21 硬件工程师笔面试高频考点真题解析——单片机

本文系统介绍了单片机的核心概念与应用知识,包括:单片机定义与选型要点(性能、存储、外设等);核心参数(主频、I/O、通信接口等);最小系统组成(电源、复位、晶振、BOOT);常见外设(GPIO、ADC、定时器等);GPIO工作模式(输入/输出);死机原因分析(硬件干扰、软件错误等);上电故障排查流程(电源、时钟、程序等);与DSP、FPGA的对比(控制vs运算vs可编程);常用通信总线特点(UART、I2C、SPI、CAN);看门狗功能(超时复位防死机)。内容涵盖单片机硬件设计关键要点,适合嵌入式开发者参考

XU157303764的博客 1314

时间卷积网络(TCN):结构+pytorch代码

文章目录TCNTCN结构1-D FCN的结构因果卷积(Causal Convolutions)膨胀因果卷积(Dilated Causal Convolutions)膨胀非因果卷积(Dilated Non-Causal Convolutions)残差块结构pytorch代码讲解 TCN   TCN(Temporal Convolutional Network)是由Shaojie Bai et al....

Leon_winter的博客 11万+

工作中使用到的单词(软件开发)

1.エイリアス   alias 别名 powershell使用 get-help 命令时,会得到与此命令 相关(别名)的命令 2.プロキシ proxy 代 理 3.プロモート   promote 促进,提升   开发的系统新功能上线发布,有时会使用到这个词,比如 STプロモート 还有使用リリース这个词的。比如STリリース。个人认为两者含义相同。 4.見逃し み................................................................

sun0322 6万+

单神经元PID控制:从Hebb到Delta,四种学习规则在MATLAB里到底怎么选?

本文深入探讨了单神经元PID控制算法在MATLAB中的四种学习规则(无监督Hebb、有监督Delta、有监督Hebb及改进Hebb)的实战对比与选型指南。通过详细的仿真实验和性能指标分析,为工程师提供了基于实际工程考量的选型建议和参数整定技巧,帮助优化复杂非线性系统的控制效果。

weixin_31220401的博客 283

C++ 实习面试第一阶段:面向对象基础

C 和 C++ 都是通用编程语言,但设计思想和语言特性有所不同。C++ 相比 C 增加了以下重要特性:面试回答: C++ 并不是完全取代 C 的语言,它兼容了大量 C 语言的编程方式,同时提供了面向对象、泛型编程和资源管理等更丰富的特性。指针是存储对象地址的变量,引用是对象的别名。代码示例: 需要注意:两者都用于表示空指针,但类型和使用方式不同。为什么引入 nullptr?因为 NULL 在一些实现中本质上是整数 0,可能导致函数重载时产生歧义。 面试重点: C++11 以后优先使用 nullptr

zengjiajun2006的博客 1133

西门子博图TIA Portal V18实战:用LAD梯形图快速上手一个电机启停控制程序

本文详细介绍了如何在西门子博图TIA Portal V18中使用LAD梯形图快速实现电机启停控制程序。从项目创建、硬件组态到LAD编程和仿真调试,逐步引导读者掌握PLC编程的核心逻辑,特别适合工业自动化领域的初学者快速上手。

weixin_30617737的博客 690

U-net复现pytorch版本 以及制作自己的数据集并训练

GitHub链接: https://github.com/milesial/Pytorch-UNet 论文链接:

candice5566的博客 3万+

Java 集合容器高频面试题全解:HashMap 树化阈值、1.7 死循环与 ConcurrentHashMap 演进(2026 招聘 JD 真题复盘)

HashMap 树化阈值 8 的泊松分布依据、退化阈值 6 的防抖设计、JDK 1.7 头插成环死循环的排查与修复、1.8 尾插后为何仍线程不安全、ConcurrentHashMap 放弃分段锁的原因与 ForwardingNode 协作扩容、computeIfAbsent 死锁陷阱、大集合找重复的场景题选型,附三层追问链自测。题目来源为 38 期公开招聘 JD 样本与字节、拼多多、建行、美团、快手公开面经。

蒙眼过河 418

52、Power BI Desktop 数据筛选全攻略

本文全面介绍了Power BI Desktop中各类数据筛选功能的使用方法,涵盖布尔型数据筛选、高级文本筛选、Top N筛选、可视化级别、页面级别和报告级别筛选等核心操作。通过详细步骤、流程图和实际案例,帮助用户深入理解筛选器的层级关系与综合应用技巧,并提供常见问题解决方案,助力高效精准的数据分析与可视化展示。

linux6sysadmin的博客 110

你背的Spring答案,全是5.x时代的:7.0.8源码一翻,80%的博客都过时了

这是 **Spring 7.0.8 源码系列的准备篇**,不急着逐行啃代码,只解决三件事:**为什么读、读什么、怎么读才不半途而废**。如果你正处在“注解一贴、项目一跑、日志一红、开始玄学”的状态,这篇就是给你写的。

xiaokjv的博客 269

MTK Camera HAL 与 FeaturePipe 架构解析:从硬件抽象到功能管线的工程落地路径

MTK 的 Camera 系统在 Android 平台下采用高度模块化的 HAL 与 FeaturePipe 架构组合,分别承担着底层硬件抽象与上层功能封装的职责。通过将 ISP、3A 算法、Sensor 驱动与图像处理节点解耦封装于功能链条中,联发科平台实现了灵活可控的影像能力调度机制。本文将基于真实项目实践,系统解析 MTK Camera HAL 与 FeaturePipe 架构的设计思路、核心模块与运行机制,结合 AE/AWB/AF 控制流、Buffer 管理策略、功能管线构建方法与多线程调度实现方式

努力分享一些人工智能、计算机视觉、影像等相关的知识干货! 1574

AUTOSAR从入门到精通-诊断事件管理模块(DEM)(四)

在 Autosar 中,诊断事件是指在车辆电子系统中发生的特定事件,可以用于诊断和故障排除。DEM模块通过EventId和相关的EventName来表示每个诊断事件。诊断事件的状态代表了监控器的结果,DEM模块通过RTE或其他BSW模块从SW-C接收监控器的结果。DEM模块使用EventId来管理系统中每个诊断事件的状态,并对各个测试结果执行所需的操作,例如存储冻结帧/拓展帧。所有监控器和BSW模块使用EventId作为符号化的EventName来管理诊断事件。

getusushu的博客 1510

【AI Agent面试系列】上下文管理1

工具返回价值密度最低、体积最大(常占 30%~60%),清理它收益最高、损失最小。摘要对话会丢语义细节,代价更高。按"价值密度从低到高"排序,不是按"体积从大到小"。

363

YOLOv8改进 | 主干网络 | 将主干网络替换为轻量化的ShuffleNetv2【原理 + 完整代码】

yolov8,yolov8创新,yolov8改进

kay_545 2566

上位机面试:值类型 vs 引用类型

大部分教程会告诉你"值类型在栈上,引用类型在堆上"。如果 ④ 和 ⑤ 都用 99,⑤ 打印出 99 时你分不清是"第 3 条和第 4 条是最容易混的,也是面试最爱追问的地方。很多人把"传引用类型的参数"当成"引用传递",这是错的。所以那句"引用类型比较就是比地址"不是通则——跑 100 万次对比,数量级差异很明显。记住这张图,下面所有现象都能推出来。这一行,才是真正的"按引用传递"。的核心原因——泛型不需要装箱。生效了"还是"根本没生效"。,比背"栈和堆"高一个层次。,这是测试设计的基本要求。

hanxiaodihhh的博客 249

LinePhone3.2.7(android端最新版本) sip客户端

SIP客户端For android:LinePhone3.2.7(最新版本),官网地址:http://www.linphone.org/

温度PID控制PLC程序

温度PID稳定控制程序,适用于欧姆龙PLC,对其他PLC也具有很大参考价值

C++ 运行时多态与虚函数机制全面剖析【面试重点】

构成C++ 运行时多态必须是基类的指针或者引用调用虚函数。被调用的函数必须是虚函数,并且派生类必须对该虚函数完成重写/覆盖(Override)。只有基类的指针或引用才能既指向基类对象,又指向派生类对象(切片机制);而只有完成了虚函数重写,派生类与基类才具备不同的函数行为,达到“指向谁就调用谁”的效果。在类成员函数的前面加上virtual关键字修饰,该成员函数就成为了虚函数。public:// 成员函数前加 virtual 修饰,声明为虚函数。

Fuyo_0209的博客 450

ET2012新版免狗免安装绿色软件

ET2012新版免狗免安祖昂绿色软件。,新界面,新视觉,

力扣35搜索插入位置(二分查找)

该题通过二分查找寻找插入位置。使用 mid = (left + right) >> 1 防止整数溢出,等价于 (left + right) / 2,但更安全。当 target <= nums[mid] 时,更新答案并继续向左查找;否则向右。最终返回最小的插入位置,时间复杂度 O(log n),空间复杂度 O(1)。

Pniubi的博客 193

VC中sqlite的使用

在VC中对sqlite的使用,实现数据库大批数据的快速插入,读取数据库中的数据在ListCtrl中显示

LeetCode 面试经典 150_位运算_只出现一次的数字 II(137_C++_中等)

LeetCode 面试经典 150_位运算_只出现一次的数字 II(137_C++_中等) 题目描述: 给你一个整数数组 nums ,除某个元素仅出现 一次 外,其余每个元素都恰出现 三次 。请你找出并返回那个只出现了一次的元素。 你必须设计并实现线性时间复杂度的算法且使用常数级空间来解决此问题。

huayimenghan的博客 127

微服务体系下DevOps运维岗位面试方案设计

建立岗位题库与评分卡:把本文的考察点固化为结构化评分表,减少面试官主观漂移。面试官认证与校准:定期做「同候选人多人评分」校准,提升一致性。持续迭代题库:每季度根据真实招聘结果回看,淘汰区分度低的问题。候选人侧:把 STAR 案例当成工程资产来维护,每次项目结束就沉淀一份,面试时自然从容。微服务下的 DevOps 与运维岗位,考察的核心不是「你会不会用某个工具」,而是面对复杂系统时,能否用工程化手段持续降低不确定性。用 STAR 原则把这一点问出来、讲出来,面试双方都能少走弯路。

流水账 641

【全栈面试】Excel 导入流程

本文系统梳理全栈工程师面试中关于Excel导入功能的五大核心考察维度:架构分层(DTO/BO/DO职责分离)、前后端校验分工、性能优化(异步+流式读取+批量插入)、异常处理策略(精准反馈与错误报告)、技术选型原理(EasyExcel vs POI)。强调全栈思维应贯穿“契约先行、解耦设计、安全兜底、体验优化”全流程,通过实例展示如何从同步阻塞到异步高效重构,实现高可用、可维护、用户体验佳的完整链路设计。

qq_38980678的博客 288

【AI Agent面试系列】AI Agent可观测、评测、安全相关的知识点

之前的文章介绍了和,本文主要介绍AI Agent的测评、可观测等知识。

296

2026-11-05-Go内存管理面试

内存分配器分层、三色标记、写屏障、STW、内存泄漏——Go 内存管理这块面试最容易问懵。这篇把分配器到 GC 到泄漏排查串成十连问,每题配答案框架和追问应对,文末给 GC 调优对照表。

利威尔的博客,主要分享一些go后端,agent相关知识,都是最新最前沿而且是免费的,在我的专栏里有各种方向的详细分类和内容整理,详细内容请阅读主页置顶文章,希望对你有帮助。 404
上一篇: Spring Boot 3.x 自定义Starter实战:面试官死磕的自动配置原理,我翻源码帮你画透了
下一篇: 血泪教训:线上CPU飙到500%后,我这样5分钟救回来的
Java开发廖志伟
博客等级 码龄2年 3491粉丝 1534原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

抵扣说明:

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

余额充值