--- title: "09-登录鉴权" created: 2025-12-03 tags: - 博客 aliases: - 登录鉴权 --- # 登录鉴权 ## **一、设计理论** ### **1.1 两种鉴权模式对比** ![[鉴权-dd9c97cc.jpg]] ### **1.2 认证流程图** #### **Session-Cookie 模式** ![[Session-Cookie-657a37d4.jpg]] #### **JWT Token 模式** ![[JWT_Token-4a0cc2c8.jpg]] ### **1.3 JWT Token 结构** ![[JWT_Token结构-d7245c91.jpg]] --- ## **二、Session + Cookie + Redis 模式实现** ### **2.1 项目结构** ```text src/main/java/com/zwnsyw/zwwwspringbootbasetemplate/ ├── config/ │ └── AppProperties.java # 统一配置属性类 ├── security/ │ └── session/ │ ├── SessionStore.java # Session 存储接口 │ ├── InMemorySessionStore.java # 内存存储实现(开发环境) │ ├── RedisSessionStore.java # Redis 存储实现(生产环境) │ └── SessionCookieManager.java # Session Cookie 管理器 └── model/ └── vo/ ├── LoginUserVO.java # 登录用户信息 └── SessionInfoVO.java # Session 信息 VO src/main/resources/ ├── application.yml # 主配置文件(通用默认值) ├── application-dev.yml # 开发环境配置 ├── application-prod.yml # 生产环境配置 ├── .env.dev # 开发环境变量(不提交 Git) └── .env.prod # 生产环境变量(不提交 Git) ``` ### **2.2 配置文件** #### **主配置文件** `application.yml` ```yaml # ===================================================== # application.yml - 主配置文件 # ===================================================== # 此文件包含所有环境通用的默认配置 # 环境特定配置在 application-{profile}.yml 中覆盖 # 敏感信息通过 .env.{profile} 环境变量注入 # ===================================================== spring: application: name: ${APP_NAME:ZwwwSpringBootBaseTemplate} profiles: active: ${SPRING_PROFILES_ACTIVE:dev} # ---------- 数据源配置 ---------- datasource: url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:zwwwspringbootbasetemplate}?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: ${DB_USERNAME:root} password: ${DB_PASSWORD:zw200495} driver-class-name: com.mysql.cj.jdbc.Driver hikari: minimum-idle: 5 maximum-pool-size: 20 idle-timeout: 300000 max-lifetime: 1200000 connection-timeout: 30000 pool-name: HikariPool # ---------- JPA 配置 ---------- jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: format_sql: true dialect: org.hibernate.dialect.MySQL8Dialect # ---------- Redis 配置 ---------- data: redis: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} database: ${REDIS_DATABASE:0} timeout: 10s lettuce: pool: max-active: 20 max-idle: 10 min-idle: 5 max-wait: 3s # ---------- 服务器配置 ---------- server: port: ${SERVER_PORT:8080} servlet: context-path: /api # ------ MyBatis Plus 配置 ------ mybatis-plus: configuration: map-underscore-to-camel-case: false default-enum-type-handler: org.apache.ibatis.type.EnumOrdinalTypeHandler global-config: db-config: logic-delete-field: isDelete logic-delete-value: 1 logic-not-delete-value: 0 # ---------- 应用配置 ---------- app: name: ${APP_NAME:ZwwwSpringBootBaseTemplate} version: ${APP_VERSION:1.0.0} debug: ${APP_DEBUG:true} # 安全配置 security: password-salt: ${APP_SECURITY_PASSWORD_SALT:default-salt-change-in-production} bcrypt-strength: ${APP_SECURITY_BCRYPT_STRENGTH:10} # JWT 配置 jwt: secret: ${APP_JWT_SECRET:default-jwt-secret-change-in-production-must-be-long} expiration: ${APP_JWT_EXPIRATION:604800} token-prefix: "Bearer " header-name: Authorization # Session 配置 session: store-type: ${APP_SESSION_STORE_TYPE:memory} timeout-minutes: ${APP_SESSION_TIMEOUT_MINUTES:30} cookie-name: ${APP_SESSION_COOKIE_NAME:ZSESSION} cookie-path: ${APP_SESSION_COOKIE_PATH:/} cookie-domain: ${APP_SESSION_COOKIE_DOMAIN:} same-site: ${APP_SESSION_SAME_SITE:Lax} http-only: ${APP_SESSION_HTTP_ONLY:true} secure: ${APP_SESSION_SECURE:false} sliding-window: ${APP_SESSION_SLIDING_WINDOW:true} redis-key-prefix: ${APP_SESSION_REDIS_KEY_PREFIX:session:} cleanup-interval-ms: ${APP_SESSION_CLEANUP_INTERVAL_MS:300000} # 文件上传配置 file: max-size: ${APP_FILE_MAX_SIZE:10485760} allowed-formats: ${APP_FILE_ALLOWED_FORMATS:jpg,jpeg,png,gif,webp,pdf} upload-path: ${APP_FILE_UPLOAD_PATH:./uploads} # 用户配置 user: max-password-retry: ${APP_USER_MAX_PASSWORD_RETRY:5} max-login-device: ${APP_USER_MAX_LOGIN_DEVICE:3} lock-minutes: ${APP_USER_LOCK_MINUTES:30} # CORS 配置 cors: allowed-origins: ${APP_CORS_ORIGINS:http://localhost:5173,http://localhost:3000} # ---------- Knife4j 配置 ---------- knife4j: enable: ${KNIFE4J_ENABLE:true} setting: language: zh_cn swagger-model-name: 实体类 # ---------- 日志配置 ---------- logging: level: root: ${LOG_LEVEL:INFO} com.zwnsyw: DEBUG org.springframework.security: DEBUG pattern: console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" ``` #### **开发环境配置** `application-dev.yml` ```yaml # ===================================================== # application-dev.yml - 开发环境配置 # ===================================================== mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl knife4j: enable: true logging: level: com.zwnsyw: DEBUG ``` #### **生产环境配置** `application-prod.yml` ```yaml # ===================================================== # application-prod.yml - 生产环境配置 # ===================================================== # 通过环境变量注入敏感配置,此文件可以提交到 Git # ===================================================== spring: config: activate: on-profile: prod # ---------- 数据源配置 ---------- datasource: url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useSSL=true&requireSSL=true&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4 username: ${DB_USERNAME} password: ${DB_PASSWORD} hikari: minimum-idle: 10 maximum-pool-size: 50 idle-timeout: 300000 max-lifetime: 1800000 connection-timeout: 30000 # ---------- Redis 配置 ---------- data: redis: host: ${REDIS_HOST} port: ${REDIS_PORT} password: ${REDIS_PASSWORD:} database: ${REDIS_DATABASE:0} timeout: 10s lettuce: pool: max-active: 50 max-idle: 20 min-idle: 10 max-wait: 5s # ---------- JPA 配置 ---------- jpa: hibernate: ddl-auto: validate show-sql: false properties: hibernate: format_sql: false # ---------- 应用配置 ---------- app: name: ${APP_NAME:ZwwwSpringBootBaseTemplate} version: ${APP_VERSION:1.0.0} debug: false # 安全配置 security: password-salt: ${APP_SECURITY_PASSWORD_SALT} bcrypt-strength: ${APP_SECURITY_BCRYPT_STRENGTH:12} # JWT 配置 jwt: secret: ${APP_JWT_SECRET} expiration: ${APP_JWT_EXPIRATION:604800} # Session 配置(生产环境使用 Redis) session: store-type: ${APP_SESSION_STORE_TYPE:redis} timeout-minutes: ${APP_SESSION_TIMEOUT_MINUTES:30} cookie-name: ${APP_SESSION_COOKIE_NAME:ZSESSION} cookie-path: ${APP_SESSION_COOKIE_PATH:/} cookie-domain: ${APP_SESSION_COOKIE_DOMAIN:} same-site: ${APP_SESSION_SAME_SITE:Strict} http-only: ${APP_SESSION_HTTP_ONLY:true} secure: ${APP_SESSION_SECURE:true} sliding-window: ${APP_SESSION_SLIDING_WINDOW:true} redis-key-prefix: ${APP_SESSION_REDIS_KEY_PREFIX:prod:session:} # 文件上传配置 file: max-size: ${APP_FILE_MAX_SIZE:10485760} allowed-formats: ${APP_FILE_ALLOWED_FORMATS:jpg,jpeg,png,gif,webp,pdf} upload-path: ${APP_FILE_UPLOAD_PATH:/var/data/uploads} # 用户配置 user: max-password-retry: ${APP_USER_MAX_PASSWORD_RETRY:3} max-login-device: ${APP_USER_MAX_LOGIN_DEVICE:3} lock-minutes: ${APP_USER_LOCK_MINUTES:60} # CORS 配置 cors: allowed-origins: ${APP_CORS_ORIGINS:} # ---------- Knife4j 配置 ---------- knife4j: enable: ${KNIFE4J_ENABLE:false} production: true # ---------- 日志配置 ---------- logging: level: root: ${LOG_LEVEL:WARN} com.zwnsyw: INFO org.springframework.security: WARN file: name: /var/log/app/application.log logback: rollingpolicy: max-file-size: 100MB max-history: 30 # ---------- 服务器配置 ---------- server: port: ${SERVER_PORT:8080} tomcat: max-threads: 200 min-spare-threads: 20 accept-count: 100 ``` #### **开发环境变量** `.env.dev` ```properties # ===================================================== # .env.dev - 开发环境配置 # ===================================================== # IDEA 配置方式: # 1. 安装 EnvFile 插件 # 2. Run/Debug Configurations → EnvFile 标签 → 勾选 Enable # 3. 添加此文件路径 # ===================================================== # ============ Spring Profile ============ SPRING_PROFILES_ACTIVE=dev # ============ 应用信息 ============ APP_NAME=ZwwwSpringBootBaseTemplate-Dev APP_VERSION=1.0.0-dev APP_DEBUG=true # ============ 服务器配置 ============ SERVER_PORT=8080 # ============ 数据库配置 ============ DB_HOST=localhost DB_PORT=3306 DB_NAME=zwwwspringbootbasetemplate DB_USERNAME=root DB_PASSWORD=zw200495 # ============ Redis 配置 ============ REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= REDIS_DATABASE=0 # ============ JWT 配置 ============ APP_JWT_SECRET=dev-jwt-secret-for-local-development-only-simple-key APP_JWT_EXPIRATION=86400 # ============ 安全配置 ============ APP_SECURITY_PASSWORD_SALT=Dev@Salt#2024!LocalDevelopment APP_SECURITY_BCRYPT_STRENGTH=4 # ============ Session 配置 memory/redis ============ APP_SESSION_STORE_TYPE=redis APP_SESSION_TIMEOUT_MINUTES=60 APP_SESSION_COOKIE_NAME=ZSESSION APP_SESSION_COOKIE_PATH=/ APP_SESSION_COOKIE_DOMAIN= APP_SESSION_SAME_SITE=Lax APP_SESSION_HTTP_ONLY=true APP_SESSION_SECURE=false APP_SESSION_SLIDING_WINDOW=true APP_SESSION_CLEANUP_INTERVAL_MS=300000 # ============ 文件上传配置 ============ APP_FILE_MAX_SIZE=10485760 APP_FILE_ALLOWED_FORMATS=jpg,jpeg,png,gif,webp,pdf APP_FILE_UPLOAD_PATH=D:/uploads/dev # ============ 用户配置 ============ APP_USER_MAX_PASSWORD_RETRY=10 APP_USER_MAX_LOGIN_DEVICE=5 APP_USER_LOCK_MINUTES=5 # ============ CORS 配置 ============ APP_CORS_ORIGINS=http://localhost:5173,http://localhost:3000 # ============ 文档配置 ============ KNIFE4J_ENABLE=true # ============ 日志配置 ============ LOG_LEVEL=DEBUG ``` #### **生产环境变量** `.env.prod` ```properties # ===================================================== # .env.prod - 生产环境配置 # ===================================================== # 部署方式: # 方式一:source .env.prod && java -jar app.jar # 方式二:Docker 环境变量注入 # 方式三:K8s ConfigMap/Secret # # 安全提醒: # - 此文件包含敏感信息,绝不能提交到 Git # - 确保文件权限为 600:chmod 600 .env.prod # - 生产密钥至少 32 位随机字符 # ===================================================== # ============ Spring Profile ============ SPRING_PROFILES_ACTIVE=prod # ============ 应用信息 ============ APP_NAME=ZwwwSpringBootBaseTemplate APP_VERSION=1.0.0 APP_DEBUG=false # ============ 服务器配置 ============ SERVER_PORT=8080 # ============ 数据库配置 ============ # TODO: 替换为真实生产数据库配置 DB_HOST=prod-mysql.example.com DB_PORT=3306 DB_NAME=prod_database DB_USERNAME=prod_db_user DB_PASSWORD=super_secure_database_password_here_min_16_chars # ============ Redis 配置 ============ # TODO: 替换为真实 Redis 配置 REDIS_HOST=prod-redis.example.com REDIS_PORT=6379 REDIS_PASSWORD=redis_secure_password_here_min_16_chars REDIS_DATABASE=0 # ============ JWT 配置 ============ # 生成方式:openssl rand -base64 64 APP_JWT_SECRET=production_jwt_secret_must_be_very_long_and_secure_at_least_256_bits_change_this APP_JWT_EXPIRATION=604800 # ============ 安全配置 ============ # 生成方式:openssl rand -base64 32 APP_SECURITY_PASSWORD_SALT=Prod@Salt#2024!YourSecureRandomString!@#$%ChangeThis APP_SECURITY_BCRYPT_STRENGTH=12 # ============ Session 配置(生产环境使用 Redis)============ APP_SESSION_STORE_TYPE=redis APP_SESSION_TIMEOUT_MINUTES=30 APP_SESSION_COOKIE_NAME=ZSESSION APP_SESSION_COOKIE_PATH=/ APP_SESSION_COOKIE_DOMAIN=.example.com APP_SESSION_SAME_SITE=Strict APP_SESSION_HTTP_ONLY=true APP_SESSION_SECURE=true APP_SESSION_SLIDING_WINDOW=true APP_SESSION_REDIS_KEY_PREFIX=prod:session: # ============ 文件上传配置 ============ APP_FILE_MAX_SIZE=10485760 APP_FILE_ALLOWED_FORMATS=jpg,jpeg,png,gif,webp,pdf APP_FILE_UPLOAD_PATH=/var/data/uploads # ============ 用户配置 ============ APP_USER_MAX_PASSWORD_RETRY=3 APP_USER_MAX_LOGIN_DEVICE=3 APP_USER_LOCK_MINUTES=60 # ============ CORS 配置 ============ # TODO: 替换为真实域名 APP_CORS_ORIGINS=https://www.example.com,https://admin.example.com # ============ 文档配置(生产环境禁用)============ KNIFE4J_ENABLE=false # ============ 日志配置 ============ LOG_LEVEL=WARN ``` ### **2.3 统一配置属性类** `AppProperties.java` ```java package com.zwnsyw.zwwwspringbootbasetemplate.config; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; import org.springframework.validation.annotation.Validated; import javax.validation.constraints.Max; import javax.validation.constraints.Min; import javax.validation.constraints.NotBlank; /** * 应用配置属性 - 统一配置入口 * * ==================== 使用说明 ==================== * * 本类采用三层配置管理机制: * 1. application.yml - 主配置文件(所有环境通用的默认值) * 2. application-{profile}.yml - 环境特定配置(dev/test/prod) * 3. .env.{profile} - 环境变量文件(敏感信息,不提交 Git) * * ==================== 加载顺序 ==================== * * 1. application.yml 中的默认值被加载 * 2. application-{spring.profiles.active}.yml 覆盖默认值 * 3. 系统环境变量或 .env 文件中的值最终覆盖以上配置 * * 示例: * - 在 application.yml 中定义: app.name: default-name * - 在 application-dev.yml 中覆盖: app.name: dev-name * - 在 .env.dev 中注入: APP_NAME=local-dev-name * * ==================== 注入方式 ==================== * * 1. 字段注入(简单场景): * @Autowired * private AppProperties appProperties; * * 2. 构造器注入(推荐,便于测试): * public MyService(AppProperties appProperties) { * this.appProperties = appProperties; * } * * 3. 方法参数注入: * public void myMethod(@Qualifier("appProperties") AppProperties config) { } * * ==================== 环境变量映射 ==================== * * YAML 配置 -> 环境变量(Spring 会自动转换): * - app.name -> APP_NAME * - app.jwt.secret -> APP_JWT_SECRET * - app.jwt.expiration -> APP_JWT_EXPIRATION * - app.file.max-size -> APP_FILE_MAX_SIZE * - app.session.store-type -> APP_SESSION_STORE_TYPE * - app.session.timeout-minutes -> APP_SESSION_TIMEOUT_MINUTES * * 注:在 .env 文件中使用大写 + 下划线格式 * * ==================== 配置文件检查列表 ==================== * * ✓ application.yml - 主配置,包含所有默认值 * ✓ application-dev.yml - 开发环境覆盖 * ✓ application-test.yml - 测试环境覆盖 * ✓ application-prod.yml - 生产环境覆盖(关键值从环境变量读取) * ✓ .env.dev - 开发本地环境变量(.gitignore) * ✓ .env.test - 测试环境变量(.gitignore) * ✓ .env.prod - 生产环境变量(不提交,服务器手动创建) */ @Data @Component @Validated @ConfigurationProperties(prefix = "app") public class AppProperties { /** 应用名称 */ @NotBlank(message = "应用名称不能为空") private String name; /** 应用版本 */ private String version = "1.0.0"; /** 是否开启调试模式(仅用于标识,实际由 spring.profiles.active 决定) */ private boolean debug = false; /** 安全配置 */ private SecurityConfig security = new SecurityConfig(); /** JWT 配置 */ private JwtConfig jwt = new JwtConfig(); /** Session 配置 */ private SessionConfig session = new SessionConfig(); /** 文件上传配置 */ private FileConfig file = new FileConfig(); /** 用户相关配置 */ private UserConfig user = new UserConfig(); /** CORS 配置 */ private CorsConfig cors = new CorsConfig(); // ==================== 内部配置类 ==================== /** * 安全配置 * 来源: application.yml + .env.{profile} */ @Data public static class SecurityConfig { /** * 密码静态盐值 - 生产环境必须从环境变量注入 * 用于增强 BCrypt 加密的安全性 */ @NotBlank(message = "密码盐值不能为空") private String passwordSalt; /** * BCrypt 加密强度 (4-31) * 开发环境建议: 4-6 (快速) * 生产环境建议: 10-12 (安全) * 强度每增加1,计算时间翻倍 */ @Min(value = 4, message = "BCrypt强度最小为4") @Max(value = 31, message = "BCrypt强度最大为31") private int bcryptStrength = 10; } /** * JWT 配置 * 来源: application.yml + application-{profile}.yml + .env.{profile} */ @Data public static class JwtConfig { /** JWT 密钥 - 生产环境必须从 .env.prod 注入,不要使用默认值 */ @NotBlank(message = "JWT密钥不能为空") private String secret; /** 过期时间(秒),默认7天 */ @Min(value = 60, message = "JWT过期时间不能少于60秒") private long expiration = 604800; /** Token 前缀,默认 Bearer */ private String tokenPrefix = "Bearer "; /** Authorization Header 名称,默认 Authorization */ private String headerName = "Authorization"; /** 获取过期时间(毫秒)- 方便在代码中使用 */ public long getExpirationMs() { return expiration * 1000; } } /** * Session 配置 *

* 统一管理 Session 相关的所有配置 *

* * ==================== 配置示例 ==================== * * application.yml: * app: * session: * store-type: memory # memory 或 redis * timeout-minutes: 30 # Session 过期时间(分钟) * cookie-name: ZSESSION # Cookie 名称 * sliding-window: true # 是否启用滑动窗口 * * ==================== 环境变量 ==================== * * APP_SESSION_STORE_TYPE=redis * APP_SESSION_TIMEOUT_MINUTES=60 * APP_SESSION_COOKIE_NAME=ZSESSION */ @Data public static class SessionConfig { /** * Session 存储类型 * - memory: 内存存储(开发环境,单机部署) * - redis: Redis 存储(生产环境,分布式部署) */ @NotBlank(message = "Session 存储类型不能为空") private String storeType = "memory"; /** * Session 过期时间(分钟) */ @Min(value = 1, message = "Session 过期时间最小为 1 分钟") private int timeoutMinutes = 30; /** * Cookie 名称 */ @NotBlank(message = "Cookie 名称不能为空") private String cookieName = "ZSESSION"; /** * Cookie 路径 */ private String cookiePath = "/"; /** * Cookie 域名(空表示不设置,使用当前域名) */ private String cookieDomain = ""; /** * SameSite 属性 * - Strict: 严格模式,完全禁止跨站请求携带 Cookie * - Lax: 宽松模式,允许部分跨站请求(GET 请求导航) * - None: 允许跨站请求(需要 Secure=true) */ private String sameSite = "Lax"; /** * 是否设置 HttpOnly(防止 XSS 攻击,JS 无法访问) */ private boolean httpOnly = true; /** * 是否仅 HTTPS(生产环境应为 true) */ private boolean secure = false; /** * 是否启用滑动窗口(每次访问刷新过期时间) */ private boolean slidingWindow = true; /** * Redis Key 前缀 */ private String redisKeyPrefix = "session:"; /** * 内存清理间隔(毫秒),仅内存存储生效 */ private long cleanupIntervalMs = 300000; // 5 分钟 /** * 判断是否使用 Redis 存储 */ public boolean isRedisStore() { return "redis".equalsIgnoreCase(storeType); } /** * 判断是否使用内存存储 */ public boolean isMemoryStore() { return "memory".equalsIgnoreCase(storeType); } /** * 获取超时时间(秒) */ public long getTimeoutSeconds() { return timeoutMinutes * 60L; } /** * 获取超时时间(毫秒) */ public long getTimeoutMs() { return timeoutMinutes * 60L * 1000L; } } /** * 文件上传配置 * 来源: application.yml + application-{profile}.yml */ @Data public static class FileConfig { /** 最大文件大小(字节),默认10MB */ private long maxSize = 10485760; /** 允许的文件格式(逗号分隔) */ private String allowedFormats = "jpg,jpeg,png,gif,webp,pdf"; /** 上传路径 */ private String uploadPath = "/uploads"; /** 获取允许的格式列表(数组形式,便于校验) */ public String[] getAllowedFormatArray() { return allowedFormats.split(","); } /** 获取最大文件大小(MB)- 方便在错误提示中使用 */ public long getMaxSizeMB() { return maxSize / (1024 * 1024); } } /** * 用户相关配置 * 来源: application.yml + application-{profile}.yml */ @Data public static class UserConfig { /** 密码最大重试次数,超过后锁定账户 */ private int maxPasswordRetry = 5; /** 最大同时登录设备数 */ private int maxLoginDevice = 3; /** 账户锁定时间(分钟) */ private int lockMinutes = 30; } /** * CORS 跨域配置 * 来源: cors.allowed-origins(在 application.yml 中定义) * 注:虽然放在 app 下,但实际配置在 cors 节点,需要手动读取 */ @Data public static class CorsConfig { /** 允许的源(逗号分隔) */ private String allowedOrigins = "http://localhost:5173,http://localhost:3000"; /** 获取允许的源列表(数组形式) */ public String[] getAllowedOriginsArray() { if (allowedOrigins == null || allowedOrigins.isEmpty()) { return new String[]{"*"}; } return allowedOrigins.split(","); } } // ==================== 便捷方法 ==================== /** 是否为生产环境 */ public boolean isProduction() { return !debug; } /** 是否为开发环境 */ public boolean isDevelopment() { return debug; } } ``` ### **2.4 Session 存储接口** `SessionStore.java` ```typescript package com.zwnsyw.zwwwspringbootbasetemplate.security.session; import com.zwnsyw.zwwwspringbootbasetemplate.model.vo.LoginUserVO; /** * Session 存储接口 *

* 抽象 Session 的存储逻辑,支持多种实现: * - 内存存储(开发环境) * - Redis 存储(生产环境) * - 未来可扩展:数据库、MongoDB 等 *

* * ==================== 设计思想 ==================== * * 1. 接口抽象:不关心具体存储方式 * 2. 策略模式:运行时根据配置选择实现 * 3. 可扩展:新增存储方式只需实现此接口 * * ==================== 使用示例 ==================== * * @Autowired * private SessionStore sessionStore; * * // 创建 Session * String sessionId = sessionStore.createSession(loginUserVO); * * // 获取 Session * LoginUserVO user = sessionStore.getSession(sessionId); * * // 删除 Session * sessionStore.removeSession(sessionId); */ public interface SessionStore { /** * 创建 Session * * @param user 登录用户信息 * @return 生成的 Session ID */ String createSession(LoginUserVO user); /** * 获取 Session 中的用户信息 *

* 如果启用了滑动窗口,每次获取都会刷新过期时间 *

* * @param sessionId Session ID * @return 用户信息,不存在或已过期返回 null */ LoginUserVO getSession(String sessionId); /** * 更新 Session 中的用户信息 *

* 用于用户信息变更后同步更新 Session *

* * @param sessionId Session ID * @param user 新的用户信息 * @return true 更新成功,false Session 不存在 */ boolean updateSession(String sessionId, LoginUserVO user); /** * 删除 Session * * @param sessionId Session ID */ void removeSession(String sessionId); /** * 检查 Session 是否存在且有效 * * @param sessionId Session ID * @return true 存在且有效,false 不存在或已过期 */ boolean existsSession(String sessionId); /** * 获取 Session 剩余生存时间(秒) * * @param sessionId Session ID * @return 剩余秒数,-1 表示不存在,-2 表示永不过期 */ long getSessionTTL(String sessionId); /** * 刷新 Session 过期时间 *

* 手动刷新,用于"保持登录"等场景 *

* * @param sessionId Session ID * @return true 刷新成功,false Session 不存在 */ boolean refreshSession(String sessionId); /** * 删除用户的所有 Session(用于强制下线) * * @param userId 用户 ID * @return 删除的 Session 数量 */ int removeAllSessionsByUserId(Long userId); } ``` ### **2.5 内存存储实现** `InMemorySessionStore.java` ```java package com.zwnsyw.zwwwspringbootbasetemplate.security.session; import com.zwnsyw.zwwwspringbootbasetemplate.config.AppProperties; import com.zwnsyw.zwwwspringbootbasetemplate.model.vo.LoginUserVO; import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; /** * 内存 Session 存储实现 *

* 适用场景: * - 开发环境 * - 单机部署 * - 测试环境 *

* * ==================== 注意事项 ==================== * * 1. 服务重启后 Session 会丢失 * 2. 不支持分布式部署(多实例间 Session 不共享) * 3. 需要定期清理过期 Session,防止内存泄漏 * * ==================== 启用条件 ==================== * * 配置 app.session.store-type=memory 时自动启用 */ @Slf4j @Component @RequiredArgsConstructor @ConditionalOnProperty(name = "app.session.store-type", havingValue = "memory", matchIfMissing = true) public class InMemorySessionStore implements SessionStore { private final AppProperties appProperties; /** * 获取 Session 配置(便捷方法) */ private AppProperties.SessionConfig getSessionConfig() { return appProperties.getSession(); } /** * Session 数据结构 */ @Data private static class SessionData { private LoginUserVO user; private long expiryTime; // 过期时间戳(毫秒) private long createTime; // 创建时间戳 public boolean isExpired() { return System.currentTimeMillis() > expiryTime; } public long getRemainingTTL() { long remaining = (expiryTime - System.currentTimeMillis()) / 1000; return remaining > 0 ? remaining : -1; } } /** * Session 存储容器(线程安全) */ private final Map sessions = new ConcurrentHashMap<>(); /** * 用户 ID 到 Session ID 的映射(用于强制下线) */ private final Map userSessionMap = new ConcurrentHashMap<>(); @PostConstruct public void init() { AppProperties.SessionConfig config = getSessionConfig(); log.info("InMemorySessionStore initialized, timeout: {} minutes, sliding-window: {}", config.getTimeoutMinutes(), config.isSlidingWindow()); } /** * 定时清理过期 Session *

* 每 5 分钟执行一次,清理过期的 Session,防止内存泄漏 *

*/ @Scheduled(fixedDelayString = "${app.session.cleanup-interval-ms:300000}") public void cleanupExpiredSessions() { if (sessions.isEmpty()) { return; } AtomicInteger count = new AtomicInteger(0); long now = System.currentTimeMillis(); sessions.entrySet().removeIf(entry -> { boolean expired = entry.getValue().getExpiryTime() < now; if (expired) { // 同时清理用户映射 LoginUserVO user = entry.getValue().getUser(); if (user != null) { userSessionMap.remove(user.getId()); } count.incrementAndGet(); } return expired; }); if (count.get() > 0) { log.debug("Cleaned up {} expired sessions, remaining: {}", count.get(), sessions.size()); } } @Override public String createSession(LoginUserVO user) { if (user == null || user.getId() == null) { throw new IllegalArgumentException("User or userId cannot be null"); } AppProperties.SessionConfig config = getSessionConfig(); // 生成唯一的 Session ID String sessionId = generateSessionId(); long now = System.currentTimeMillis(); long expiryTime = now + config.getTimeoutMs(); // 创建 Session 数据 SessionData data = new SessionData(); data.setUser(user); data.setExpiryTime(expiryTime); data.setCreateTime(now); // 存储 Session sessions.put(sessionId, data); // 存储用户到 Session 的映射(用于强制下线) // 注意:这里会覆盖旧的 Session,实现"单点登录"效果 String oldSessionId = userSessionMap.put(user.getId(), sessionId); if (oldSessionId != null && !oldSessionId.equals(sessionId)) { // 移除旧 Session sessions.remove(oldSessionId); log.debug("Removed old session for user {}: {}", user.getId(), oldSessionId); } log.debug("Session created: sessionId={}, userId={}, expiryTime={}", sessionId, user.getId(), expiryTime); return sessionId; } @Override public LoginUserVO getSession(String sessionId) { if (sessionId == null) { return null; } SessionData data = sessions.get(sessionId); if (data == null) { return null; } // 检查是否过期 if (data.isExpired()) { removeSessionInternal(sessionId, data); log.debug("Session expired: sessionId={}", sessionId); return null; } AppProperties.SessionConfig config = getSessionConfig(); // 滑动窗口:每次访问刷新过期时间 if (config.isSlidingWindow()) { data.setExpiryTime(System.currentTimeMillis() + config.getTimeoutMs()); } return data.getUser(); } @Override public boolean updateSession(String sessionId, LoginUserVO user) { if (sessionId == null || user == null) { return false; } SessionData data = sessions.get(sessionId); if (data == null || data.isExpired()) { return false; } AppProperties.SessionConfig config = getSessionConfig(); data.setUser(user); // 更新时也刷新过期时间 data.setExpiryTime(System.currentTimeMillis() + config.getTimeoutMs()); log.debug("Session updated: sessionId={}, userId={}", sessionId, user.getId()); return true; } @Override public void removeSession(String sessionId) { if (sessionId == null) { return; } SessionData data = sessions.remove(sessionId); if (data != null && data.getUser() != null) { userSessionMap.remove(data.getUser().getId()); log.debug("Session removed: sessionId={}, userId={}", sessionId, data.getUser().getId()); } } @Override public boolean existsSession(String sessionId) { if (sessionId == null) { return false; } SessionData data = sessions.get(sessionId); if (data == null) { return false; } if (data.isExpired()) { removeSessionInternal(sessionId, data); return false; } return true; } @Override public long getSessionTTL(String sessionId) { if (sessionId == null) { return -1; } SessionData data = sessions.get(sessionId); if (data == null) { return -1; } return data.getRemainingTTL(); } @Override public boolean refreshSession(String sessionId) { if (sessionId == null) { return false; } SessionData data = sessions.get(sessionId); if (data == null || data.isExpired()) { return false; } AppProperties.SessionConfig config = getSessionConfig(); data.setExpiryTime(System.currentTimeMillis() + config.getTimeoutMs()); log.debug("Session refreshed: sessionId={}", sessionId); return true; } @Override public int removeAllSessionsByUserId(Long userId) { if (userId == null) { return 0; } String sessionId = userSessionMap.remove(userId); if (sessionId != null) { sessions.remove(sessionId); log.debug("Removed session for user {}: {}", userId, sessionId); return 1; } return 0; } /** * 生成唯一的 Session ID */ private String generateSessionId() { return UUID.randomUUID().toString().replace("-", ""); } /** * 内部移除 Session 方法 */ private void removeSessionInternal(String sessionId, SessionData data) { sessions.remove(sessionId); if (data != null && data.getUser() != null) { userSessionMap.remove(data.getUser().getId()); } } /** * 获取当前活跃 Session 数量(用于监控) */ public int getActiveSessionCount() { return sessions.size(); } /** * 获取所有 Session 信息(用于调试) */ public Map getAllSessions() { return new ConcurrentHashMap<>(sessions); } } ``` ### **2.6 Redis 存储实现** `RedisSessionStore.java` ```java package com.zwnsyw.zwwwspringbootbasetemplate.security.session; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.zwnsyw.zwwwspringbootbasetemplate.config.AppProperties; import com.zwnsyw.zwwwspringbootbasetemplate.model.vo.LoginUserVO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; /** * Redis Session 存储实现 *

* 适用场景: * - 生产环境 * - 分布式部署 * - 需要 Session 持久化 *

* * ==================== Redis Key 设计 ==================== * * Session 数据:session:{sessionId} -> JSON(LoginUserVO) * 用户映射:session:user:{userId} -> sessionId * * ==================== 启用条件 ==================== * * 配置 app.session.store-type=redis 时自动启用 * 需要配置 Redis 连接信息 */ @Slf4j @Component @RequiredArgsConstructor @ConditionalOnProperty(name = "app.session.store-type", havingValue = "redis") public class RedisSessionStore implements SessionStore { private final StringRedisTemplate redisTemplate; private final AppProperties appProperties; private final ObjectMapper objectMapper; /** * Session Key 前缀 */ private String sessionKeyPrefix; /** * 用户到 Session 映射的 Key 前缀 */ private String userSessionKeyPrefix; /** * 获取 Session 配置(便捷方法) */ private AppProperties.SessionConfig getSessionConfig() { return appProperties.getSession(); } @PostConstruct public void init() { AppProperties.SessionConfig config = getSessionConfig(); sessionKeyPrefix = config.getRedisKeyPrefix(); userSessionKeyPrefix = config.getRedisKeyPrefix() + "user:"; log.info("RedisSessionStore initialized, keyPrefix: {}, timeout: {} minutes, sliding-window: {}", sessionKeyPrefix, config.getTimeoutMinutes(), config.isSlidingWindow()); } @Override public String createSession(LoginUserVO user) { if (user == null || user.getId() == null) { throw new IllegalArgumentException("User or userId cannot be null"); } AppProperties.SessionConfig config = getSessionConfig(); // 生成唯一的 Session ID String sessionId = generateSessionId(); String sessionKey = sessionKeyPrefix + sessionId; String userSessionKey = userSessionKeyPrefix + user.getId(); int timeoutMinutes = config.getTimeoutMinutes(); try { // 序列化用户信息 String userJson = objectMapper.writeValueAsString(user); // 存储 Session redisTemplate.opsForValue().set(sessionKey, userJson, timeoutMinutes, TimeUnit.MINUTES); // 检查是否有旧 Session(单点登录) String oldSessionId = redisTemplate.opsForValue().get(userSessionKey); if (oldSessionId != null && !oldSessionId.equals(sessionId)) { // 删除旧 Session redisTemplate.delete(sessionKeyPrefix + oldSessionId); log.debug("Removed old session for user {}: {}", user.getId(), oldSessionId); } // 存储用户到 Session 的映射 redisTemplate.opsForValue().set(userSessionKey, sessionId, timeoutMinutes, TimeUnit.MINUTES); log.debug("Session created in Redis: sessionId={}, userId={}, timeout={}min", sessionId, user.getId(), timeoutMinutes); return sessionId; } catch (JsonProcessingException e) { log.error("Failed to serialize user for session: {}", e.getMessage()); throw new RuntimeException("Session creation failed", e); } } @Override public LoginUserVO getSession(String sessionId) { if (sessionId == null) { return null; } String sessionKey = sessionKeyPrefix + sessionId; AppProperties.SessionConfig config = getSessionConfig(); try { String userJson = redisTemplate.opsForValue().get(sessionKey); if (userJson == null) { return null; } // 滑动窗口:每次访问刷新过期时间 if (config.isSlidingWindow()) { redisTemplate.expire(sessionKey, config.getTimeoutMinutes(), TimeUnit.MINUTES); } return objectMapper.readValue(userJson, LoginUserVO.class); } catch (JsonProcessingException e) { log.error("Failed to deserialize session data: sessionId={}, error={}", sessionId, e.getMessage()); return null; } } @Override public boolean updateSession(String sessionId, LoginUserVO user) { if (sessionId == null || user == null) { return false; } String sessionKey = sessionKeyPrefix + sessionId; AppProperties.SessionConfig config = getSessionConfig(); // 检查 Session 是否存在 if (Boolean.FALSE.equals(redisTemplate.hasKey(sessionKey))) { return false; } try { String userJson = objectMapper.writeValueAsString(user); redisTemplate.opsForValue().set(sessionKey, userJson, config.getTimeoutMinutes(), TimeUnit.MINUTES); log.debug("Session updated in Redis: sessionId={}, userId={}", sessionId, user.getId()); return true; } catch (JsonProcessingException e) { log.error("Failed to update session: sessionId={}, error={}", sessionId, e.getMessage()); return false; } } @Override public void removeSession(String sessionId) { if (sessionId == null) { return; } String sessionKey = sessionKeyPrefix + sessionId; // 先获取用户信息,用于清理用户映射 try { String userJson = redisTemplate.opsForValue().get(sessionKey); if (userJson != null) { LoginUserVO user = objectMapper.readValue(userJson, LoginUserVO.class); if (user != null && user.getId() != null) { redisTemplate.delete(userSessionKeyPrefix + user.getId()); } } } catch (JsonProcessingException e) { log.warn("Failed to parse session for cleanup: {}", e.getMessage()); } // 删除 Session redisTemplate.delete(sessionKey); log.debug("Session removed from Redis: sessionId={}", sessionId); } @Override public boolean existsSession(String sessionId) { if (sessionId == null) { return false; } return Boolean.TRUE.equals(redisTemplate.hasKey(sessionKeyPrefix + sessionId)); } @Override public long getSessionTTL(String sessionId) { if (sessionId == null) { return -1; } Long ttl = redisTemplate.getExpire(sessionKeyPrefix + sessionId, TimeUnit.SECONDS); return ttl != null ? ttl : -1; } @Override public boolean refreshSession(String sessionId) { if (sessionId == null) { return false; } String sessionKey = sessionKeyPrefix + sessionId; AppProperties.SessionConfig config = getSessionConfig(); if (Boolean.FALSE.equals(redisTemplate.hasKey(sessionKey))) { return false; } redisTemplate.expire(sessionKey, config.getTimeoutMinutes(), TimeUnit.MINUTES); log.debug("Session refreshed in Redis: sessionId={}", sessionId); return true; } @Override public int removeAllSessionsByUserId(Long userId) { if (userId == null) { return 0; } String userSessionKey = userSessionKeyPrefix + userId; String sessionId = redisTemplate.opsForValue().get(userSessionKey); if (sessionId != null) { redisTemplate.delete(sessionKeyPrefix + sessionId); redisTemplate.delete(userSessionKey); log.debug("Removed all sessions for user {}", userId); return 1; } return 0; } /** * 生成唯一的 Session ID */ private String generateSessionId() { return UUID.randomUUID().toString().replace("-", ""); } /** * 获取当前活跃 Session 数量(用于监控) *

* 注意:这个操作在大量 Session 时可能较慢 *

*/ public long getActiveSessionCount() { Set keys = redisTemplate.keys(sessionKeyPrefix + "*"); // 排除用户映射的 key if (keys != null) { return keys.stream() .filter(key -> !key.contains(":user:")) .count(); } return 0; } } ``` ### **2.7 Session Cookie 管理器** `SessionCookieManager.java` ```java package com.zwnsyw.zwwwspringbootbasetemplate.security.session; import com.zwnsyw.zwwwspringbootbasetemplate.config.AppProperties; import com.zwnsyw.zwwwspringbootbasetemplate.model.vo.LoginUserVO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; /** * Session Cookie 管理器 *

* 统一管理 Cookie 的创建、读取、删除,以及与 SessionStore 的交互 *

* * ==================== 职责 ==================== * * 1. 创建 Session 并设置 Cookie * 2. 从请求中读取 Session ID * 3. 从请求中获取登录用户信息 * 4. 清除 Session 和 Cookie * 5. 刷新 Session * * ==================== 安全特性 ==================== * * 1. HttpOnly: 防止 XSS 攻击,JS 无法访问 Cookie * 2. SameSite: 防止 CSRF 攻击 * - Strict: 完全禁止跨站请求携带 Cookie * - Lax: 允许部分跨站请求(如链接跳转) * - None: 允许跨站请求(需要 Secure=true) * 3. Secure: 仅 HTTPS 传输(生产环境必须开启) * * ==================== 使用示例 ==================== * * // 登录时创建 Session * sessionCookieManager.createSessionAndSetCookie(loginUserVO, response); * * // 获取当前登录用户 * LoginUserVO user = sessionCookieManager.getLoginUserFromRequest(request); * * // 登出时清除 Session * sessionCookieManager.clearSessionAndCookie(request, response); */ @Slf4j @Component @RequiredArgsConstructor public class SessionCookieManager { private final SessionStore sessionStore; private final AppProperties appProperties; /** * 获取 Session 配置(便捷方法) */ private AppProperties.SessionConfig getSessionConfig() { return appProperties.getSession(); } /** * 创建 Session 并设置 Cookie * * @param user 登录用户信息 * @param response HTTP 响应 * @return 生成的 Session ID */ public String createSessionAndSetCookie(LoginUserVO user, HttpServletResponse response) { // 创建 Session String sessionId = sessionStore.createSession(user); // 设置 Cookie addCookieToResponse(response, sessionId); log.debug("Session created and cookie set: sessionId={}, userId={}", sessionId, user.getId()); return sessionId; } /** * 从请求中获取 Session ID * * @param request HTTP 请求 * @return Session ID,不存在返回 null */ public String getSessionIdFromRequest(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); if (cookies == null || cookies.length == 0) { return null; } String cookieName = getSessionConfig().getCookieName(); for (Cookie cookie : cookies) { if (cookieName.equals(cookie.getName())) { // 添加 sessionId 格式校验 String value = cookie.getValue(); if (isValidSessionId(value)) { return value; } } } return null; } private boolean isValidSessionId(String sessionId) { // 验证 sessionId 格式(应该是 UUID 去掉横线) return sessionId != null && sessionId.matches("^[a-f0-9]{32}$"); } /** * 从请求中获取登录用户信息 * * @param request HTTP 请求 * @return 登录用户信息,未登录或 Session 过期返回 null */ public LoginUserVO getLoginUserFromRequest(HttpServletRequest request) { String sessionId = getSessionIdFromRequest(request); if (sessionId == null) { return null; } return sessionStore.getSession(sessionId); } /** * 清除 Session 和 Cookie * * @param request HTTP 请求 * @param response HTTP 响应 */ public void clearSessionAndCookie(HttpServletRequest request, HttpServletResponse response) { String sessionId = getSessionIdFromRequest(request); // 清除 Session if (sessionId != null) { sessionStore.removeSession(sessionId); } // 清除 Cookie removeCookieFromResponse(response); log.debug("Session and cookie cleared: sessionId={}", sessionId); } /** * 刷新 Session 过期时间 * * @param request HTTP 请求 * @return true 刷新成功,false Session 不存在 */ public boolean refreshSession(HttpServletRequest request) { String sessionId = getSessionIdFromRequest(request); if (sessionId == null) { return false; } return sessionStore.refreshSession(sessionId); } /** * 更新 Session 中的用户信息 * * @param request HTTP 请求 * @param user 新的用户信息 * @return true 更新成功,false Session 不存在 */ public boolean updateSession(HttpServletRequest request, LoginUserVO user) { String sessionId = getSessionIdFromRequest(request); if (sessionId == null) { return false; } return sessionStore.updateSession(sessionId, user); } /** * 检查 Session 是否有效 * * @param request HTTP 请求 * @return true 有效,false 无效或不存在 */ public boolean isSessionValid(HttpServletRequest request) { String sessionId = getSessionIdFromRequest(request); return sessionId != null && sessionStore.existsSession(sessionId); } /** * 获取 Session 剩余时间(秒) * * @param request HTTP 请求 * @return 剩余秒数,-1 表示不存在 */ public long getSessionRemainingTime(HttpServletRequest request) { String sessionId = getSessionIdFromRequest(request); if (sessionId == null) { return -1; } return sessionStore.getSessionTTL(sessionId); } /** * 强制用户下线(删除所有 Session) * * @param userId 用户 ID * @return 删除的 Session 数量 */ public int forceLogout(Long userId) { return sessionStore.removeAllSessionsByUserId(userId); } /** * 添加 Cookie 到响应 *

* 设置安全相关的 Cookie 属性 *

*/ private void addCookieToResponse(HttpServletResponse response, String sessionId) { AppProperties.SessionConfig config = getSessionConfig(); String cookieName = config.getCookieName(); String cookiePath = config.getCookiePath(); String cookieDomain = config.getCookieDomain(); String sameSite = config.getSameSite(); boolean httpOnly = config.isHttpOnly(); boolean secure = config.isSecure(); // 构建 Set-Cookie 头(支持 SameSite 属性) StringBuilder cookieBuilder = new StringBuilder(); cookieBuilder.append(cookieName).append("=").append(sessionId); cookieBuilder.append("; Path=").append(cookiePath); if (cookieDomain != null && !cookieDomain.isEmpty()) { cookieBuilder.append("; Domain=").append(cookieDomain); } if (httpOnly) { cookieBuilder.append("; HttpOnly"); } if (secure) { cookieBuilder.append("; Secure"); } if (sameSite != null && !sameSite.isEmpty()) { cookieBuilder.append("; SameSite=").append(sameSite); } // 使用 Set-Cookie 头,支持现代浏览器的 SameSite 属性 response.addHeader("Set-Cookie", cookieBuilder.toString()); log.debug("Cookie set: {}", cookieBuilder); } /** * 从响应中移除 Cookie */ private void removeCookieFromResponse(HttpServletResponse response) { AppProperties.SessionConfig config = getSessionConfig(); String cookieName = config.getCookieName(); String cookiePath = config.getCookiePath(); // 设置 Cookie 过期 Cookie cookie = new Cookie(cookieName, ""); cookie.setPath(cookiePath); cookie.setMaxAge(0); cookie.setHttpOnly(true); response.addCookie(cookie); log.debug("Cookie removed: {}", cookieName); } } ``` ### **2.8 更新** `LoginUserVO` ```java package com.zwnsyw.zwwwspringbootbasetemplate.model.vo; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.zwnsyw.zwwwspringbootbasetemplate.model.entity.User; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.io.Serializable; import java.util.HashSet; import java.util.Set; /** * 登录用户视图对象(包含权限信息,用于 Session 存储) * * @author Zwww */ @Data @Builder @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) // 忽略未知字段 @Schema(description = "登录用户信息") public class LoginUserVO implements Serializable { private static final long serialVersionUID = 1L; @Schema(description = "用户ID") private Long id; @Schema(description = "账号") private String userAccount; @Schema(description = "用户名") private String userName; @Schema(description = "头像") private String userAvatar; @Schema(description = "用户角色集合") private Set roles; @Schema(description = "用户权限集合") private Set permissions; @Schema(description = "Token 过期时间(毫秒)") private long expirationMs; /** * 权限缓存时间戳(内部使用,不返回给客户端) * 用于判断权限缓存是否已过期(30 分钟) */ @JsonIgnore @Schema(hidden = true) private long permissionsCacheTime; /** * 从 User 实体构建 LoginUserVO * * @param user 用户实体 * @param roles 角色集合 * @param permissions 权限集合 * @return LoginUserVO */ public static LoginUserVO fromEntity(User user, Set roles, Set permissions) { if (user == null) { return null; } return LoginUserVO.builder() .id(user.getId()) .userAccount(user.getUserAccount()) .userName(user.getUserName()) .userAvatar(user.getUserAvatar()) .roles(roles != null ? roles : new HashSet<>()) .permissions(permissions != null ? permissions : new HashSet<>()) .build(); } /** * 判断是否拥有指定角色 * * @param role 角色标识 * @return 是否拥有 */ public boolean hasRole(String role) { return roles != null && roles.contains(role); } /** * 判断是否拥有指定权限 * * @param permission 权限标识 * @return 是否拥有 */ public boolean hasPermission(String permission) { return permissions != null && permissions.contains(permission); } /** * 判断是否为管理员 * * @return 是否为管理员 */ public boolean isAdmin() { return hasRole("admin"); } /** * 判断用户是否为超级管理员 */ public boolean isSuperAdmin() { return roles != null && roles.contains("super_admin"); } /** * 获取 Token 过期时间(秒) */ public long getExpirationSeconds() { return expirationMs / 1000; } } ``` ### **2.9 滑动窗口** #### 刷新拦截器 `SessionRefreshInterceptor.java` ```java package com.zwnsyw.zwwwspringbootbasetemplate.security.interceptor; import com.zwnsyw.zwwwspringbootbasetemplate.security.session.SessionCookieManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; /** * Session 刷新拦截器 *

* 实现滑动窗口机制:每次有效请求都会刷新 Session 过期时间 *

*/ @Slf4j @Component @RequiredArgsConstructor public class SessionRefreshInterceptor implements HandlerInterceptor { private final SessionCookieManager sessionCookieManager; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 尝试刷新 Session if (sessionCookieManager.refreshSession(request)) { log.trace("Session refreshed for request: {}", request.getRequestURI()); } // 无论刷新成功与否,都继续处理请求 // 权限检查由 AuthInterceptor 负责 return true; } } ``` #### SecurityConfig ```java package com.zwnsyw.zwwwspringbootbasetemplate.security.config; import com.zwnsyw.zwwwspringbootbasetemplate.security.interceptor.AuthorizationInterceptor; import com.zwnsyw.zwwwspringbootbasetemplate.security.interceptor.SessionRefreshInterceptor; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration @RequiredArgsConstructor public class SecurityConfig implements WebMvcConfigurer { private final AuthorizationInterceptor authorizationInterceptor; private final SessionRefreshInterceptor sessionRefreshInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { // 1. Session 刷新拦截器(优先级最高,order=0) // 每次请求刷新 Session 过期时间,实现滑动窗口 registry.addInterceptor(sessionRefreshInterceptor) .addPathPatterns("/**") .excludePathPatterns( "/static/**", "/favicon.ico", "/doc.html", "/swagger-resources/**", "/webjars/**", "/v3/api-docs/**", "/health", "/error" ) .order(0); // 2. 权限验证拦截器(order=1) registry.addInterceptor(authorizationInterceptor) .addPathPatterns("/**") .excludePathPatterns( // 静态资源 "/static/**", "/favicon.ico", // Swagger/Knife4j "/doc.html", "/swagger-resources/**", "/webjars/**", "/v3/api-docs/**", // 健康检查和错误页面 "/health", "/error" ) .order(1); } } ``` ### **2.10 权限强制刷新** ```java package com.zwnsyw.zwwwspringbootbasetemplate.service.serviceimpl; import com.zwnsyw.zwwwspringbootbasetemplate.mapper.PermissionMapper; import com.zwnsyw.zwwwspringbootbasetemplate.mapper.RoleMapper; import com.zwnsyw.zwwwspringbootbasetemplate.mapper.UserRoleMapper; import com.zwnsyw.zwwwspringbootbasetemplate.model.entity.UserRole; import com.zwnsyw.zwwwspringbootbasetemplate.model.vo.LoginUserVO; import com.zwnsyw.zwwwspringbootbasetemplate.security.context.SecurityContext; import com.zwnsyw.zwwwspringbootbasetemplate.security.enums.Logical; import com.zwnsyw.zwwwspringbootbasetemplate.security.handler.PermissionHandler; import com.zwnsyw.zwwwspringbootbasetemplate.service.PermissionCacheService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.ObjectProvider; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.stream.Collectors; import static com.zwnsyw.zwwwspringbootbasetemplate.constant.UserConstant.SUPER_PERMISSION; /** * 权限缓存服务实现 *

* 缓存策略: * - 用户权限缓存 Key: user:permissions:{userId} * - 用户角色缓存 Key: user:roles:{userId} * - 缓存时间:默认 30 分钟(可在配置中调整) *

*/ @Slf4j @Service @RequiredArgsConstructor public class PermissionCacheServiceImpl implements PermissionCacheService { private final PermissionMapper permissionMapper; private final RoleMapper roleMapper; private final UserRoleMapper userRoleMapper; private final ObjectProvider permissionHandlerProvider; @Override @Cacheable(value = "user:permissions", key = "#userId", unless = "#result == null || #result.isEmpty()") public Set getPermissions(Long userId) { log.debug("Loading permissions from database for user: {}", userId); Set permissions = permissionMapper.selectPermissionCodesByUserId(userId); // 如果是管理员,添加超级权限 Set roles = getRoles(userId); if (roles.contains("admin")) { permissions = new HashSet<>(permissions); permissions.add(SUPER_PERMISSION); } log.debug("Loaded {} permissions for user: {}", permissions.size(), userId); return permissions; } @Override @Cacheable(value = "user:roles", key = "#userId", unless = "#result == null || #result.isEmpty()") public Set getRoles(Long userId) { log.debug("Loading roles from database for user: {}", userId); Set roles = roleMapper.selectRoleCodesByUserId(userId); log.debug("Loaded {} roles for user: {}", roles.size(), userId); return roles; } @Override @CacheEvict(value = {"user:permissions", "user:roles"}, key = "#userId") public void clearUserCache(Long userId) { log.info("Cleared permission cache for user: {}", userId); } @Override public void clearRoleCache(Long roleId) { // 查询该角色关联的所有用户 List userRoles = userRoleMapper.selectList( new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .eq(UserRole::getRoleId, roleId) ); // 清除这些用户的缓存 Set userIds = userRoles.stream() .map(UserRole::getUserId) .collect(Collectors.toSet()); for (Long userId : userIds) { clearUserCache(userId); } log.info("Cleared permission cache for {} users related to role: {}", userIds.size(), roleId); } @Override @CacheEvict(value = {"user:permissions", "user:roles"}, allEntries = true) public void clearAllCache() { log.info("Cleared all permission caches"); } /** * 带权限刷新的权限检查 * * 使用场景: * 1. 权限变更后需要立即生效 * 2. 用户在 Session 中权限已过期(超过 30 分钟未更新) * 3. 需要检查最新的权限配置 * * @param permissions 需要的权限码数组 * @param logical AND/OR 逻辑 * @param forceRefresh 是否强制从数据库刷新权限 * @return true 用户拥有权限,false 没有权限 * * 使用示例: * // 强制刷新权限并检查 * hasPermissionWithRefresh(new String[]{"user:add"}, Logical.AND, true); * * // 正常检查,如果权限超过 30 分钟未更新则自动刷新 * hasPermissionWithRefresh(new String[]{"user:add"}, Logical.AND, false); */ public boolean hasPermissionWithRefresh(String[] permissions, Logical logical, boolean forceRefresh) { LoginUserVO user = SecurityContext.getCurrentUser(); if (user == null) { log.debug("User not authenticated"); return false; } Set userPermissions = user.getPermissions(); // 强制刷新或权限缓存已过期 if (forceRefresh || isPermissionCacheExpired(user)) { log.debug("Refreshing permissions for user: {}, forceRefresh: {}", user.getId(), forceRefresh); // 从数据库重新加载权限 userPermissions = getPermissions(user.getId()); user.setPermissions(userPermissions); user.setPermissionsCacheTime(System.currentTimeMillis()); log.debug("Permissions refreshed for user: {}, count: {}", user.getId(), userPermissions.size()); } // ✅ 获取 PermissionHandler 实例并进行权限检查 // 通过 ObjectProvider 的 getIfAvailable() 延迟获取,避免循环依赖 PermissionHandler handler = permissionHandlerProvider.getIfAvailable(); if (handler == null) { log.warn("PermissionHandler not available"); return false; } return handler.hasPermission(permissions, logical); } /** * 检查权限缓存是否已过期 * * 权限缓存过期时间:30 分钟 * 如果用户在 Session 中的权限超过 30 分钟未更新,认为缓存过期 * * @param user 当前用户信息 * @return true 缓存已过期,需要刷新;false 缓存仍然有效 */ private boolean isPermissionCacheExpired(LoginUserVO user) { if (user == null) { return true; } long cacheTime = user.getPermissionsCacheTime(); if (cacheTime == 0) { // 首次检查,缓存时间为 0,认为已过期 return true; } long currentTime = System.currentTimeMillis(); long cacheDuration = currentTime - cacheTime; // 30 分钟 = 30 * 60 * 1000 毫秒 long expireThreshold = 30 * 60 * 1000L; boolean expired = cacheDuration > expireThreshold; if (expired) { log.debug("Permission cache expired for user: {}, duration: {} ms", user.getId(), cacheDuration); } return expired; } /** * 刷新当前登录用户的权限 * * 使用场景: * 1. 用户权限变更后,刷新当前用户的权限缓存 * 2. 需要立即生效新权限 * * @return true 刷新成功,false 当前没有登录用户 * * 使用示例: * permissionCacheService.refreshCurrentUserPermissions(); */ public boolean refreshCurrentUserPermissions() { LoginUserVO user = SecurityContext.getCurrentUser(); if (user == null) { log.debug("No current user to refresh permissions"); return false; } return refreshUserPermissions(user.getId()); } /** * 刷新指定用户的权限 * * @param userId 用户 ID * @return true 刷新成功 */ public boolean refreshUserPermissions(Long userId) { if (userId == null) { return false; } log.info("Refreshing permissions for user: {}", userId); // 清除缓存,强制从数据库重新加载 clearUserCache(userId); // 如果用户在线,更新其 SecurityContext 中的权限 LoginUserVO currentUser = SecurityContext.getCurrentUser(); if (currentUser != null && currentUser.getId().equals(userId)) { Set newPermissions = getPermissions(userId); currentUser.setPermissions(newPermissions); currentUser.setPermissionsCacheTime(System.currentTimeMillis()); log.info("Updated SecurityContext permissions for user: {}", userId); } return true; } /** * 批量刷新用户权限 * * 使用场景: * 1. 权限配置全局变更 * 2. 某个角色的权限变更 * * @param userIds 用户 ID 列表 */ public void batchRefreshUserPermissions(List userIds) { if (userIds == null || userIds.isEmpty()) { return; } log.info("Batch refreshing permissions for {} users", userIds.size()); for (Long userId : userIds) { refreshUserPermissions(userId); } } /** * 📋 使用说明 * 1. 审计日志(auditLog)- 4 个使用场景 * 场景调用方式说明访问被拒绝auditLog(request, null, "ACCESS_DENIED", "NOT_LOGIN")未登录权限检查失败auditLog(request, user, "PERMISSION_CHECK_FAILED", "Required: user:add")权限不足角色检查失败auditLog(request, user, "ROLE_CHECK_FAILED", "Required: admin")角色不足访问成功auditLog(request, user, "ACCESS_GRANTED", "SUCCESS")通过验证 * 输出日志示例: * AUDIT: userId=1001, account=admin, method=POST, uri=/api/user/add, action=ACCESS_GRANTED, result=SUCCESS * AUDIT: userId=1002, account=user, method=DELETE, uri=/api/user/delete, action=PERMISSION_CHECK_FAILED, result=Required: user:delete * AUDIT: userId=ANONYMOUS, account=ANONYMOUS, method=GET, uri=/api/login, action=ACCESS_DENIED, result=NOT_LOGIN * * 2. 权限强制刷新(hasPermissionWithRefresh)- 4 个方法 * 方法 1:权限检查 + 自动刷新 * // 正常权限检查,30 分钟自动刷新一次 * boolean hasPermission = permissionCacheService.hasPermissionWithRefresh( * new String[]{"user:add"}, * Logical.AND, * false // 不强制刷新 * ); * 方法 2:权限检查 + 强制刷新 * // 权限变更后立即生效 * boolean hasPermission = permissionCacheService.hasPermissionWithRefresh( * new String[]{"user:add"}, * Logical.AND, * true // 强制从数据库重新加载 * ); * 方法 3:刷新当前用户权限 * // 在权限变更后调用,自动更新当前用户的 SecurityContext * permissionCacheService.refreshCurrentUserPermissions(); * 方法 4:刷新指定用户权限 * // 刷新某个用户的权限(如果该用户在线会立即生效) * permissionCacheService.refreshUserPermissions(userId); * * // 批量刷新多个用户的权限 * permissionCacheService.batchRefreshUserPermissions(Arrays.asList(userId1, userId2)); * ``` * * --- * * ## 🔄 **权限缓存刷新流程** * ``` * 权限变更 * ↓ * 调用 refreshUserPermissions(userId) 或 clearUserCache(userId) * ↓ * 清除 Redis 缓存 * ↓ * 如果用户在线: * ├─ 更新 SecurityContext 中的权限 * └─ 更新 permissionsCacheTime 时间戳 * ↓ * 下次权限检查立即生效 * ``` * * --- * * ## ⏱️ **权限缓存策略** * ``` * 缓存有效期:30 分钟 * * 场景 1:频繁访问 * ├─ 0-30 分钟:使用缓存中的权限 * └─ 30+ 分钟:自动从数据库刷新权限 * * 场景 2:权限变更后需要立即生效 * ├─ 调用 refreshUserPermissions(userId) * ├─ 清除 Redis 缓存 * └─ 更新 SecurityContext 权限 * * 场景 3:角色变更影响多个用户 * ├─ clearRoleCache(roleId) * ├─ 自动清除所有相关用户的权限缓存 * └─ 下次访问时从数据库重新加载 * * *案例 1:用户权限变更(如添加新权限) * // 1. 在管理员操作中更新权限后调用 * permissionCacheService.refreshUserPermissions(targetUserId); * * // 2. 日志输出 * // Refreshing permissions for user: 1001 * // Updated SecurityContext permissions for user: 1001 * * * 案例 2:角色权限变更(如修改 admin 角色权限) * // 1. 修改角色权限后调用 * permissionCacheService.clearRoleCache(roleId); * // 2. 所有拥有 admin 角色的用户在下次请求时自动刷新权限 * * * 案例 3:敏感操作需要最新权限 * // 在删除操作前,强制刷新权限检查 * @RequiresPermission("user:delete") * @DeleteMapping("/{id}") * public void deleteUser(@PathVariable Long id) { * // 强制从数据库检查权限(防止权限过期) * boolean hasPermission = permissionCacheService.hasPermissionWithRefresh( * new String[]{"user:delete"}, * Logical.AND, * true // 强制刷新 * ); * * if (!hasPermission) { * throw new BusinessException(ErrorCode.FORBIDDEN); * } * * // 执行删除... * } */ } ``` ### **2.11 审查日志** ```java package com.zwnsyw.zwwwspringbootbasetemplate.security.interceptor; import com.zwnsyw.zwwwspringbootbasetemplate.exception.BusinessException; import com.zwnsyw.zwwwspringbootbasetemplate.exception.ErrorCode; import com.zwnsyw.zwwwspringbootbasetemplate.model.vo.LoginUserVO; import com.zwnsyw.zwwwspringbootbasetemplate.security.annotation.RequiresPermission; import com.zwnsyw.zwwwspringbootbasetemplate.security.annotation.RequiresRole; import com.zwnsyw.zwwwspringbootbasetemplate.security.config.AnonymousUrlConfig; import com.zwnsyw.zwwwspringbootbasetemplate.security.context.SecurityContext; import com.zwnsyw.zwwwspringbootbasetemplate.security.handler.PermissionHandler; import com.zwnsyw.zwwwspringbootbasetemplate.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.lang.reflect.Method; import java.util.Arrays; /** * 鉴权拦截器 - 权限和认证的核心 *

* ==================== 执行流程 ==================== * * preHandle (请求处理前): * 1. 检查是否是 HandlerMethod(Controller 方法) * 2. 放行 OPTIONS 请求(CORS 预检) * 3. 检查 URL 是否允许匿名访问 * - 是:尝试获取用户信息(可选),继续处理 * - 否:必须获取用户信息,否则拒绝 * 4. 检查 @RequiresPermission 和 @RequiresRole 注解 * * afterCompletion (请求完成后,总是会调用): * 5. 清理 ThreadLocal 中的用户信息(防止线程池泄露) * * ==================== 注意事项 ==================== * * - 即使发生异常,afterCompletion 也会被调用 * - OPTIONS 请求自动放行(CORS 预检) * - 非 Controller 方法(静态资源等)自动放行 *

*/ @Slf4j @Component @RequiredArgsConstructor public class AuthorizationInterceptor implements HandlerInterceptor { private final UserService userService; private final AnonymousUrlConfig anonymousUrlConfig; private final PermissionHandler permissionHandler; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 非 Controller 方法直接放行(静态资源、异常处理等) if (!(handler instanceof HandlerMethod)) { log.debug("Not a HandlerMethod, pass through"); return true; } String requestUri = request.getRequestURI(); String method = request.getMethod(); // 2. OPTIONS 请求放行(CORS 预检) if ("OPTIONS".equalsIgnoreCase(method)) { log.debug("OPTIONS request, pass through for CORS preflight"); return true; } log.debug("Checking authorization for {} {}", method, requestUri); // 3. 检查是否匿名访问 URL if (anonymousUrlConfig.isAnonymousUrl(requestUri)) { log.debug("Anonymous URL, no authentication required"); // 尝试获取用户信息(用于日志、审计等) trySetCurrentUser(request); return true; } // 4. 非匿名 URL 必须获取用户信息 LoginUserVO loginUser = userService.getLoginUser(request); if (loginUser == null) { log.warn("Authentication required but user not found for {}", requestUri); auditLog(request, null, "ACCESS_DENIED", "NOT_LOGIN"); throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR); } // 将用户信息存储到 ThreadLocal SecurityContext.setCurrentUser(loginUser); log.debug("User authenticated: {}", loginUser.getId()); // 5. 检查权限和角色注解 HandlerMethod handlerMethod = (HandlerMethod) handler; try { checkPermissionAnnotations(handlerMethod, request, loginUser); checkRoleAnnotations(handlerMethod, request, loginUser); // 权限检查通过,记录审计日志 auditLog(request, loginUser, "ACCESS_GRANTED", "SUCCESS"); } catch (BusinessException e) { // 权限检查失败,记录审计日志 auditLog(request, loginUser, "ACCESS_DENIED", e.getMessage()); throw e; } return true; } /** * 请求完成后必须清理 ThreadLocal * 即使发生异常也会调用此方法 */ @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 清理 ThreadLocal,防止线程池复用时信息泄露 if (SecurityContext.isAuthenticated()) { Long userId = SecurityContext.getCurrentUserId(); log.debug("Clearing SecurityContext for user {}", userId); } SecurityContext.clear(); } /** * 尝试获取用户信息(匿名访问时使用) * 不抛异常,失败时忽略 */ private void trySetCurrentUser(HttpServletRequest request) { try { LoginUserVO loginUser = userService.getLoginUser(request); if (loginUser != null) { SecurityContext.setCurrentUser(loginUser); log.debug("Set user info for anonymous request: {}", loginUser.getId()); } } catch (Exception e) { // 忽略异常,匿名访问不需要用户信息 log.trace("Failed to get user info for anonymous request", e); } } /** * 检查权限注解 */ private void checkPermissionAnnotations(HandlerMethod handlerMethod, HttpServletRequest request, LoginUserVO user) { Method method = handlerMethod.getMethod(); Class beanType = handlerMethod.getBeanType(); // 优先检查方法级别注解 RequiresPermission methodPermission = method.getAnnotation(RequiresPermission.class); if (methodPermission != null) { validatePermission(methodPermission, method.getName(), "方法", request, user); return; } // 再检查类级别注解 RequiresPermission classPermission = beanType.getAnnotation(RequiresPermission.class); if (classPermission != null) { validatePermission(classPermission, beanType.getName(), "类", request, user); } } /** * 检查角色注解 */ private void checkRoleAnnotations(HandlerMethod handlerMethod, HttpServletRequest request, LoginUserVO user) { Method method = handlerMethod.getMethod(); Class beanType = handlerMethod.getBeanType(); // 优先检查方法级别注解 RequiresRole methodRole = method.getAnnotation(RequiresRole.class); if (methodRole != null) { validateRole(methodRole, method.getName(), "方法", request, user); return; } // 再检查类级别注解 RequiresRole classRole = beanType.getAnnotation(RequiresRole.class); if (classRole != null) { validateRole(classRole, beanType.getName(), "类", request, user); } } /** * 验证权限,失败抛出异常 */ private void validatePermission(RequiresPermission annotation, String target, String type, HttpServletRequest request, LoginUserVO user) { if (!permissionHandler.hasPermission(annotation.value(), annotation.logical())) { String permissions = Arrays.toString(annotation.value()); Long userId = SecurityContext.getCurrentUserId(); log.warn("{} {} - 权限不足 - userId: {}, required: {}, logical: {}", type, target, userId, permissions, annotation.logical()); // 审计日志:权限拒绝 auditLog(request, user, "PERMISSION_CHECK_FAILED", "Required: " + permissions + ", Logical: " + annotation.logical()); throw new BusinessException(ErrorCode.FORBIDDEN, "权限不足,需要 " + permissions + " 权限"); } } /** * 验证角色,失败抛出异常 */ private void validateRole(RequiresRole annotation, String target, String type, HttpServletRequest request, LoginUserVO user) { if (!permissionHandler.hasRole(annotation.value(), annotation.logical())) { String roles = Arrays.toString(annotation.value()); Long userId = SecurityContext.getCurrentUserId(); log.warn("{} {} - 角色不足 - userId: {}, required: {}, logical: {}", type, target, userId, roles, annotation.logical()); // 审计日志:角色拒绝 auditLog(request, user, "ROLE_CHECK_FAILED", "Required: " + roles + ", Logical: " + annotation.logical()); throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "角色权限不足,需要 " + roles + " 角色"); } } /** * 审计日志记录 * * @param request HTTP 请求 * @param user 用户信息(可能为 null) * @param action 操作(ACCESS_DENIED, ACCESS_GRANTED, PERMISSION_CHECK_FAILED 等) * @param result 结果描述 * * 使用场景: * 1. 访问被拒绝:auditLog(request, null, "ACCESS_DENIED", "NOT_LOGIN") * 2. 权限检查失败:auditLog(request, user, "PERMISSION_CHECK_FAILED", "Required: user:add") * 3. 访问成功:auditLog(request, user, "ACCESS_GRANTED", "SUCCESS") */ private void auditLog(HttpServletRequest request, LoginUserVO user, String action, String result) { String userId = user != null ? String.valueOf(user.getId()) : "ANONYMOUS"; String userAccount = user != null ? user.getUserAccount() : "ANONYMOUS"; log.info("AUDIT: userId={}, account={}, method={}, uri={}, action={}, result={}", userId, userAccount, request.getMethod(), request.getRequestURI(), action, result); } } ``` ### **2.12 需要在主启动类添加注解** ```java package com.zwnsyw.zwwwspringbootbasetemplate; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cache.annotation.EnableCaching; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling @EnableCaching public class ZwwwSpringBootBaseTemplateApplication { public static void main(String[] args) { SpringApplication.run(ZwwwSpringBootBaseTemplateApplication.class, args); } } ``` ### **配置对比表** | **配置项** | **开发环境 (dev)** | **生产环境 (prod)** | | --- | --- | --- | | Session 存储 | `memory` | `redis` | | Session 超时 | 60 分钟 | 30 分钟 | | Cookie Secure | `false` | `true` | | SameSite | `Lax` | `Strict` | | BCrypt 强度 | 4 | 12 | | JWT 过期 | 1 天 | 7 天 | | 密码重试 | 10 次 | 3 次 | | 锁定时间 | 5 分钟 | 60 分钟 | | Knife4j | 开启 | 禁用 | | CORS | localhost:5173/3000 | 生产域名 | --- ## 三、**JWT Token 模式实现** ### **3.1 目录结构** ```text src/main/java/com/zwnsyw/zwwwspringbootbasetemplate/ ├── security/ │ ├── annotation/ │ │ ├── Anonymous.java (已存在) │ │ ├── RequiresPermission.java (已存在) │ │ └── RequiresRole.java (已存在) │ │ │ ├── config/ │ │ ├── SecurityConfig.java (已存在) │ │ ├── AnonymousUrlConfig.java (已存在) │ │ └── JwtConfig.java (✨ 新增) │ │ │ ├── context/ │ │ └── SecurityContext.java (已存在) │ │ │ ├── enums/ │ │ └── Logical.java (已存在) │ │ │ ├── handler/ │ │ └── PermissionHandler.java (已存在) │ │ │ ├── interceptor/ │ │ ├── AuthorizationInterceptor.java (已存在) │ │ ├── SessionRefreshInterceptor.java (已存在) │ │ └── HybridAuthInterceptor.java (✨ 新增) │ │ │ ├── jwt/ (✨ 新建目录) │ │ ├── JwtTokenProvider.java (✨ 新增 - Token 生成/验证) │ │ ├── JwtTokenFilter.java (✨ 新增 - Filter 中解析 Token) │ │ └── JwtTokenBlacklist.java (✨ 新增 - Token 黑名单) │ │ │ └── utils/ │ └── SecurityUtils.java (已存在) │ └── model/ └── vo/ └── JwtTokenVO.java (✨ 新增 - Token 响应 VO) ``` ### **3.2 JWT 工具类** ```java package com.zwnsyw.zwwwspringbootbasetemplate.security.jwt; import com.zwnsyw.zwwwspringbootbasetemplate.config.AppProperties; import com.zwnsyw.zwwwspringbootbasetemplate.exception.BusinessException; import com.zwnsyw.zwwwspringbootbasetemplate.exception.ErrorCode; import com.zwnsyw.zwwwspringbootbasetemplate.model.vo.LoginUserVO; import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.util.*; /** * JWT Token 工具类 * * ==================== 功能说明 ==================== * * 负责 JWT Token 的: * 1. 生成 Access Token(generateAccessToken) * 2. 生成 Refresh Token(generateRefreshToken) * 3. 验证 Token(validateToken) * 4. 解析 Token(getClaimsFromToken) * 5. 提取用户信息(getUserIdFromToken、isTokenExpired) * * ==================== Token 结构 ==================== * * JWT 格式:header.payload.signature * * Access Token Payload 示例: * { * "sub": "1001", // userId * "account": "admin", // userAccount * "name": "管理员", // userName * "avatar": "http://...", // userAvatar * "roles": ["admin"], // 角色列表 * "permissions": ["user:add", ...],// 权限列表 * "iat": 1701779400, // 签发时间(秒) * "exp": 1701783000 // 过期时间(秒)= iat + 3600 * } * * Refresh Token Payload 示例: * { * "sub": "1001", * "type": "refresh", * "iat": 1701779400, * "exp": 1709382600 // 7天后过期 * } */ @Slf4j @Component @RequiredArgsConstructor public class JwtTokenProvider { private final AppProperties appProperties; // ==================== 生成 Token ==================== /** * 生成 Access Token * * 特点: * - 包含完整的用户信息(角色、权限) * - 过期时间较短(默认 1 小时) * - 用于访问受保护资源 * * @param user 登录用户信息 * @return Access Token 字符串 */ public String generateAccessToken(LoginUserVO user) { if (user == null || user.getId() == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户信息不能为空"); } long expirationMs = appProperties.getJwt().getExpirationMs(); Map claims = new HashMap<>(); claims.put("account", user.getUserAccount()); claims.put("name", user.getUserName()); claims.put("avatar", user.getUserAvatar()); claims.put("roles", user.getRoles() != null ? new ArrayList<>(user.getRoles()) : new ArrayList<>()); claims.put("permissions", user.getPermissions() != null ? new ArrayList<>(user.getPermissions()) : new ArrayList<>()); claims.put("type", "access"); return createToken(claims, String.valueOf(user.getId()), expirationMs); } /** * 生成 Refresh Token * * 特点: * - 包含最少信息(仅用户 ID) * - 过期时间较长(默认 7 天) * - 用于刷新 Access Token * * @param user 登录用户信息 * @return Refresh Token 字符串 */ public String generateRefreshToken(LoginUserVO user) { if (user == null || user.getId() == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户信息不能为空"); } // 7 天过期 long refreshExpirationMs = 7 * 24 * 60 * 60 * 1000L; Map claims = new HashMap<>(); claims.put("type", "refresh"); return createToken(claims, String.valueOf(user.getId()), refreshExpirationMs); } // ==================== 验证 Token ==================== /** * 验证 Token 有效性 * * 检查项目: * 1. 签名是否有效 * 2. Token 是否已过期 * 3. Token 格式是否正确 * * @param token Token 字符串 * @return true Token 有效,false Token 无效或已过期 */ public boolean validateToken(String token) { try { Jwts.parserBuilder() .setSigningKey(getSecretKey()) .build() .parseClaimsJws(token); return true; } catch (SecurityException e) { log.warn("JWT signature validation failed: {}", e.getMessage()); } catch (MalformedJwtException e) { log.warn("JWT format is invalid: {}", e.getMessage()); } catch (ExpiredJwtException e) { log.warn("JWT token is expired: {}", e.getMessage()); } catch (UnsupportedJwtException e) { log.warn("JWT token is unsupported: {}", e.getMessage()); } catch (IllegalArgumentException e) { log.warn("JWT token is empty: {}", e.getMessage()); } catch (Exception e) { log.warn("JWT token validation error: {}", e.getMessage()); } return false; } // ==================== 解析 Token ==================== /** * 从 Token 中提取所有声明(Claims) * * @param token Token 字符串 * @return Claims 对象(包含所有声明信息) * @throws JwtException Token 无效或解析失败时抛出 */ public Claims getClaimsFromToken(String token) { return Jwts.parserBuilder() .setSigningKey(getSecretKey()) .build() .parseClaimsJws(token) .getBody(); } /** * 从 Token 中提取 userId * * @param token Token 字符串 * @return userId,提取失败返回 null */ public Long getUserIdFromToken(String token) { try { Claims claims = getClaimsFromToken(token); String subject = claims.getSubject(); return Long.valueOf(subject); } catch (Exception e) { log.warn("Failed to extract userId from token: {}", e.getMessage()); return null; } } /** * 获取 Token 的签发时间 * * @param token Token 字符串 * @return 签发时间 */ public Date getIssuedAtFromToken(String token) { try { Claims claims = getClaimsFromToken(token); return claims.getIssuedAt(); } catch (Exception e) { log.warn("Failed to extract issued at from token: {}", e.getMessage()); return null; } } /** * 获取 Token 的过期时间 * * @param token Token 字符串 * @return 过期时间 */ public Date getExpirationFromToken(String token) { try { Claims claims = getClaimsFromToken(token); return claims.getExpiration(); } catch (Exception e) { log.warn("Failed to extract expiration from token: {}", e.getMessage()); return null; } } /** * 获取 Token 的剩余有效期(秒) * * 用途:将 Token 加入黑名单时使用此值作为 TTL * * @param token Token 字符串 * @return 剩余秒数,-1 表示已过期或无法获取 */ public long getTokenRemainingSeconds(String token) { try { Claims claims = getClaimsFromToken(token); Date expiration = claims.getExpiration(); long remainingMs = expiration.getTime() - System.currentTimeMillis(); return remainingMs > 0 ? remainingMs / 1000 : -1; } catch (Exception e) { log.warn("Failed to get remaining seconds from token: {}", e.getMessage()); return -1; } } /** * 检查 Token 是否已过期 * * @param token Token 字符串 * @return true 已过期,false 未过期 */ public boolean isTokenExpired(String token) { try { Claims claims = getClaimsFromToken(token); return claims.getExpiration().before(new Date()); } catch (ExpiredJwtException e) { return true; } catch (Exception e) { log.warn("Failed to check token expiration: {}", e.getMessage()); return true; } } /** * 检查 Token 是否即将过期(5 分钟内) * * 用途:前端可根据此判断是否需要刷新 Token * * @param token Token 字符串 * @return true 即将过期,false 仍然有效 */ public boolean isTokenAboutToExpire(String token) { long remainingSeconds = getTokenRemainingSeconds(token); return remainingSeconds >= 0 && remainingSeconds < 300; // 5 分钟 } // ==================== 内部方法 ==================== /** * 创建 Token * * @param claims 声明信息 * @param subject Token 主体(通常是 userId) * @param expirationMs 过期时间(毫秒) * @return Token 字符串 */ private String createToken(Map claims, String subject, long expirationMs) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expirationMs); String token = Jwts.builder() .setClaims(claims) .setSubject(subject) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(getSecretKey(), SignatureAlgorithm.HS256) .compact(); log.debug("Token created: subject={}, expiresIn={}ms", subject, expirationMs); return token; } /** * 获取签名密钥 * * 密钥要求: * - 长度 ≥ 32 位(256 bit) * - 使用 HMAC256 算法 * * @return SecretKey 用于签名和验证 * @throws BusinessException 密钥不满足要求时抛出 */ private SecretKey getSecretKey() { String secret = appProperties.getJwt().getSecret(); if (secret == null || secret.isEmpty()) { throw new BusinessException(ErrorCode.USER_INVALID_TOKEN, "JWT 密钥未配置"); } if (secret.length() < 32) { throw new BusinessException(ErrorCode.USER_INVALID_TOKEN, "JWT 密钥长度必须至少 32 位,当前: " + secret.length()); } return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } } ``` ### **3.3 JWT Token Filter** ```java package com.zwnsyw.zwwwspringbootbasetemplate.security.jwt; import com.zwnsyw.zwwwspringbootbasetemplate.config.AppProperties; import com.zwnsyw.zwwwspringbootbasetemplate.model.vo.LoginUserVO; import com.zwnsyw.zwwwspringbootbasetemplate.security.context.SecurityContext; import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.HashSet; import java.util.Set; /** * JWT Token Filter * * ==================== 执行流程 ==================== * * 1. 从 Authorization Header 提取 Token * 格式:Authorization: Bearer {token} * * 2. 验证 Token 有效性 * - 检查签名 * - 检查过期时间 * * 3. 从 Token 中解析用户信息 * - userId、account、name、roles、permissions * * 4. 将用户信息存入 SecurityContext(ThreadLocal) * - 后续 AuthorizationInterceptor 会使用此信息进行权限检查 * * 5. 继续处理请求 * * 6. finally 块中清理 SecurityContext * - 防止线程池复用时信息泄露 * * ==================== Filter vs Interceptor ==================== * * Filter(Servlet 过滤器): * ├─ 执行顺序:最先执行 * ├─ 作用范围:整个 HTTP 请求/响应 * ├─ 功能:可以修改请求/响应流 * └─ 用途:Token 解析(本类) * * Interceptor(Spring MVC 拦截器): * ├─ 执行顺序:Filter 之后 * ├─ 作用范围:仅 Controller 方法 * ├─ 功能:无法修改请求/响应流 * └─ 用途:权限检查(AuthorizationInterceptor) */ @Slf4j @Component @RequiredArgsConstructor public class JwtTokenFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final AppProperties appProperties; private final JwtTokenBlacklist jwtTokenBlacklist; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { // 1. 从 Header 中提取 Token String token = extractToken(request); // 2. 验证 Token 有效性 if (StringUtils.hasText(token)) { // 检查 Token 是否在黑名单中 if (jwtTokenBlacklist.isBlacklisted(token)) { log.debug("Token is blacklisted: {}", maskToken(token)); } else if (jwtTokenProvider.validateToken(token)) { // Token 有效,解析用户信息 LoginUserVO user = parseTokenToUser(token); if (user != null) { SecurityContext.setCurrentUser(user); log.debug("JWT token authenticated for user: {}", user.getId()); } } } } catch (Exception e) { log.warn("Failed to process JWT token: {}", e.getMessage()); // 不抛异常,继续处理请求 // 权限检查由 AuthorizationInterceptor 负责 } try { // 继续处理请求(Chain of Responsibility 模式) filterChain.doFilter(request, response); } finally { // 必须清理 SecurityContext // 防止线程池中线程被复用时信息泄露 SecurityContext.clear(); } } /** * 从 Authorization Header 中提取 Token * * 格式示例: * Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... * * @param request HTTP 请求 * @return Token 字符串(去掉 "Bearer " 前缀),不存在返回 null */ private String extractToken(HttpServletRequest request) { String authHeader = request.getHeader(appProperties.getJwt().getHeaderName()); if (!StringUtils.hasText(authHeader)) { return null; } String prefix = appProperties.getJwt().getTokenPrefix(); if (authHeader.startsWith(prefix)) { return authHeader.substring(prefix.length()).trim(); } log.trace("Invalid Authorization header format"); return null; } /** * 从 Token 中解析用户信息 * * @param token Token 字符串 * @return LoginUserVO 对象,解析失败返回 null */ private LoginUserVO parseTokenToUser(String token) { try { Claims claims = jwtTokenProvider.getClaimsFromToken(token); LoginUserVO user = new LoginUserVO(); user.setId(Long.valueOf(claims.getSubject())); user.setUserAccount((String) claims.get("account")); user.setUserName((String) claims.get("name")); user.setUserAvatar((String) claims.get("avatar")); // 提取角色列表 @SuppressWarnings("unchecked") java.util.List rolesList = (java.util.List) claims.get("roles"); if (rolesList != null) { user.setRoles(new HashSet<>(rolesList)); } else { user.setRoles(new HashSet<>()); } // 提取权限列表 @SuppressWarnings("unchecked") java.util.List permissionsList = (java.util.List) claims.get("permissions"); if (permissionsList != null) { user.setPermissions(new HashSet<>(permissionsList)); } else { user.setPermissions(new HashSet<>()); } // 设置权限缓存时间(Token 中的权限是最新的) user.setPermissionsCacheTime(System.currentTimeMillis()); log.trace("Token parsed successfully for user: {}", user.getId()); return user; } catch (Exception e) { log.warn("Failed to parse JWT token: {}", e.getMessage()); return null; } } /** * 是否应该跳过此请求(不处理 Filter) * * @param request HTTP 请求 * @return true 跳过,false 处理 */ @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { String path = request.getServletPath(); // 跳过静态资源和系统接口 return path.startsWith("/static/") || path.startsWith("/swagger-resources/") || path.startsWith("/v3/api-docs/") || path.startsWith("/webjars/") || path.equals("/favicon.ico") || path.equals("/doc.html") || path.equals("/health") || path.equals("/error"); } /** * 掩盖 Token(仅显示前 20 个字符,用于日志) * * @param token 完整 Token * @return 掩盖后的 Token */ private String maskToken(String token) { if (token == null || token.length() <= 20) { return "***"; } return token.substring(0, 20) + "..."; } } ``` ### **3.4 JWT Token 黑名单** ```java package com.zwnsyw.zwwwspringbootbasetemplate.security.jwt; import com.zwnsyw.zwwwspringbootbasetemplate.config.AppProperties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import java.util.Set; import java.util.concurrent.TimeUnit; /** * JWT Token 黑名单 * * ==================== 功能说明 ==================== * * 用于实现 JWT Token 登出机制。 * * 由于 JWT Token 是无状态的,无法在服务器端直接"禁用"一个已签发的 Token。 * 黑名单机制是一种在服务器端记录"已登出的 Token"的方案。 * * ==================== 工作流程 ==================== * * 1. 用户登出时: * ├─ 获取 Token 的剩余有效期 * └─ 将 Token 加入黑名单,设置 TTL 为剩余有效期 * * 2. 验证 Token 时(JwtTokenFilter): * ├─ 先检查 Token 是否在黑名单中 * ├─ 如果在黑名单中,拒绝请求 * └─ 如果不在黑名单中,继续验证签名 * * 3. 过期清理: * ├─ Redis 会在 Token 过期时自动删除黑名单记录 * └─ 无需手动清理 * * ==================== 优缺点 ==================== * * 优点: * ✓ 支持强制登出 * ✓ Token 过期后自动清理 * ✓ 实现简单 * * 缺点: * ✗ 增加了 Redis 查询开销 * ✗ 与无状态设计不完全一致 * ✗ 在高并发下可能成为性能瓶颈 * * ==================== 适用场景 ==================== * * - 需要强制登出功能 * - 需要立即禁用 Token * - 用户注销敏感账户时 * * 不适用: * - 只需等待 Token 自然过期 * - 对性能要求极高 */ @Slf4j @Component @RequiredArgsConstructor public class JwtTokenBlacklist { private final StringRedisTemplate redisTemplate; private final AppProperties appProperties; private static final String BLACKLIST_KEY_PREFIX = "jwt:blacklist:"; /** * 将 Token 加入黑名单 * * 使用场景:用户登出时调用 * * @param token Token 字符串 * @param ttlSeconds Token 的剩余有效期(秒) * 建议从 JwtTokenProvider.getTokenRemainingSeconds() 获取 */ public void addToBlacklist(String token, long ttlSeconds) { if (!StringUtils.hasText(token)) { log.warn("Token is empty, skip adding to blacklist"); return; } if (ttlSeconds <= 0) { log.debug("Token already expired, no need to add to blacklist"); return; } String key = BLACKLIST_KEY_PREFIX + token; try { // 设置过期时间为 Token 的剩余时间 // Redis 会在时间到时自动删除该 key redisTemplate.opsForValue().set(key, "1", ttlSeconds, TimeUnit.SECONDS); log.info("Token added to blacklist, TTL: {} seconds", ttlSeconds); } catch (Exception e) { log.error("Failed to add token to blacklist: {}", e.getMessage()); } } /** * 检查 Token 是否在黑名单中 * * @param token Token 字符串 * @return true Token 在黑名单中,false Token 不在黑名单中 */ public boolean isBlacklisted(String token) { if (!StringUtils.hasText(token)) { return false; } String key = BLACKLIST_KEY_PREFIX + token; try { Boolean exists = redisTemplate.hasKey(key); return Boolean.TRUE.equals(exists); } catch (Exception e) { log.error("Failed to check token blacklist status: {}", e.getMessage()); // 发生错误时,为了安全起见,返回 false(允许请求继续) // 或者返回 true(拒绝请求)取决于业务需求 return false; } } /** * 从黑名单中手动移除 Token * * 使用场景:很少使用,仅在特殊情况下需要 * * @param token Token 字符串 */ public void removeFromBlacklist(String token) { if (!StringUtils.hasText(token)) { return; } String key = BLACKLIST_KEY_PREFIX + token; try { redisTemplate.delete(key); log.info("Token removed from blacklist"); } catch (Exception e) { log.error("Failed to remove token from blacklist: {}", e.getMessage()); } } /** * 清空所有黑名单(仅用于测试/调试) */ public void clearBlacklist() { try { Set keys = redisTemplate.keys(BLACKLIST_KEY_PREFIX + "*"); if (keys != null && !keys.isEmpty()) { redisTemplate.delete(keys); log.warn("Cleared all blacklisted tokens: {}", keys.size()); } } catch (Exception e) { log.error("Failed to clear blacklist: {}", e.getMessage()); } } /** * 获取黑名单中的 Token 数量 * * @return Token 数量 */ public long getBlacklistSize() { try { Set keys = redisTemplate.keys(BLACKLIST_KEY_PREFIX + "*"); return keys != null ? keys.size() : 0; } catch (Exception e) { log.error("Failed to get blacklist size: {}", e.getMessage()); return 0; } } } ``` ### **3.5 JWT Token VO** ```java package com.zwnsyw.zwwwspringbootbasetemplate.model.vo; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.io.Serializable; /** * JWT Token 响应 VO * * 登录时返回给客户端的 Token 信息 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor @Schema(description = "JWT Token 响应") public class JwtTokenVO implements Serializable { private static final long serialVersionUID = 1L; @Schema(description = "Access Token(用于访问受保护资源)") private String accessToken; @Schema(description = "Refresh Token(用于刷新 Access Token)") private String refreshToken; @Schema(description = "Token 类型,通常是 Bearer") private String tokenType = "Bearer"; @Schema(description = "Access Token 过期时间(秒)") private long expiresIn; @Schema(description = "当前用户信息") private LoginUserVO user; } ``` ### **3.5 拦截器** ```java package com.zwnsyw.zwwwspringbootbasetemplate.security.config; import com.zwnsyw.zwwwspringbootbasetemplate.security.interceptor.AuthorizationInterceptor; import com.zwnsyw.zwwwspringbootbasetemplate.security.interceptor.SessionRefreshInterceptor; import com.zwnsyw.zwwwspringbootbasetemplate.security.jwt.JwtTokenFilter; import lombok.RequiredArgsConstructor; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; /** * 安全配置 - 注册 Filter 和 Interceptor * * ==================== 执行顺序 ==================== * * 1. JwtTokenFilter(order=0) ← 最先执行,提取 JWT Token * 2. SessionRefreshInterceptor(order=0) ← 刷新 Session * 3. AuthorizationInterceptor(order=1) ← 权限检查 * 4. Controller 业务逻辑 */ @Configuration @RequiredArgsConstructor public class SecurityConfig implements WebMvcConfigurer { private final AuthorizationInterceptor authorizationInterceptor; private final SessionRefreshInterceptor sessionRefreshInterceptor; private final JwtTokenFilter jwtTokenFilter; @Override public void addInterceptors(InterceptorRegistry registry) { // Session 刷新拦截器 // 每次请求刷新 Session 过期时间,实现滑动窗口 registry.addInterceptor(sessionRefreshInterceptor) .addPathPatterns("/**") .excludePathPatterns( "/static/**", "/favicon.ico", "/doc.html", "/swagger-resources/**", "/webjars/**", "/v3/api-docs/**", "/health", "/error" ) .order(0); // 权限验证拦截器 registry.addInterceptor(authorizationInterceptor) .addPathPatterns("/**") .excludePathPatterns( "/static/**", "/favicon.ico", "/doc.html", "/swagger-resources/**", "/webjars/**", "/v3/api-docs/**", "/health", "/error" ) .order(1); } /** * 注册 JWT Token Filter * 在所有拦截器之前执行 */ @Bean public FilterRegistrationBean jwtTokenFilterRegistration(JwtTokenFilter jwtTokenFilter) { FilterRegistrationBean registration = new FilterRegistrationBean<>(); registration.setFilter(jwtTokenFilter); registration.addUrlPatterns("/*"); registration.setOrder(0); // 最高优先级 registration.setName("jwtTokenFilter"); return registration; } } ``` ## **四、重构** ### **清晰的职责边界** ![[清晰的职责边界-82398f56.jpg]] 登录流程: ```text UserLoginDTO │ ▼ AuthController.login() │ ▼ AuthService.login() │ ├──► UserService.validateCredentials() ──► 验证账号密码 │ │ │ ▼ │ 返回 User 实体 │ ├──► UserService.toLoginUserVO() ──► 构建 LoginUserVO │ └──► AuthenticationManager.login() ──► 创建 Session/JWT │ ▼ 返回 AuthResult │ ▼ AuthResponseVO(用户信息 + Token) ``` --- ## **五、总结对比** ![[总结对比-ca27d9d2.jpg]] ---