--- 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* 每 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* 适用场景: * - 生产环境 * - 分布式部署 * - 需要 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* 统一管理 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* 实现滑动窗口机制:每次有效请求都会刷新 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* ==================== 执行流程 ==================== * * 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