From 1910732965e313faa6a25a667d68f5f6ae2da17e Mon Sep 17 00:00:00 2001 From: kurihada Date: Wed, 5 Nov 2025 23:30:19 +0800 Subject: [PATCH] Initial commit --- .dockerignore | 36 + .gitignore | 55 ++ Dockerfile | 12 + pom.xml | 173 +++++ .../kurihada/auth/AuthSystemApplication.java | 15 + .../kurihada/auth/annotation/RateLimit.java | 66 ++ .../auth/annotation/ValidPassword.java | 78 +++ .../kurihada/auth/aspect/RateLimitAspect.java | 150 +++++ .../auth/component/RedisRateLimiter.java | 168 +++++ .../com/kurihada/auth/config/AsyncConfig.java | 30 + .../com/kurihada/auth/config/CorsConfig.java | 45 ++ .../kurihada/auth/config/Knife4jConfig.java | 41 ++ .../auth/config/MybatisPlusConfig.java | 57 ++ .../kurihada/auth/config/ProdCorsConfig.java | 64 ++ .../com/kurihada/auth/config/RedisConfig.java | 49 ++ .../kurihada/auth/config/SecurityConfig.java | 102 +++ .../kurihada/auth/config/WebMvcConfig.java | 31 + .../auth/constant/CacheConstants.java | 92 +++ .../kurihada/auth/context/TenantContext.java | 30 + .../auth/controller/AuditLogController.java | 58 ++ .../auth/controller/AuthController.java | 206 ++++++ .../auth/controller/PermissionController.java | 110 +++ .../auth/controller/RoleController.java | 167 +++++ .../auth/controller/TestController.java | 30 + .../auth/controller/UserController.java | 202 ++++++ .../auth/dto/AssignPermissionsRequest.java | 23 + .../kurihada/auth/dto/AssignRolesRequest.java | 23 + .../auth/dto/AuditLogQueryRequest.java | 67 ++ .../kurihada/auth/dto/AuditLogResponse.java | 85 +++ .../auth/dto/ChangePasswordRequest.java | 27 + .../auth/dto/CreatePermissionRequest.java | 33 + .../kurihada/auth/dto/CreateRoleRequest.java | 23 + .../auth/dto/ForgotPasswordRequest.java | 20 + .../com/kurihada/auth/dto/LoginRequest.java | 31 + .../com/kurihada/auth/dto/LoginResponse.java | 34 + .../com/kurihada/auth/dto/PageRequest.java | 54 ++ .../com/kurihada/auth/dto/PageResult.java | 69 ++ .../com/kurihada/auth/dto/PermissionDTO.java | 42 ++ .../kurihada/auth/dto/PermissionTreeNode.java | 45 ++ .../auth/dto/RefreshTokenRequest.java | 14 + .../kurihada/auth/dto/RegisterRequest.java | 56 ++ .../auth/dto/ResendVerificationRequest.java | 20 + .../auth/dto/ResetPasswordRequest.java | 31 + .../java/com/kurihada/auth/dto/Result.java | 64 ++ .../java/com/kurihada/auth/dto/RoleDTO.java | 37 + .../auth/dto/UpdatePermissionRequest.java | 29 + .../kurihada/auth/dto/UpdateRoleRequest.java | 19 + .../kurihada/auth/dto/UpdateUserRequest.java | 37 + .../java/com/kurihada/auth/dto/UserDTO.java | 43 ++ .../auth/dto/UserPermissionCache.java | 83 +++ .../kurihada/auth/dto/VerifyEmailRequest.java | 18 + .../com/kurihada/auth/entity/AuditLog.java | 96 +++ .../com/kurihada/auth/entity/Permission.java | 87 +++ .../kurihada/auth/entity/RefreshToken.java | 70 ++ .../java/com/kurihada/auth/entity/Role.java | 70 ++ .../kurihada/auth/entity/RolePermission.java | 60 ++ .../java/com/kurihada/auth/entity/Tenant.java | 82 +++ .../java/com/kurihada/auth/entity/User.java | 137 ++++ .../com/kurihada/auth/entity/UserRole.java | 60 ++ .../auth/exception/BusinessException.java | 27 + .../exception/GlobalExceptionHandler.java | 186 ++++++ .../auth/exception/RateLimitException.java | 23 + .../auth/handler/MyMetaObjectHandler.java | 35 + .../auth/interceptor/TenantInterceptor.java | 68 ++ .../kurihada/auth/mapper/AuditLogMapper.java | 12 + .../auth/mapper/PermissionMapper.java | 12 + .../auth/mapper/RefreshTokenMapper.java | 12 + .../com/kurihada/auth/mapper/RoleMapper.java | 12 + .../auth/mapper/RolePermissionMapper.java | 70 ++ .../kurihada/auth/mapper/TenantMapper.java | 12 + .../com/kurihada/auth/mapper/UserMapper.java | 28 + .../kurihada/auth/mapper/UserRoleMapper.java | 93 +++ .../auth/security/JwtAccessDeniedHandler.java | 36 + .../security/JwtAuthenticationEntryPoint.java | 36 + .../security/JwtAuthenticationFilter.java | 111 +++ .../auth/security/UserDetailsServiceImpl.java | 91 +++ .../auth/service/AccountLockService.java | 50 ++ .../auth/service/AuditLogService.java | 68 ++ .../kurihada/auth/service/AuthService.java | 632 ++++++++++++++++++ .../kurihada/auth/service/EmailService.java | 149 +++++ .../EmailVerificationTokenService.java | 110 +++ .../service/PasswordResetTokenService.java | 128 ++++ .../auth/service/PermissionCacheService.java | 94 +++ .../auth/service/PermissionService.java | 51 ++ .../auth/service/RefreshTokenService.java | 102 +++ .../auth/service/RolePermissionService.java | 98 +++ .../kurihada/auth/service/RoleService.java | 45 ++ .../auth/service/TokenBlacklistService.java | 71 ++ .../auth/service/UserRoleService.java | 141 ++++ .../kurihada/auth/service/UserService.java | 466 +++++++++++++ .../service/impl/AccountLockServiceImpl.java | 146 ++++ .../service/impl/AuditLogServiceImpl.java | 232 +++++++ .../impl/PermissionCacheServiceImpl.java | 314 +++++++++ .../service/impl/PermissionServiceImpl.java | 488 ++++++++++++++ .../impl/RolePermissionServiceImpl.java | 238 +++++++ .../auth/service/impl/RoleServiceImpl.java | 398 +++++++++++ .../service/impl/UserRoleServiceImpl.java | 318 +++++++++ .../java/com/kurihada/auth/util/IpUtil.java | 160 +++++ .../java/com/kurihada/auth/util/JwtUtil.java | 155 +++++ .../auth/util/PasswordStrengthUtil.java | 179 +++++ .../kurihada/auth/util/TenantResolver.java | 182 +++++ .../auth/validation/PasswordValidator.java | 228 +++++++ src/main/resources/application.yml | 190 ++++++ .../resources/db/migration/init_database.sql | 259 +++++++ src/main/resources/mapper/UserMapper.xml | 158 +++++ .../templates/email-verification.html | 166 +++++ .../templates/password-reset-email.html | 112 ++++ 107 files changed, 10748 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 pom.xml create mode 100644 src/main/java/com/kurihada/auth/AuthSystemApplication.java create mode 100644 src/main/java/com/kurihada/auth/annotation/RateLimit.java create mode 100644 src/main/java/com/kurihada/auth/annotation/ValidPassword.java create mode 100644 src/main/java/com/kurihada/auth/aspect/RateLimitAspect.java create mode 100644 src/main/java/com/kurihada/auth/component/RedisRateLimiter.java create mode 100644 src/main/java/com/kurihada/auth/config/AsyncConfig.java create mode 100644 src/main/java/com/kurihada/auth/config/CorsConfig.java create mode 100644 src/main/java/com/kurihada/auth/config/Knife4jConfig.java create mode 100644 src/main/java/com/kurihada/auth/config/MybatisPlusConfig.java create mode 100644 src/main/java/com/kurihada/auth/config/ProdCorsConfig.java create mode 100644 src/main/java/com/kurihada/auth/config/RedisConfig.java create mode 100644 src/main/java/com/kurihada/auth/config/SecurityConfig.java create mode 100644 src/main/java/com/kurihada/auth/config/WebMvcConfig.java create mode 100644 src/main/java/com/kurihada/auth/constant/CacheConstants.java create mode 100644 src/main/java/com/kurihada/auth/context/TenantContext.java create mode 100644 src/main/java/com/kurihada/auth/controller/AuditLogController.java create mode 100644 src/main/java/com/kurihada/auth/controller/AuthController.java create mode 100644 src/main/java/com/kurihada/auth/controller/PermissionController.java create mode 100644 src/main/java/com/kurihada/auth/controller/RoleController.java create mode 100644 src/main/java/com/kurihada/auth/controller/TestController.java create mode 100644 src/main/java/com/kurihada/auth/controller/UserController.java create mode 100644 src/main/java/com/kurihada/auth/dto/AssignPermissionsRequest.java create mode 100644 src/main/java/com/kurihada/auth/dto/AssignRolesRequest.java create mode 100644 src/main/java/com/kurihada/auth/dto/AuditLogQueryRequest.java create mode 100644 src/main/java/com/kurihada/auth/dto/AuditLogResponse.java create mode 100644 src/main/java/com/kurihada/auth/dto/ChangePasswordRequest.java create mode 100644 src/main/java/com/kurihada/auth/dto/CreatePermissionRequest.java create mode 100644 src/main/java/com/kurihada/auth/dto/CreateRoleRequest.java create mode 100644 src/main/java/com/kurihada/auth/dto/ForgotPasswordRequest.java create mode 100644 src/main/java/com/kurihada/auth/dto/LoginRequest.java create mode 100644 src/main/java/com/kurihada/auth/dto/LoginResponse.java create mode 100644 src/main/java/com/kurihada/auth/dto/PageRequest.java create mode 100644 src/main/java/com/kurihada/auth/dto/PageResult.java create mode 100644 src/main/java/com/kurihada/auth/dto/PermissionDTO.java create mode 100644 src/main/java/com/kurihada/auth/dto/PermissionTreeNode.java create mode 100644 src/main/java/com/kurihada/auth/dto/RefreshTokenRequest.java create mode 100644 src/main/java/com/kurihada/auth/dto/RegisterRequest.java create mode 100644 src/main/java/com/kurihada/auth/dto/ResendVerificationRequest.java create mode 100644 src/main/java/com/kurihada/auth/dto/ResetPasswordRequest.java create mode 100644 src/main/java/com/kurihada/auth/dto/Result.java create mode 100644 src/main/java/com/kurihada/auth/dto/RoleDTO.java create mode 100644 src/main/java/com/kurihada/auth/dto/UpdatePermissionRequest.java create mode 100644 src/main/java/com/kurihada/auth/dto/UpdateRoleRequest.java create mode 100644 src/main/java/com/kurihada/auth/dto/UpdateUserRequest.java create mode 100644 src/main/java/com/kurihada/auth/dto/UserDTO.java create mode 100644 src/main/java/com/kurihada/auth/dto/UserPermissionCache.java create mode 100644 src/main/java/com/kurihada/auth/dto/VerifyEmailRequest.java create mode 100644 src/main/java/com/kurihada/auth/entity/AuditLog.java create mode 100644 src/main/java/com/kurihada/auth/entity/Permission.java create mode 100644 src/main/java/com/kurihada/auth/entity/RefreshToken.java create mode 100644 src/main/java/com/kurihada/auth/entity/Role.java create mode 100644 src/main/java/com/kurihada/auth/entity/RolePermission.java create mode 100644 src/main/java/com/kurihada/auth/entity/Tenant.java create mode 100644 src/main/java/com/kurihada/auth/entity/User.java create mode 100644 src/main/java/com/kurihada/auth/entity/UserRole.java create mode 100644 src/main/java/com/kurihada/auth/exception/BusinessException.java create mode 100644 src/main/java/com/kurihada/auth/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/kurihada/auth/exception/RateLimitException.java create mode 100644 src/main/java/com/kurihada/auth/handler/MyMetaObjectHandler.java create mode 100644 src/main/java/com/kurihada/auth/interceptor/TenantInterceptor.java create mode 100644 src/main/java/com/kurihada/auth/mapper/AuditLogMapper.java create mode 100644 src/main/java/com/kurihada/auth/mapper/PermissionMapper.java create mode 100644 src/main/java/com/kurihada/auth/mapper/RefreshTokenMapper.java create mode 100644 src/main/java/com/kurihada/auth/mapper/RoleMapper.java create mode 100644 src/main/java/com/kurihada/auth/mapper/RolePermissionMapper.java create mode 100644 src/main/java/com/kurihada/auth/mapper/TenantMapper.java create mode 100644 src/main/java/com/kurihada/auth/mapper/UserMapper.java create mode 100644 src/main/java/com/kurihada/auth/mapper/UserRoleMapper.java create mode 100644 src/main/java/com/kurihada/auth/security/JwtAccessDeniedHandler.java create mode 100644 src/main/java/com/kurihada/auth/security/JwtAuthenticationEntryPoint.java create mode 100644 src/main/java/com/kurihada/auth/security/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/kurihada/auth/security/UserDetailsServiceImpl.java create mode 100644 src/main/java/com/kurihada/auth/service/AccountLockService.java create mode 100644 src/main/java/com/kurihada/auth/service/AuditLogService.java create mode 100644 src/main/java/com/kurihada/auth/service/AuthService.java create mode 100644 src/main/java/com/kurihada/auth/service/EmailService.java create mode 100644 src/main/java/com/kurihada/auth/service/EmailVerificationTokenService.java create mode 100644 src/main/java/com/kurihada/auth/service/PasswordResetTokenService.java create mode 100644 src/main/java/com/kurihada/auth/service/PermissionCacheService.java create mode 100644 src/main/java/com/kurihada/auth/service/PermissionService.java create mode 100644 src/main/java/com/kurihada/auth/service/RefreshTokenService.java create mode 100644 src/main/java/com/kurihada/auth/service/RolePermissionService.java create mode 100644 src/main/java/com/kurihada/auth/service/RoleService.java create mode 100644 src/main/java/com/kurihada/auth/service/TokenBlacklistService.java create mode 100644 src/main/java/com/kurihada/auth/service/UserRoleService.java create mode 100644 src/main/java/com/kurihada/auth/service/UserService.java create mode 100644 src/main/java/com/kurihada/auth/service/impl/AccountLockServiceImpl.java create mode 100644 src/main/java/com/kurihada/auth/service/impl/AuditLogServiceImpl.java create mode 100644 src/main/java/com/kurihada/auth/service/impl/PermissionCacheServiceImpl.java create mode 100644 src/main/java/com/kurihada/auth/service/impl/PermissionServiceImpl.java create mode 100644 src/main/java/com/kurihada/auth/service/impl/RolePermissionServiceImpl.java create mode 100644 src/main/java/com/kurihada/auth/service/impl/RoleServiceImpl.java create mode 100644 src/main/java/com/kurihada/auth/service/impl/UserRoleServiceImpl.java create mode 100644 src/main/java/com/kurihada/auth/util/IpUtil.java create mode 100644 src/main/java/com/kurihada/auth/util/JwtUtil.java create mode 100644 src/main/java/com/kurihada/auth/util/PasswordStrengthUtil.java create mode 100644 src/main/java/com/kurihada/auth/util/TenantResolver.java create mode 100644 src/main/java/com/kurihada/auth/validation/PasswordValidator.java create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/db/migration/init_database.sql create mode 100644 src/main/resources/mapper/UserMapper.xml create mode 100644 src/main/resources/templates/email-verification.html create mode 100644 src/main/resources/templates/password-reset-email.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7a576e5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,36 @@ +# Git +.git +.gitignore +.gitattributes + +# CI/CD +.github + +# IDE +.idea +.vscode +*.iml +*.iws +*.ipr + +# Maven +target/ +!target/*.jar +!.mvn/wrapper/maven-wrapper.jar + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db + +# Documentation +README.md +docs/ + +# Docker +Dockerfile +.dockerignore +docker-compose.yml \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..094788e --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# Eclipse +.settings/ +.project +.classpath +.factorypath + +# IntelliJ IDEA +.idea/ +*.iws +*.iml +*.ipr +out/ + +# VS Code +.vscode/ + +# macOS +.DS_Store + +# Windows +Thumbs.db +desktop.ini + +# Log files +*.log +logs/ + +# Temporary files +*.tmp +*.temp +*.swp +*~ + +# Application properties with sensitive data +application-local.yml +application-local.properties + +# Maven cache (CI/CD) +.m2/ + +# Environment variables +.env +.env.local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3b81c52 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +# 多阶段构建 Dockerfile +# 阶段1: 构建 +FROM openjdk:21 + +WORKDIR /app + +COPY target/auth-backend-1.0.0.jar /app/ + +EXPOSE 9001 + +# 启动应用 +CMD ["java", "-jar", "auth-backend-1.0.0.jar"] diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..c29006d --- /dev/null +++ b/pom.xml @@ -0,0 +1,173 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.7 + + + + com.kurihada + auth-backend + 1.0.0 + auth-system + 权限管理系统 + + + 21 + 21 + 21 + UTF-8 + 0.12.3 + 3.5.14 + + + + + + com.baomidou + mybatis-plus-bom + ${mybatis-plus.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-security + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + org.springframework.boot + spring-boot-starter-mail + + + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + + + io.lettuce + lettuce-core + + + + + com.baomidou + mybatis-plus-spring-boot3-starter + + + com.baomidou + mybatis-plus-jsqlparser + + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-aop + + + + + com.mysql + mysql-connector-j + runtime + + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + + org.projectlombok + lombok + true + + + + + com.github.xiaoymin + knife4j-openapi3-jakarta-spring-boot-starter + 4.3.0 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + org.springframework.security + spring-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/src/main/java/com/kurihada/auth/AuthSystemApplication.java b/src/main/java/com/kurihada/auth/AuthSystemApplication.java new file mode 100644 index 0000000..4d1db44 --- /dev/null +++ b/src/main/java/com/kurihada/auth/AuthSystemApplication.java @@ -0,0 +1,15 @@ +package com.kurihada.auth; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 权限系统主应用类 + */ +@SpringBootApplication +public class AuthSystemApplication { + + public static void main(String[] args) { + SpringApplication.run(AuthSystemApplication.class, args); + } +} diff --git a/src/main/java/com/kurihada/auth/annotation/RateLimit.java b/src/main/java/com/kurihada/auth/annotation/RateLimit.java new file mode 100644 index 0000000..72f60fb --- /dev/null +++ b/src/main/java/com/kurihada/auth/annotation/RateLimit.java @@ -0,0 +1,66 @@ +package com.kurihada.auth.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 限流注解 + * 用于标记需要限流的接口 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RateLimit { + + /** + * 限流的键前缀,用于区分不同的限流规则 + * 例如: "login", "register", "api" + */ + String key() default "rate_limit"; + + /** + * 时间窗口,单位:秒 + * 默认 60 秒 + */ + int window() default 60; + + /** + * 时间窗口内允许的最大请求次数 + * 默认 5 次 + */ + int maxRequests() default 5; + + /** + * 限流维度类型 + * IP: 基于IP地址限流 + * USER: 基于用户名限流 + * IP_AND_USER: 同时基于IP和用户名限流 + */ + LimitType limitType() default LimitType.IP; + + /** + * 限流提示消息 + */ + String message() default "请求过于频繁,请稍后再试"; + + /** + * 限流维度类型枚举 + */ + enum LimitType { + /** + * 基于IP地址限流 + */ + IP, + + /** + * 基于用户名限流 + */ + USER, + + /** + * 同时基于IP和用户名限流 + */ + IP_AND_USER + } +} diff --git a/src/main/java/com/kurihada/auth/annotation/ValidPassword.java b/src/main/java/com/kurihada/auth/annotation/ValidPassword.java new file mode 100644 index 0000000..52a878e --- /dev/null +++ b/src/main/java/com/kurihada/auth/annotation/ValidPassword.java @@ -0,0 +1,78 @@ +package com.kurihada.auth.annotation; + +import com.kurihada.auth.validation.PasswordValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +/** + * 密码复杂度验证注解 + * 验证密码是否满足复杂度要求 + */ +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = PasswordValidator.class) +@Documented +public @interface ValidPassword { + + /** + * 默认错误消息 + */ + String message() default "密码不符合复杂度要求"; + + /** + * 分组 + */ + Class[] groups() default {}; + + /** + * 载荷 + */ + Class[] payload() default {}; + + /** + * 最小长度(默认8位) + */ + int minLength() default 8; + + /** + * 最大长度(默认32位) + */ + int maxLength() default 32; + + /** + * 是否要求包含小写字母(默认true) + */ + boolean requireLowercase() default true; + + /** + * 是否要求包含大写字母(默认true) + */ + boolean requireUppercase() default true; + + /** + * 是否要求包含数字(默认true) + */ + boolean requireDigit() default true; + + /** + * 是否要求包含特殊字符(默认true) + */ + boolean requireSpecialChar() default true; + + /** + * 允许的特殊字符(默认常用特殊字符) + */ + String specialChars() default "!@#$%^&*()_+-=[]{}|;:,.<>?"; + + /** + * 是否禁止包含用户名(默认true) + */ + boolean forbidUsername() default true; + + /** + * 是否禁止常见弱密码(默认true) + */ + boolean forbidCommonPasswords() default true; +} diff --git a/src/main/java/com/kurihada/auth/aspect/RateLimitAspect.java b/src/main/java/com/kurihada/auth/aspect/RateLimitAspect.java new file mode 100644 index 0000000..6f66a1a --- /dev/null +++ b/src/main/java/com/kurihada/auth/aspect/RateLimitAspect.java @@ -0,0 +1,150 @@ +package com.kurihada.auth.aspect; + +import com.kurihada.auth.annotation.RateLimit; +import com.kurihada.auth.component.RedisRateLimiter; +import com.kurihada.auth.exception.RateLimitException; +import com.kurihada.auth.util.IpUtil; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.lang.reflect.Method; + +/** + * 限流切面 + * 拦截带有 @RateLimit 注解的方法,执行限流检查 + */ +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class RateLimitAspect { + + private final RedisRateLimiter rateLimiter; + + @Around("@annotation(com.kurihada.auth.annotation.RateLimit)") + public Object around(ProceedingJoinPoint point) throws Throwable { + MethodSignature signature = (MethodSignature) point.getSignature(); + Method method = signature.getMethod(); + RateLimit rateLimit = method.getAnnotation(RateLimit.class); + + if (rateLimit == null) { + return point.proceed(); + } + + // 获取请求信息 + HttpServletRequest request = getRequest(); + if (request == null) { + log.warn("无法获取 HttpServletRequest,跳过限流检查"); + return point.proceed(); + } + + // 构建限流键 + String limitKey = buildLimitKey(rateLimit, request, point.getArgs()); + + // 执行限流检查 + RedisRateLimiter.RateLimitResult result = rateLimiter.tryAcquire( + limitKey, + rateLimit.window(), + rateLimit.maxRequests() + ); + + if (!result.isAllowed()) { + String errorMessage = String.format("%s,请在 %d 秒后重试", + rateLimit.message(), result.getWaitTime()); + log.warn("限流触发 - 方法: {}, Key: {}, 等待时间: {}秒", + method.getName(), limitKey, result.getWaitTime()); + throw new RateLimitException(errorMessage, result.getWaitTime()); + } + + log.debug("限流检查通过 - 方法: {}, Key: {}, 剩余次数: {}", + method.getName(), limitKey, result.getRemaining()); + + return point.proceed(); + } + + /** + * 构建限流键 + */ + private String buildLimitKey(RateLimit rateLimit, HttpServletRequest request, Object[] args) { + StringBuilder keyBuilder = new StringBuilder("rate_limit:"); + keyBuilder.append(rateLimit.key()).append(":"); + + switch (rateLimit.limitType()) { + case IP: + String ip = IpUtil.getIpAddress(request); + keyBuilder.append("ip:").append(ip); + break; + + case USER: + String username = extractUsername(args); + if (username != null) { + keyBuilder.append("user:").append(username); + } else { + // 如果无法获取用户名,降级为IP限流 + log.warn("无法提取用户名,降级为IP限流"); + String fallbackIp = IpUtil.getIpAddress(request); + keyBuilder.append("ip:").append(fallbackIp); + } + break; + + case IP_AND_USER: + String ipAddr = IpUtil.getIpAddress(request); + String user = extractUsername(args); + if (user != null) { + keyBuilder.append("ip_user:").append(ipAddr).append(":").append(user); + } else { + // 如果无法获取用户名,只使用IP + keyBuilder.append("ip:").append(ipAddr); + } + break; + } + + return keyBuilder.toString(); + } + + /** + * 从方法参数中提取用户名 + * 支持 LoginRequest、RegisterRequest 等包含 username 字段的对象 + */ + private String extractUsername(Object[] args) { + if (args == null || args.length == 0) { + return null; + } + + for (Object arg : args) { + if (arg == null) { + continue; + } + + try { + // 尝试通过反射获取 username 字段 + Method getUsername = arg.getClass().getMethod("getUsername"); + Object username = getUsername.invoke(arg); + if (username instanceof String) { + return (String) username; + } + } catch (Exception e) { + // 忽略异常,继续尝试下一个参数 + } + } + + return null; + } + + /** + * 获取当前 HTTP 请求 + */ + private HttpServletRequest getRequest() { + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + return attributes != null ? attributes.getRequest() : null; + } +} diff --git a/src/main/java/com/kurihada/auth/component/RedisRateLimiter.java b/src/main/java/com/kurihada/auth/component/RedisRateLimiter.java new file mode 100644 index 0000000..77b71f9 --- /dev/null +++ b/src/main/java/com/kurihada/auth/component/RedisRateLimiter.java @@ -0,0 +1,168 @@ +package com.kurihada.auth.component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; + +/** + * Redis 实现的限流器 + * 使用滑动窗口算法 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisRateLimiter { + + private final RedisTemplate redisTemplate; + + /** + * 滑动窗口限流算法的 Lua 脚本 + * 使用 Sorted Set 实现,score 为时间戳 + */ + private static final String RATE_LIMIT_SCRIPT = """ + local key = KEYS[1] + local window = tonumber(ARGV[1]) + local maxRequests = tonumber(ARGV[2]) + local currentTime = tonumber(ARGV[3]) + + -- 移除窗口之外的记录 + redis.call('ZREMRANGEBYSCORE', key, 0, currentTime - window * 1000) + + -- 获取当前窗口内的请求数 + local currentRequests = redis.call('ZCARD', key) + + if currentRequests < maxRequests then + -- 允许请求,添加当前时间戳 + redis.call('ZADD', key, currentTime, currentTime) + -- 设置过期时间(窗口时间 + 1秒缓冲) + redis.call('EXPIRE', key, window + 1) + return {1, maxRequests - currentRequests - 1} + else + -- 拒绝请求,返回最早请求的时间,用于计算需要等待的时间 + local oldestTime = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')[2] + local remainingTime = math.ceil((tonumber(oldestTime) + window * 1000 - currentTime) / 1000) + return {0, remainingTime} + end + """; + + /** + * 检查是否允许请求 + * + * @param key 限流键 + * @param window 时间窗口(秒) + * @param maxRequests 最大请求数 + * @return 如果允许请求返回 true,否则返回 false + */ + public boolean isAllowed(String key, int window, int maxRequests) { + RateLimitResult result = tryAcquire(key, window, maxRequests); + return result.isAllowed(); + } + + /** + * 尝试获取请求许可 + * + * @param key 限流键 + * @param window 时间窗口(秒) + * @param maxRequests 最大请求数 + * @return 限流结果 + */ + public RateLimitResult tryAcquire(String key, int window, int maxRequests) { + try { + long currentTime = Instant.now().toEpochMilli(); + + RedisScript script = RedisScript.of(RATE_LIMIT_SCRIPT, List.class); + List result = (List) redisTemplate.execute( + script, + Collections.singletonList(key), + String.valueOf(window), + String.valueOf(maxRequests), + String.valueOf(currentTime) + ); + + if (result == null || result.size() != 2) { + log.error("限流脚本执行异常,返回结果为空或格式错误"); + // 降级处理:允许请求通过 + return new RateLimitResult(true, 0, 0); + } + + boolean allowed = result.get(0) == 1; + int remaining = result.get(1).intValue(); + + if (allowed) { + log.debug("限流检查通过,key: {}, 剩余请求数: {}", key, remaining); + return new RateLimitResult(true, remaining, 0); + } else { + log.warn("触发限流,key: {}, 需等待: {}秒", key, remaining); + return new RateLimitResult(false, 0, remaining); + } + + } catch (Exception e) { + log.error("限流检查异常,key: {}", key, e); + // 降级处理:允许请求通过,避免因限流组件故障影响正常业务 + return new RateLimitResult(true, 0, 0); + } + } + + /** + * 重置限流计数 + * + * @param key 限流键 + */ + public void reset(String key) { + try { + redisTemplate.delete(key); + log.info("重置限流计数,key: {}", key); + } catch (Exception e) { + log.error("重置限流计数失败,key: {}", key, e); + } + } + + /** + * 获取当前窗口内的请求数 + * + * @param key 限流键 + * @return 请求数 + */ + public long getCurrentRequests(String key) { + try { + Long count = redisTemplate.opsForZSet().zCard(key); + return count != null ? count : 0; + } catch (Exception e) { + log.error("获取当前请求数失败,key: {}", key, e); + return 0; + } + } + + /** + * 限流结果 + */ + public static class RateLimitResult { + private final boolean allowed; + private final int remaining; + private final int waitTime; + + public RateLimitResult(boolean allowed, int remaining, int waitTime) { + this.allowed = allowed; + this.remaining = remaining; + this.waitTime = waitTime; + } + + public boolean isAllowed() { + return allowed; + } + + public int getRemaining() { + return remaining; + } + + public int getWaitTime() { + return waitTime; + } + } +} diff --git a/src/main/java/com/kurihada/auth/config/AsyncConfig.java b/src/main/java/com/kurihada/auth/config/AsyncConfig.java new file mode 100644 index 0000000..3675017 --- /dev/null +++ b/src/main/java/com/kurihada/auth/config/AsyncConfig.java @@ -0,0 +1,30 @@ +package com.kurihada.auth.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +/** + * 异步配置 + * 用于审计日志等异步任务 + */ +@Configuration +@EnableAsync +public class AsyncConfig implements AsyncConfigurer { + + @Override + public Executor getAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(5); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("async-audit-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(60); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/kurihada/auth/config/CorsConfig.java b/src/main/java/com/kurihada/auth/config/CorsConfig.java new file mode 100644 index 0000000..bfd6fe8 --- /dev/null +++ b/src/main/java/com/kurihada/auth/config/CorsConfig.java @@ -0,0 +1,45 @@ +package com.kurihada.auth.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +/** + * 开发环境跨域配置(允许所有域名) + */ +@Configuration +@Profile({"default", "dev"}) // 仅在开发环境生效 +public class CorsConfig { + + @Bean + public CorsFilter corsFilter() { + CorsConfiguration config = new CorsConfiguration(); + + // 允许所有域名跨域(生产环境应该指定具体域名) + config.addAllowedOriginPattern("*"); + + // 允许所有请求头 + config.addAllowedHeader("*"); + + // 允许所有请求方法 + config.addAllowedMethod("*"); + + // 允许发送Cookie + config.setAllowCredentials(true); + + // 预检请求的有效期,单位为秒 + config.setMaxAge(3600L); + + // 允许的响应头 + config.addExposedHeader("Authorization"); + config.addExposedHeader("Content-Type"); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + + return new CorsFilter(source); + } +} diff --git a/src/main/java/com/kurihada/auth/config/Knife4jConfig.java b/src/main/java/com/kurihada/auth/config/Knife4jConfig.java new file mode 100644 index 0000000..c9d2fa3 --- /dev/null +++ b/src/main/java/com/kurihada/auth/config/Knife4jConfig.java @@ -0,0 +1,41 @@ +package com.kurihada.auth.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.Components; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Knife4j API文档配置 + */ +@Configuration +public class Knife4jConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("权限管理系统 API") + .description("基于Spring Boot 3 + MyBatis Plus + JWT的权限管理系统接口文档") + .version("1.0.0") + .contact(new Contact() + .name("Auth System") + .email("support@example.com")) + .license(new License() + .name("Apache 2.0") + .url("https://www.apache.org/licenses/LICENSE-2.0"))) + .components(new Components() + .addSecuritySchemes("Bearer", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("请输入JWT Token,格式:Bearer {token}"))) + .addSecurityItem(new SecurityRequirement().addList("Bearer")); + } +} diff --git a/src/main/java/com/kurihada/auth/config/MybatisPlusConfig.java b/src/main/java/com/kurihada/auth/config/MybatisPlusConfig.java new file mode 100644 index 0000000..9d69985 --- /dev/null +++ b/src/main/java/com/kurihada/auth/config/MybatisPlusConfig.java @@ -0,0 +1,57 @@ +package com.kurihada.auth.config; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import com.kurihada.auth.context.TenantContext; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.LongValue; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.kurihada.auth.mapper") +public class MybatisPlusConfig { + + /** + * 添加分页插件和租户插件 + */ + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + + // 租户插件(必须在分页插件之前) + interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler() { + @Override + public Expression getTenantId() { + // 从上下文中获取租户ID + Long tenantId = TenantContext.getTenantId(); + if (tenantId == null) { + return null; + } + return new LongValue(tenantId); + } + + @Override + public String getTenantIdColumn() { + // 租户字段名 + return "tenant_id"; + } + + @Override + public boolean ignoreTable(String tableName) { + // 忽略的表(不进行租户隔离) + // tenants 表本身不需要租户隔离 + // permissions 表允许为 null(公共权限) + return "tenants".equalsIgnoreCase(tableName); + } + })); + + // 分页插件(必须在租户插件之后) + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); + + return interceptor; + } +} \ No newline at end of file diff --git a/src/main/java/com/kurihada/auth/config/ProdCorsConfig.java b/src/main/java/com/kurihada/auth/config/ProdCorsConfig.java new file mode 100644 index 0000000..34cb495 --- /dev/null +++ b/src/main/java/com/kurihada/auth/config/ProdCorsConfig.java @@ -0,0 +1,64 @@ +package com.kurihada.auth.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +import java.util.Arrays; + +/** + * 生产环境跨域配置(限制允许的域名) + */ +@Configuration +@Profile("prod") // 仅在生产环境生效 +public class ProdCorsConfig { + + @Bean + public CorsFilter prodCorsFilter() { + CorsConfiguration config = new CorsConfiguration(); + + // 生产环境:只允许指定的域名跨域 + config.setAllowedOrigins(Arrays.asList( + "https://your-frontend-domain.com", + "https://www.your-frontend-domain.com", + "https://admin.your-frontend-domain.com" + )); + + // 允许的请求头 + config.setAllowedHeaders(Arrays.asList( + "Authorization", + "Content-Type", + "Accept", + "X-Requested-With" + )); + + // 允许的请求方法 + config.setAllowedMethods(Arrays.asList( + "GET", + "POST", + "PUT", + "DELETE", + "OPTIONS" + )); + + // 允许发送Cookie + config.setAllowCredentials(true); + + // 预检请求的有效期 + config.setMaxAge(3600L); + + // 允许的响应头 + config.setExposedHeaders(Arrays.asList( + "Authorization", + "Content-Type" + )); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + + return new CorsFilter(source); + } +} diff --git a/src/main/java/com/kurihada/auth/config/RedisConfig.java b/src/main/java/com/kurihada/auth/config/RedisConfig.java new file mode 100644 index 0000000..dee5967 --- /dev/null +++ b/src/main/java/com/kurihada/auth/config/RedisConfig.java @@ -0,0 +1,49 @@ +package com.kurihada.auth.config; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Redis 配置类 + */ +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // 使用Jackson2JsonRedisSerializer来序列化和反序列化redis的value值 + Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer<>(Object.class); + + ObjectMapper mapper = new ObjectMapper(); + mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); + mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL); + + serializer.setObjectMapper(mapper); + + // 使用StringRedisSerializer来序列化和反序列化redis的key值 + StringRedisSerializer stringSerializer = new StringRedisSerializer(); + + // key采用String的序列化方式 + template.setKeySerializer(stringSerializer); + // hash的key也采用String的序列化方式 + template.setHashKeySerializer(stringSerializer); + // value序列化方式采用jackson + template.setValueSerializer(serializer); + // hash的value序列化方式采用jackson + template.setHashValueSerializer(serializer); + + template.afterPropertiesSet(); + return template; + } +} diff --git a/src/main/java/com/kurihada/auth/config/SecurityConfig.java b/src/main/java/com/kurihada/auth/config/SecurityConfig.java new file mode 100644 index 0000000..3fa12af --- /dev/null +++ b/src/main/java/com/kurihada/auth/config/SecurityConfig.java @@ -0,0 +1,102 @@ +package com.kurihada.auth.config; + +import com.kurihada.auth.security.JwtAccessDeniedHandler; +import com.kurihada.auth.security.JwtAuthenticationEntryPoint; +import com.kurihada.auth.security.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/** + * Spring Security配置类 + */ +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final UserDetailsService userDetailsService; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + + /** + * 配置安全过滤器链 + */ + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + // 禁用CSRF + .csrf(AbstractHttpConfigurer::disable) + // 启用CORS(使用默认配置,会自动使用CorsFilter) + .cors(cors -> {}) + // 配置异常处理 + .exceptionHandling(exception -> exception + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler) + ) + // 配置会话管理 - 无状态 + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + // 配置授权规则 + .authorizeHttpRequests(auth -> auth + // 允许匿名访问的接口 + .requestMatchers("/api/v1/auth/login", "/api/v1/auth/register", + "/api/v1/auth/forgot-password", "/api/v1/auth/reset-password", + "/api/v1/auth/verify-email", "/api/v1/auth/resend-verification").permitAll() + // Knife4j 文档接口 + .requestMatchers("/doc.html", "/webjars/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll() + // 其他接口需要认证 + .anyRequest().authenticated() + ) + // 配置认证提供者 + .authenticationProvider(authenticationProvider()) + // 添加JWT过滤器 + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + /** + * 密码编码器 + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** + * 认证提供者 + */ + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + /** + * 认证管理器 + */ + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } +} diff --git a/src/main/java/com/kurihada/auth/config/WebMvcConfig.java b/src/main/java/com/kurihada/auth/config/WebMvcConfig.java new file mode 100644 index 0000000..61338ef --- /dev/null +++ b/src/main/java/com/kurihada/auth/config/WebMvcConfig.java @@ -0,0 +1,31 @@ +package com.kurihada.auth.config; + +import com.kurihada.auth.interceptor.TenantInterceptor; +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; + +/** + * Web MVC 配置 + */ +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final TenantInterceptor tenantInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(tenantInterceptor) + .addPathPatterns("/**") + .excludePathPatterns( + "/api/auth/login", + "/api/auth/register", + "/doc.html", + "/swagger-resources/**", + "/webjars/**", + "/v3/api-docs/**" + ); + } +} diff --git a/src/main/java/com/kurihada/auth/constant/CacheConstants.java b/src/main/java/com/kurihada/auth/constant/CacheConstants.java new file mode 100644 index 0000000..2ca9c33 --- /dev/null +++ b/src/main/java/com/kurihada/auth/constant/CacheConstants.java @@ -0,0 +1,92 @@ +package com.kurihada.auth.constant; + +/** + * 缓存常量 + */ +public class CacheConstants { + + /** + * 缓存键前缀 + */ + public static final String CACHE_PREFIX = "auth:"; + + /** + * 用户权限缓存键前缀 + * 格式: auth:user:permissions:{userId}:{tenantId} + */ + public static final String USER_PERMISSIONS_KEY = CACHE_PREFIX + "user:permissions:"; + + /** + * 用户角色缓存键前缀 + * 格式: auth:user:roles:{userId}:{tenantId} + */ + public static final String USER_ROLES_KEY = CACHE_PREFIX + "user:roles:"; + + /** + * 角色权限缓存键前缀 + * 格式: auth:role:permissions:{roleId}:{tenantId} + */ + public static final String ROLE_PERMISSIONS_KEY = CACHE_PREFIX + "role:permissions:"; + + /** + * 租户所有用户权限缓存键前缀 + * 格式: auth:tenant:users:{tenantId} + */ + public static final String TENANT_USERS_KEY = CACHE_PREFIX + "tenant:users:"; + + /** + * 用户信息缓存键前缀 + * 格式: auth:user:info:{userId} + */ + public static final String USER_INFO_KEY = CACHE_PREFIX + "user:info:"; + + /** + * 权限缓存过期时间(秒)- 默认2小时 + */ + public static final long PERMISSION_CACHE_TTL = 7200L; + + /** + * 用户信息缓存过期时间(秒)- 默认30分钟 + */ + public static final long USER_INFO_CACHE_TTL = 1800L; + + /** + * 角色权限缓存过期时间(秒)- 默认1小时 + */ + public static final long ROLE_PERMISSION_CACHE_TTL = 3600L; + + /** + * 构建用户权限缓存键 + */ + public static String buildUserPermissionKey(Long userId, Long tenantId) { + return USER_PERMISSIONS_KEY + userId + ":" + tenantId; + } + + /** + * 构建用户角色缓存键 + */ + public static String buildUserRoleKey(Long userId, Long tenantId) { + return USER_ROLES_KEY + userId + ":" + tenantId; + } + + /** + * 构建角色权限缓存键 + */ + public static String buildRolePermissionKey(Long roleId, Long tenantId) { + return ROLE_PERMISSIONS_KEY + roleId + ":" + tenantId; + } + + /** + * 构建租户用户集合缓存键 + */ + public static String buildTenantUsersKey(Long tenantId) { + return TENANT_USERS_KEY + tenantId; + } + + /** + * 构建用户信息缓存键 + */ + public static String buildUserInfoKey(Long userId) { + return USER_INFO_KEY + userId; + } +} diff --git a/src/main/java/com/kurihada/auth/context/TenantContext.java b/src/main/java/com/kurihada/auth/context/TenantContext.java new file mode 100644 index 0000000..a0999a5 --- /dev/null +++ b/src/main/java/com/kurihada/auth/context/TenantContext.java @@ -0,0 +1,30 @@ +package com.kurihada.auth.context; + +/** + * 租户上下文 - 使用ThreadLocal存储当前租户ID + */ +public class TenantContext { + + private static final ThreadLocal CURRENT_TENANT = new ThreadLocal<>(); + + /** + * 设置当前租户ID + */ + public static void setTenantId(Long tenantId) { + CURRENT_TENANT.set(tenantId); + } + + /** + * 获取当前租户ID + */ + public static Long getTenantId() { + return CURRENT_TENANT.get(); + } + + /** + * 清除当前租户ID + */ + public static void clear() { + CURRENT_TENANT.remove(); + } +} diff --git a/src/main/java/com/kurihada/auth/controller/AuditLogController.java b/src/main/java/com/kurihada/auth/controller/AuditLogController.java new file mode 100644 index 0000000..110b51a --- /dev/null +++ b/src/main/java/com/kurihada/auth/controller/AuditLogController.java @@ -0,0 +1,58 @@ +package com.kurihada.auth.controller; + +import com.kurihada.auth.dto.AuditLogQueryRequest; +import com.kurihada.auth.dto.AuditLogResponse; +import com.kurihada.auth.dto.PageResult; +import com.kurihada.auth.dto.Result; +import com.kurihada.auth.service.AuditLogService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +/** + * 审计日志控制器 + */ +@Tag(name = "审计日志管理", description = "审计日志查询相关接口") +@RestController +@RequestMapping("/api/v1/audit-logs") +@RequiredArgsConstructor +@SecurityRequirement(name = "Bearer") +public class AuditLogController { + + private final AuditLogService auditLogService; + + /** + * 分页查询审计日志 + */ + @Operation( + summary = "分页查询审计日志", + description = "根据条件查询审计日志,支持按用户、操作类型、时间范围等筛选。需要 ADMIN 或 AUDIT_VIEWER 权限" + ) + @GetMapping + @PreAuthorize("hasAnyRole('ADMIN', 'AUDIT_VIEWER')") + public Result> queryAuditLogs( + @Valid @ModelAttribute AuditLogQueryRequest request) { + PageResult result = auditLogService.queryAuditLogs(request); + return Result.success(result); + } + + /** + * 根据ID获取审计日志详情 + */ + @Operation( + summary = "获取审计日志详情", + description = "根据日志ID查询审计日志详细信息。需要 ADMIN 或 AUDIT_VIEWER 权限" + ) + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'AUDIT_VIEWER')") + public Result getAuditLogById( + @Parameter(description = "审计日志ID") @PathVariable Long id) { + AuditLogResponse response = auditLogService.getAuditLogById(id); + return Result.success(response); + } +} diff --git a/src/main/java/com/kurihada/auth/controller/AuthController.java b/src/main/java/com/kurihada/auth/controller/AuthController.java new file mode 100644 index 0000000..f9560ae --- /dev/null +++ b/src/main/java/com/kurihada/auth/controller/AuthController.java @@ -0,0 +1,206 @@ +package com.kurihada.auth.controller; + +import com.kurihada.auth.annotation.RateLimit; +import com.kurihada.auth.dto.*; +import com.kurihada.auth.entity.User; +import com.kurihada.auth.mapper.UserMapper; +import com.kurihada.auth.service.AuthService; +import com.kurihada.auth.util.JwtUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * 认证控制器 + */ +@Tag(name = "认证管理", description = "用户登录、注册相关接口") +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + private final JwtUtil jwtUtil; + private final UserMapper userMapper; + + @Value("${jwt.header}") + private String tokenHeader; + + @Value("${jwt.prefix}") + private String tokenPrefix; + + /** + * 用户登录 + */ + @Operation(summary = "用户登录", description = "通过用户名和密码登录,返回JWT Token") + @PostMapping("/login") + @RateLimit( + key = "login", + window = 60, + maxRequests = 5, + limitType = RateLimit.LimitType.IP_AND_USER, + message = "登录请求过于频繁,请稍后再试" + ) + public Result login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) { + LoginResponse response = authService.login(request, httpRequest); + return Result.success("登录成功", response); + } + + /** + * 用户注册 + */ + @Operation(summary = "用户注册", description = "注册新用户账号") + @PostMapping("/register") + @RateLimit( + key = "register", + window = 300, + maxRequests = 3, + limitType = RateLimit.LimitType.IP, + message = "注册请求过于频繁,请稍后再试" + ) + public Result register(@Valid @RequestBody RegisterRequest request, HttpServletRequest httpRequest) { + authService.register(request, httpRequest); + return Result.success(); + } + + /** + * 刷新 Access Token + */ + @Operation(summary = "刷新Token", description = "使用 Refresh Token 获取新的 Access Token") + @PostMapping("/refresh") + @RateLimit( + key = "refresh", + window = 60, + maxRequests = 10, + limitType = RateLimit.LimitType.IP, + message = "刷新Token请求过于频繁,请稍后再试" + ) + public Result refreshToken(@Valid @RequestBody RefreshTokenRequest request) { + LoginResponse response = authService.refreshAccessToken(request.getRefreshToken()); + return Result.success("Token刷新成功", response); + } + + /** + * 用户退出登录 + */ + @Operation(summary = "退出登录", description = "退出登录,使当前 Token 失效") + @PostMapping("/logout") + public Result logout(HttpServletRequest request) { + // 从请求中获取 token + String token = getJwtFromRequest(request); + + Long userId = null; + Long tenantId = null; + + // 从 token 中解析用户信息 + if (token != null) { + try { + // 从 token 获取用户名和租户ID + String username = jwtUtil.getUsernameFromToken(token); + tenantId = jwtUtil.getTenantIdFromToken(token); + + // 根据用户名查询用户获取用户ID + if (username != null) { + User user = userMapper.selectUserWithRolesAndPermissions(username); + if (user != null) { + userId = user.getId(); + // 如果token中没有tenantId,使用用户的tenantId + if (tenantId == null) { + tenantId = user.getTenantId(); + } + } + } + } catch (Exception e) { + // Token 解析失败,仍然尝试将其加入黑名单 + } + } + + authService.logout(token, userId, tenantId); + return Result.success(); + } + + /** + * 忘记密码 - 发送重置邮件 + */ + @Operation(summary = "忘记密码", description = "发送密码重置邮件到用户邮箱") + @PostMapping("/forgot-password") + @RateLimit( + key = "forgot-password", + window = 300, + maxRequests = 3, + limitType = RateLimit.LimitType.IP, + message = "请求过于频繁,请稍后再试" + ) + public Result forgotPassword(@Valid @RequestBody ForgotPasswordRequest request) { + authService.forgotPassword(request); + return Result.success("如果该邮箱已注册,我们已向其发送密码重置邮件"); + } + + /** + * 重置密码 + */ + @Operation(summary = "重置密码", description = "通过重置令牌设置新密码") + @PostMapping("/reset-password") + @RateLimit( + key = "reset-password", + window = 60, + maxRequests = 5, + limitType = RateLimit.LimitType.IP, + message = "请求过于频繁,请稍后再试" + ) + public Result resetPassword(@Valid @RequestBody ResetPasswordRequest request) { + authService.resetPassword(request); + return Result.success("密码重置成功,请使用新密码登录"); + } + + /** + * 验证邮箱 + */ + @Operation(summary = "验证邮箱", description = "通过验证令牌验证用户邮箱") + @PostMapping("/verify-email") + @RateLimit( + key = "verify-email", + window = 60, + maxRequests = 10, + message = "请求过于频繁,请稍后再试" + ) + public Result verifyEmail(@Valid @RequestBody com.kurihada.auth.dto.VerifyEmailRequest request) { + authService.verifyEmail(request.getToken(), request.getTenantId()); + return Result.success("邮箱验证成功!现在可以登录了"); + } + + /** + * 重新发送验证邮件 + */ + @Operation(summary = "重新发送验证邮件", description = "为指定邮箱重新发送验证邮件") + @PostMapping("/resend-verification") + @RateLimit( + key = "resend-verification", + window = 300, + maxRequests = 3, + message = "请求过于频繁,请稍后再试" + ) + public Result resendVerification(@Valid @RequestBody com.kurihada.auth.dto.ResendVerificationRequest request) { + authService.resendVerificationEmail(request.getEmail(), request.getTenantId()); + return Result.success("验证邮件已发送,请检查您的邮箱"); + } + + /** + * 从请求中获取JWT token + */ + private String getJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader(tokenHeader); + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(tokenPrefix + " ")) { + return bearerToken.substring(7); + } + + return null; + } +} diff --git a/src/main/java/com/kurihada/auth/controller/PermissionController.java b/src/main/java/com/kurihada/auth/controller/PermissionController.java new file mode 100644 index 0000000..25f71da --- /dev/null +++ b/src/main/java/com/kurihada/auth/controller/PermissionController.java @@ -0,0 +1,110 @@ +package com.kurihada.auth.controller; + +import com.kurihada.auth.dto.*; +import com.kurihada.auth.service.PermissionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 权限控制器 + */ +@Tag(name = "权限管理", description = "权限信息管理相关接口") +@RestController +@RequestMapping("/api/v1/permissions") +@RequiredArgsConstructor +@SecurityRequirement(name = "Bearer") +public class PermissionController { + + private final PermissionService permissionService; + + /** + * 分页查询权限 + */ + @Operation(summary = "分页查询权限", description = "分页查询权限列表,支持搜索和排序") + @GetMapping + @PreAuthorize("hasAnyRole('ADMIN', 'USER')") + public Result> getPermissionsPage(@Valid @ModelAttribute PageRequest pageRequest) { + PageResult result = permissionService.getPermissionsPage(pageRequest); + return Result.success(result); + } + + /** + * 根据ID获取权限 + */ + @Operation(summary = "根据ID获取权限", description = "根据权限ID查询权限详细信息") + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'USER')") + public Result getPermissionById( + @Parameter(description = "权限ID") @PathVariable Long id) { + PermissionDTO permission = permissionService.getPermissionById(id); + return Result.success(permission); + } + + /** + * 创建新权限 + */ + @Operation(summary = "创建权限", description = "创建新的权限(仅管理员)") + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + public Result createPermission( + @Valid @RequestBody CreatePermissionRequest request) { + PermissionDTO permission = permissionService.createPermission(request); + return Result.success(permission); + } + + /** + * 更新权限 + */ + @Operation(summary = "更新权限", description = "更新指定权限信息(仅管理员)") + @PutMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + public Result updatePermission( + @Parameter(description = "权限ID") @PathVariable Long id, + @Valid @RequestBody UpdatePermissionRequest request) { + PermissionDTO permission = permissionService.updatePermission(id, request); + return Result.success(permission); + } + + /** + * 删除权限 + */ + @Operation(summary = "删除权限", description = "删除指定权限(仅管理员)") + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + public Result deletePermission( + @Parameter(description = "权限ID") @PathVariable Long id) { + permissionService.deletePermission(id); + return Result.success(); + } + + /** + * 切换权限状态 + */ + @Operation(summary = "切换权限状态", description = "启用或禁用指定权限(仅管理员)") + @PutMapping("/{id}/toggle-status") + @PreAuthorize("hasRole('ADMIN')") + public Result togglePermissionStatus( + @Parameter(description = "权限ID") @PathVariable Long id) { + permissionService.togglePermissionStatus(id); + return Result.success(); + } + + /** + * 获取权限树形结构 + */ + @Operation(summary = "获取权限树", description = "获取权限的树形结构(用于菜单展示)") + @GetMapping("/tree") + @PreAuthorize("hasAnyRole('ADMIN', 'USER')") + public Result> getPermissionTree() { + List tree = permissionService.getPermissionTree(); + return Result.success(tree); + } +} diff --git a/src/main/java/com/kurihada/auth/controller/RoleController.java b/src/main/java/com/kurihada/auth/controller/RoleController.java new file mode 100644 index 0000000..e6f584f --- /dev/null +++ b/src/main/java/com/kurihada/auth/controller/RoleController.java @@ -0,0 +1,167 @@ +package com.kurihada.auth.controller; + +import com.kurihada.auth.dto.*; +import com.kurihada.auth.service.RolePermissionService; +import com.kurihada.auth.service.RoleService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 角色控制器 + */ +@Tag(name = "角色管理", description = "角色信息管理相关接口") +@RestController +@RequestMapping("/api/v1/roles") +@RequiredArgsConstructor +@SecurityRequirement(name = "Bearer") +public class RoleController { + + private final RoleService roleService; + private final RolePermissionService rolePermissionService; + + /** + * 分页查询角色 + */ + @Operation(summary = "分页查询角色", description = "分页查询角色列表,支持搜索和排序") + @GetMapping + @PreAuthorize("hasAnyRole('ADMIN', 'USER')") + public Result> getRolesPage(@Valid @ModelAttribute PageRequest pageRequest) { + PageResult result = roleService.getRolesPage(pageRequest); + return Result.success(result); + } + + /** + * 根据ID获取角色 + */ + @Operation(summary = "根据ID获取角色", description = "根据角色ID查询角色详细信息") + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'USER')") + public Result getRoleById( + @Parameter(description = "角色ID") @PathVariable Long id) { + RoleDTO role = roleService.getRoleById(id); + return Result.success(role); + } + + /** + * 创建新角色 + */ + @Operation(summary = "创建角色", description = "创建新的角色(仅管理员)") + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + public Result createRole( + @Valid @RequestBody CreateRoleRequest request) { + RoleDTO role = roleService.createRole(request); + return Result.success(role); + } + + /** + * 更新角色 + */ + @Operation(summary = "更新角色", description = "更新指定角色信息(仅管理员)") + @PutMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + public Result updateRole( + @Parameter(description = "角色ID") @PathVariable Long id, + @Valid @RequestBody UpdateRoleRequest request) { + RoleDTO role = roleService.updateRole(id, request); + return Result.success(role); + } + + /** + * 删除角色 + */ + @Operation(summary = "删除角色", description = "删除指定角色(仅管理员)") + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + public Result deleteRole( + @Parameter(description = "角色ID") @PathVariable Long id) { + roleService.deleteRole(id); + return Result.success(); + } + + /** + * 切换角色状态 + */ + @Operation(summary = "切换角色状态", description = "启用或禁用指定角色(仅管理员)") + @PutMapping("/{id}/toggle-status") + @PreAuthorize("hasRole('ADMIN')") + public Result toggleRoleStatus( + @Parameter(description = "角色ID") @PathVariable Long id) { + roleService.toggleRoleStatus(id); + return Result.success(); + } + + /** + * 获取角色的所有权限 + */ + @Operation(summary = "获取角色权限", description = "获取指定角色的所有权限列表") + @GetMapping("/{id}/permissions") + @PreAuthorize("hasAnyRole('ADMIN', 'USER')") + public Result> getRolePermissions( + @Parameter(description = "角色ID") @PathVariable Long id) { + List permissions = rolePermissionService.getPermissionsByRoleId(id); + List permissionDTOs = permissions.stream() + .map(permission -> PermissionDTO.builder() + .id(permission.getId()) + .name(permission.getName()) + .code(permission.getCode()) + .tenantId(permission.getTenantId()) + .type(permission.getType()) + .resource(permission.getResource()) + .parentId(permission.getParentId()) + .sort(permission.getSort()) + .status(permission.getStatus()) + .createTime(permission.getCreateTime()) + .updateTime(permission.getUpdateTime()) + .build()) + .toList(); + return Result.success(permissionDTOs); + } + + /** + * 为角色分配权限(批量) + */ + @Operation(summary = "分配权限", description = "为指定角色分配权限(批量,仅管理员)") + @PostMapping("/{id}/permissions") + @PreAuthorize("hasRole('ADMIN')") + public Result assignPermissionsToRole( + @Parameter(description = "角色ID") @PathVariable Long id, + @Valid @RequestBody AssignPermissionsRequest request) { + rolePermissionService.assignPermissionsToRole(id, request.getPermissionIds()); + return Result.success(); + } + + /** + * 移除角色的指定权限 + */ + @Operation(summary = "移除权限", description = "移除角色的指定权限(仅管理员)") + @DeleteMapping("/{roleId}/permissions/{permissionId}") + @PreAuthorize("hasRole('ADMIN')") + public Result removePermissionFromRole( + @Parameter(description = "角色ID") @PathVariable Long roleId, + @Parameter(description = "权限ID") @PathVariable Long permissionId) { + rolePermissionService.removePermissionFromRole(roleId, permissionId); + return Result.success(); + } + + /** + * 更新角色的权限(替换) + */ + @Operation(summary = "更新角色权限", description = "替换角色的所有权限(仅管理员)") + @PutMapping("/{id}/permissions") + @PreAuthorize("hasRole('ADMIN')") + public Result updateRolePermissions( + @Parameter(description = "角色ID") @PathVariable Long id, + @Valid @RequestBody AssignPermissionsRequest request) { + rolePermissionService.updateRolePermissions(id, request.getPermissionIds()); + return Result.success(); + } +} diff --git a/src/main/java/com/kurihada/auth/controller/TestController.java b/src/main/java/com/kurihada/auth/controller/TestController.java new file mode 100644 index 0000000..768e88c --- /dev/null +++ b/src/main/java/com/kurihada/auth/controller/TestController.java @@ -0,0 +1,30 @@ +package com.kurihada.auth.controller; + +import com.kurihada.auth.dto.Result; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 测试控制器 + */ +@RestController +@RequestMapping("/api/v1/test") +public class TestController { + + /** + * 健康检查 + */ + @GetMapping("/health") + public Result health() { + return Result.success("服务运行正常"); + } + + /** + * 需要认证的接口 + */ + @GetMapping("/protected") + public Result protectedEndpoint() { + return Result.success("您已通过认证"); + } +} diff --git a/src/main/java/com/kurihada/auth/controller/UserController.java b/src/main/java/com/kurihada/auth/controller/UserController.java new file mode 100644 index 0000000..b045d29 --- /dev/null +++ b/src/main/java/com/kurihada/auth/controller/UserController.java @@ -0,0 +1,202 @@ +package com.kurihada.auth.controller; + +import com.kurihada.auth.dto.*; +import com.kurihada.auth.service.UserRoleService; +import com.kurihada.auth.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 用户控制器 + */ +@Tag(name = "用户管理", description = "用户信息管理相关接口") +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +@SecurityRequirement(name = "Bearer") +public class UserController { + + private final UserService userService; + private final UserRoleService userRoleService; + + /** + * 获取当前登录用户信息 + */ + @Operation(summary = "获取当前用户信息", description = "获取当前登录用户的详细信息") + @GetMapping("/current") + public Result getCurrentUser() { + UserDTO user = userService.getCurrentUser(); + return Result.success(user); + } + + /** + * 更新当前用户信息 + */ + @Operation(summary = "更新当前用户信息", description = "当前登录用户更新自己的基本信息") + @PutMapping("/current") + public Result updateCurrentUser(@Valid @RequestBody UpdateUserRequest request) { + UserDTO user = userService.updateCurrentUser(request); + return Result.success(user); + } + + /** + * 根据ID获取用户信息 + */ + @Operation(summary = "根据ID获取用户", description = "根据用户ID查询用户详细信息") + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'USER')") + public Result getUserById( + @Parameter(description = "用户ID") @PathVariable Long id) { + UserDTO user = userService.getUserById(id); + return Result.success(user); + } + + /** + * 分页查询用户列表 + */ + @Operation(summary = "分页查询用户", description = "分页查询用户列表,支持搜索和排序(仅管理员)") + @GetMapping + @PreAuthorize("hasRole('ADMIN')") + public Result> getUsersPage(@Valid @ModelAttribute PageRequest pageRequest) { + PageResult result = userService.getUsersPage(pageRequest); + return Result.success(result); + } + + /** + * 删除用户 + */ + @Operation(summary = "删除用户", description = "逻辑删除指定用户(仅管理员)") + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + public Result deleteUser( + @Parameter(description = "用户ID") @PathVariable Long id) { + userService.deleteUser(id); + return Result.success(); + } + + /** + * 更新用户信息 + */ + @Operation(summary = "更新用户信息", description = "更新指定用户的基本信息(仅管理员)") + @PutMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + public Result updateUser( + @Parameter(description = "用户ID") @PathVariable Long id, + @Valid @RequestBody UpdateUserRequest request) { + UserDTO user = userService.updateUser(id, request); + return Result.success(user); + } + + /** + * 启用/禁用用户 + */ + @Operation(summary = "切换用户状态", description = "启用或禁用指定用户(仅管理员)") + @PutMapping("/{id}/toggle-status") + @PreAuthorize("hasRole('ADMIN')") + public Result toggleUserStatus( + @Parameter(description = "用户ID") @PathVariable Long id) { + userService.toggleUserStatus(id); + return Result.success(); + } + + /** + * 获取用户的所有角色 + */ + @Operation(summary = "获取用户角色", description = "获取指定用户的所有角色列表") + @GetMapping("/{id}/roles") + @PreAuthorize("hasAnyRole('ADMIN', 'USER')") + public Result> getUserRoles( + @Parameter(description = "用户ID") @PathVariable Long id) { + List roles = userRoleService.getRolesByUserId(id); + List roleDTOs = roles.stream() + .map(role -> RoleDTO.builder() + .id(role.getId()) + .name(role.getName()) + .code(role.getCode()) + .tenantId(role.getTenantId()) + .description(role.getDescription()) + .status(role.getStatus()) + .createTime(role.getCreateTime()) + .updateTime(role.getUpdateTime()) + .build()) + .toList(); + return Result.success(roleDTOs); + } + + /** + * 为用户分配角色(批量) + */ + @Operation(summary = "分配角色", description = "为指定用户分配角色(批量,仅管理员)") + @PostMapping("/{id}/roles") + @PreAuthorize("hasRole('ADMIN')") + public Result assignRolesToUser( + @Parameter(description = "用户ID") @PathVariable Long id, + @Valid @RequestBody AssignRolesRequest request) { + userRoleService.assignRolesToUser(id, request.getRoleIds()); + return Result.success(); + } + + /** + * 移除用户的指定角色 + */ + @Operation(summary = "移除角色", description = "移除用户的指定角色(仅管理员)") + @DeleteMapping("/{userId}/roles/{roleId}") + @PreAuthorize("hasRole('ADMIN')") + public Result removeRoleFromUser( + @Parameter(description = "用户ID") @PathVariable Long userId, + @Parameter(description = "角色ID") @PathVariable Long roleId) { + userRoleService.removeRoleFromUser(userId, roleId); + return Result.success(); + } + + /** + * 更新用户的角色(替换) + */ + @Operation(summary = "更新用户角色", description = "替换用户的所有角色(仅管理员)") + @PutMapping("/{id}/roles") + @PreAuthorize("hasRole('ADMIN')") + public Result updateUserRoles( + @Parameter(description = "用户ID") @PathVariable Long id, + @Valid @RequestBody AssignRolesRequest request) { + userRoleService.updateUserRoles(id, request.getRoleIds()); + return Result.success(); + } + + /** + * 修改当前用户密码 + */ + @Operation(summary = "修改密码", description = "当前用户修改自己的密码") + @PutMapping("/password") + public Result changePassword(@Valid @RequestBody ChangePasswordRequest request) { + userService.changePassword(request); + return Result.success(); + } + + /** + * 获取当前用户的权限列表 + */ + @Operation(summary = "获取当前用户权限", description = "获取当前登录用户的所有权限(包括通过角色继承的)") + @GetMapping("/permissions") + public Result> getCurrentUserPermissions() { + List permissions = userService.getCurrentUserPermissions(); + return Result.success(permissions); + } + + /** + * 获取当前用户的菜单树 + */ + @Operation(summary = "获取当前用户菜单", description = "获取当前登录用户可访问的菜单树形结构") + @GetMapping("/menus") + public Result> getCurrentUserMenus() { + List menus = userService.getCurrentUserMenus(); + return Result.success(menus); + } +} diff --git a/src/main/java/com/kurihada/auth/dto/AssignPermissionsRequest.java b/src/main/java/com/kurihada/auth/dto/AssignPermissionsRequest.java new file mode 100644 index 0000000..18c978d --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/AssignPermissionsRequest.java @@ -0,0 +1,23 @@ +package com.kurihada.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 分配权限请求DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "分配权限请求") +public class AssignPermissionsRequest { + + @Schema(description = "权限ID列表", example = "[1, 2, 3]") + @NotEmpty(message = "权限ID列表不能为空") + private List permissionIds; +} diff --git a/src/main/java/com/kurihada/auth/dto/AssignRolesRequest.java b/src/main/java/com/kurihada/auth/dto/AssignRolesRequest.java new file mode 100644 index 0000000..356b3fe --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/AssignRolesRequest.java @@ -0,0 +1,23 @@ +package com.kurihada.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 分配角色请求DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "分配角色请求") +public class AssignRolesRequest { + + @Schema(description = "角色ID列表", example = "[1, 2, 3]") + @NotEmpty(message = "角色ID列表不能为空") + private List roleIds; +} diff --git a/src/main/java/com/kurihada/auth/dto/AuditLogQueryRequest.java b/src/main/java/com/kurihada/auth/dto/AuditLogQueryRequest.java new file mode 100644 index 0000000..908a80c --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/AuditLogQueryRequest.java @@ -0,0 +1,67 @@ +package com.kurihada.auth.dto; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +/** + * 审计日志查询请求 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class AuditLogQueryRequest extends PageRequest { + + /** + * 用户ID + */ + private Long userId; + + /** + * 用户名(模糊查询) + */ + private String username; + + /** + * 操作类型 + */ + private String actionType; + + /** + * 资源类型 + */ + private String resourceType; + + /** + * 资源ID + */ + private String resourceId; + + /** + * 操作结果(SUCCESS, FAILURE) + */ + private String result; + + /** + * 租户ID + */ + private Long tenantId; + + /** + * 开始时间 + */ + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime startDate; + + /** + * 结束时间 + */ + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime endDate; + + /** + * IP地址(精确匹配) + */ + private String ipAddress; +} diff --git a/src/main/java/com/kurihada/auth/dto/AuditLogResponse.java b/src/main/java/com/kurihada/auth/dto/AuditLogResponse.java new file mode 100644 index 0000000..18ebcee --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/AuditLogResponse.java @@ -0,0 +1,85 @@ +package com.kurihada.auth.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 审计日志响应DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AuditLogResponse { + + /** + * 日志ID + */ + private Long id; + + /** + * 操作类型 + */ + private String actionType; + + /** + * 操作用户ID + */ + private Long userId; + + /** + * 操作用户名 + */ + private String username; + + /** + * 租户ID + */ + private Long tenantId; + + /** + * 目标资源类型 + */ + private String resourceType; + + /** + * 目标资源ID + */ + private String resourceId; + + /** + * 操作详情 + */ + private String details; + + /** + * 操作结果(SUCCESS, FAILURE) + */ + private String result; + + /** + * 错误信息(如果失败) + */ + private String errorMessage; + + /** + * 请求IP + */ + private String ipAddress; + + /** + * User Agent + */ + private String userAgent; + + /** + * 创建时间 + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; +} diff --git a/src/main/java/com/kurihada/auth/dto/ChangePasswordRequest.java b/src/main/java/com/kurihada/auth/dto/ChangePasswordRequest.java new file mode 100644 index 0000000..b9526fc --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/ChangePasswordRequest.java @@ -0,0 +1,27 @@ +package com.kurihada.auth.dto; + +import com.kurihada.auth.annotation.ValidPassword; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 修改密码请求DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "修改密码请求") +public class ChangePasswordRequest { + + @Schema(description = "旧密码", example = "OldPass123!") + @NotBlank(message = "旧密码不能为空") + private String oldPassword; + + @Schema(description = "新密码", example = "NewPass123!") + @NotBlank(message = "新密码不能为空") + @ValidPassword + private String newPassword; +} diff --git a/src/main/java/com/kurihada/auth/dto/CreatePermissionRequest.java b/src/main/java/com/kurihada/auth/dto/CreatePermissionRequest.java new file mode 100644 index 0000000..f6a4747 --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/CreatePermissionRequest.java @@ -0,0 +1,33 @@ +package com.kurihada.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 创建权限请求 + */ +@Data +public class CreatePermissionRequest { + + @NotBlank(message = "权限名称不能为空") + @Size(max = 50, message = "权限名称长度不能超过50个字符") + private String name; + + @NotBlank(message = "权限代码不能为空") + @Size(max = 100, message = "权限代码长度不能超过100个字符") + private String code; + + @Size(max = 255, message = "权限描述长度不能超过255个字符") + private String description; + + @Size(max = 20, message = "权限类型长度不能超过20个字符") + private String type; + + @Size(max = 255, message = "资源路径长度不能超过255个字符") + private String resource; + + private Long parentId; + + private Integer sort = 0; +} diff --git a/src/main/java/com/kurihada/auth/dto/CreateRoleRequest.java b/src/main/java/com/kurihada/auth/dto/CreateRoleRequest.java new file mode 100644 index 0000000..1ebf3cc --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/CreateRoleRequest.java @@ -0,0 +1,23 @@ +package com.kurihada.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 创建角色请求 + */ +@Data +public class CreateRoleRequest { + + @NotBlank(message = "角色名称不能为空") + @Size(max = 50, message = "角色名称长度不能超过50个字符") + private String name; + + @NotBlank(message = "角色代码不能为空") + @Size(max = 50, message = "角色代码长度不能超过50个字符") + private String code; + + @Size(max = 255, message = "角色描述长度不能超过255个字符") + private String description; +} diff --git a/src/main/java/com/kurihada/auth/dto/ForgotPasswordRequest.java b/src/main/java/com/kurihada/auth/dto/ForgotPasswordRequest.java new file mode 100644 index 0000000..1704880 --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/ForgotPasswordRequest.java @@ -0,0 +1,20 @@ +package com.kurihada.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 忘记密码请求DTO + */ +@Data +public class ForgotPasswordRequest { + + @NotBlank(message = "邮箱不能为空") + @Email(message = "邮箱格式不正确") + private String email; + + @NotNull(message = "租户ID不能为空") + private Long tenantId; +} diff --git a/src/main/java/com/kurihada/auth/dto/LoginRequest.java b/src/main/java/com/kurihada/auth/dto/LoginRequest.java new file mode 100644 index 0000000..0415190 --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/LoginRequest.java @@ -0,0 +1,31 @@ +package com.kurihada.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 登录请求DTO + */ +@Data +public class LoginRequest { + + @NotBlank(message = "用户名不能为空") + private String username; + + @NotBlank(message = "密码不能为空") + private String password; + + /** + * 租户代码(可选) + * 如果不提供,系统会按以下优先级查找: + * 1. 请求头 X-Tenant-Code + * 2. 子域名(如 tenant1.example.com) + * 3. 使用默认租户 + */ + private String tenantCode; + + /** + * 租户ID(可选,一般使用 tenantCode) + */ + private Long tenantId; +} diff --git a/src/main/java/com/kurihada/auth/dto/LoginResponse.java b/src/main/java/com/kurihada/auth/dto/LoginResponse.java new file mode 100644 index 0000000..2224aa6 --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/LoginResponse.java @@ -0,0 +1,34 @@ +package com.kurihada.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Set; + +/** + * 登录响应DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LoginResponse { + + private String token; + + private String refreshToken; + + private String tokenType = "Bearer"; + + private Long userId; + + private String username; + + private String realName; + + private Set roles; + + private Set permissions; +} diff --git a/src/main/java/com/kurihada/auth/dto/PageRequest.java b/src/main/java/com/kurihada/auth/dto/PageRequest.java new file mode 100644 index 0000000..f616e47 --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/PageRequest.java @@ -0,0 +1,54 @@ +package com.kurihada.auth.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.Data; + +/** + * 分页请求参数 + */ +@Data +public class PageRequest { + + /** + * 当前页码(从1开始) + */ + @Min(value = 1, message = "页码必须大于0") + private Integer page = 1; + + /** + * 每页大小 + */ + @Min(value = 1, message = "每页大小必须大于0") + @Max(value = 100, message = "每页大小不能超过100") + private Integer size = 10; + + /** + * 排序字段 + */ + private String sortBy; + + /** + * 排序方向(asc/desc) + */ + private String sortDirection = "desc"; + + /** + * 搜索关键词 + */ + private String keyword; + + /** + * 获取 MyBatis Plus 的偏移量 + */ + public long getOffset() { + return (long) (page - 1) * size; + } + + /** + * 获取限制数量 + */ + public long getLimit() { + return size; + } +} diff --git a/src/main/java/com/kurihada/auth/dto/PageResult.java b/src/main/java/com/kurihada/auth/dto/PageResult.java new file mode 100644 index 0000000..edf2fd5 --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/PageResult.java @@ -0,0 +1,69 @@ +package com.kurihada.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 分页响应结果 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PageResult { + + /** + * 数据列表 + */ + private List records; + + /** + * 总记录数 + */ + private Long total; + + /** + * 当前页码 + */ + private Integer page; + + /** + * 每页大小 + */ + private Integer size; + + /** + * 总页数 + */ + private Integer totalPages; + + /** + * 是否有下一页 + */ + private Boolean hasNext; + + /** + * 是否有上一页 + */ + private Boolean hasPrevious; + + /** + * 创建分页结果 + */ + public static PageResult of(List records, Long total, Integer page, Integer size) { + int totalPages = (int) Math.ceil((double) total / size); + return PageResult.builder() + .records(records) + .total(total) + .page(page) + .size(size) + .totalPages(totalPages) + .hasNext(page < totalPages) + .hasPrevious(page > 1) + .build(); + } +} diff --git a/src/main/java/com/kurihada/auth/dto/PermissionDTO.java b/src/main/java/com/kurihada/auth/dto/PermissionDTO.java new file mode 100644 index 0000000..319094a --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/PermissionDTO.java @@ -0,0 +1,42 @@ +package com.kurihada.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 权限信息DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PermissionDTO { + + private Long id; + + private String name; + + private String code; + + private Long tenantId; + + private String description; + + private String type; + + private String resource; + + private Long parentId; + + private Integer sort; + + private Integer status; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/kurihada/auth/dto/PermissionTreeNode.java b/src/main/java/com/kurihada/auth/dto/PermissionTreeNode.java new file mode 100644 index 0000000..ec5d089 --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/PermissionTreeNode.java @@ -0,0 +1,45 @@ +package com.kurihada.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 权限树节点DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PermissionTreeNode { + + private Long id; + + private String name; + + private String code; + + private Long tenantId; + + private String type; + + private String resource; + + private Long parentId; + + private Integer sort; + + private Integer status; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; + + @Builder.Default + private List children = new ArrayList<>(); +} diff --git a/src/main/java/com/kurihada/auth/dto/RefreshTokenRequest.java b/src/main/java/com/kurihada/auth/dto/RefreshTokenRequest.java new file mode 100644 index 0000000..e2d8c27 --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/RefreshTokenRequest.java @@ -0,0 +1,14 @@ +package com.kurihada.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 刷新Token请求 + */ +@Data +public class RefreshTokenRequest { + + @NotBlank(message = "Refresh Token不能为空") + private String refreshToken; +} diff --git a/src/main/java/com/kurihada/auth/dto/RegisterRequest.java b/src/main/java/com/kurihada/auth/dto/RegisterRequest.java new file mode 100644 index 0000000..da314a7 --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/RegisterRequest.java @@ -0,0 +1,56 @@ +package com.kurihada.auth.dto; + +import com.kurihada.auth.annotation.ValidPassword; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 注册请求DTO + */ +@Data +public class RegisterRequest { + + @NotBlank(message = "用户名不能为空") + @Size(min = 3, max = 50, message = "用户名长度必须在3-50个字符之间") + @Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "用户名只能包含字母、数字和下划线") + private String username; + + @NotBlank(message = "密码不能为空") + @ValidPassword( + minLength = 8, + maxLength = 32, + requireLowercase = true, + requireUppercase = true, + requireDigit = true, + requireSpecialChar = true, + forbidCommonPasswords = true + ) + private String password; + + @NotBlank(message = "确认密码不能为空") + private String confirmPassword; + + @Email(message = "邮箱格式不正确") + private String email; + + private String realName; + + private String phone; + + /** + * 租户代码(可选) + * 如果不提供,系统会按以下优先级查找: + * 1. 请求头 X-Tenant-Code + * 2. 子域名(如 tenant1.example.com) + * 3. 使用默认租户 + */ + private String tenantCode; + + /** + * 租户ID(可选,一般使用 tenantCode) + */ + private Long tenantId; +} diff --git a/src/main/java/com/kurihada/auth/dto/ResendVerificationRequest.java b/src/main/java/com/kurihada/auth/dto/ResendVerificationRequest.java new file mode 100644 index 0000000..5824c8f --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/ResendVerificationRequest.java @@ -0,0 +1,20 @@ +package com.kurihada.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 重新发送验证邮件请求 + */ +@Data +public class ResendVerificationRequest { + + @NotBlank(message = "邮箱不能为空") + @Email(message = "邮箱格式不正确") + private String email; + + @NotNull(message = "租户ID不能为空") + private Long tenantId; +} diff --git a/src/main/java/com/kurihada/auth/dto/ResetPasswordRequest.java b/src/main/java/com/kurihada/auth/dto/ResetPasswordRequest.java new file mode 100644 index 0000000..a6e96a4 --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/ResetPasswordRequest.java @@ -0,0 +1,31 @@ +package com.kurihada.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 重置密码请求DTO + */ +@Data +public class ResetPasswordRequest { + + @NotBlank(message = "重置令牌不能为空") + private String token; + + @NotNull(message = "租户ID不能为空") + private Long tenantId; + + @NotBlank(message = "新密码不能为空") + @Size(min = 8, max = 32, message = "密码长度必须在8-32位之间") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=\\[\\]{}|;:,.<>?]).+$", + message = "密码必须包含大小写字母、数字和特殊字符" + ) + private String newPassword; + + @NotBlank(message = "确认密码不能为空") + private String confirmPassword; +} diff --git a/src/main/java/com/kurihada/auth/dto/Result.java b/src/main/java/com/kurihada/auth/dto/Result.java new file mode 100644 index 0000000..c6bbf52 --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/Result.java @@ -0,0 +1,64 @@ +package com.kurihada.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 统一响应结果 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Result { + + /** + * 响应码 + */ + private Integer code; + + /** + * 响应消息 + */ + private String message; + + /** + * 响应数据 + */ + private T data; + + /** + * 成功响应 + */ + public static Result success(T data) { + return new Result<>(200, "操作成功", data); + } + + /** + * 成功响应(无数据) + */ + public static Result success() { + return new Result<>(200, "操作成功", null); + } + + /** + * 成功响应(自定义消息) + */ + public static Result success(String message, T data) { + return new Result<>(200, message, data); + } + + /** + * 失败响应 + */ + public static Result error(String message) { + return new Result<>(500, message, null); + } + + /** + * 失败响应(自定义响应码) + */ + public static Result error(Integer code, String message) { + return new Result<>(code, message, null); + } +} diff --git a/src/main/java/com/kurihada/auth/dto/RoleDTO.java b/src/main/java/com/kurihada/auth/dto/RoleDTO.java new file mode 100644 index 0000000..2e5d19b --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/RoleDTO.java @@ -0,0 +1,37 @@ +package com.kurihada.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Set; + +/** + * 角色信息DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RoleDTO { + + private Long id; + + private String name; + + private String code; + + private Long tenantId; + + private String description; + + private Integer status; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; + + private Set permissions; +} diff --git a/src/main/java/com/kurihada/auth/dto/UpdatePermissionRequest.java b/src/main/java/com/kurihada/auth/dto/UpdatePermissionRequest.java new file mode 100644 index 0000000..5260d32 --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/UpdatePermissionRequest.java @@ -0,0 +1,29 @@ +package com.kurihada.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 更新权限请求 + */ +@Data +public class UpdatePermissionRequest { + + @NotBlank(message = "权限名称不能为空") + @Size(max = 50, message = "权限名称长度不能超过50个字符") + private String name; + + @Size(max = 255, message = "权限描述长度不能超过255个字符") + private String description; + + @Size(max = 20, message = "权限类型长度不能超过20个字符") + private String type; + + @Size(max = 255, message = "资源路径长度不能超过255个字符") + private String resource; + + private Long parentId; + + private Integer sort; +} diff --git a/src/main/java/com/kurihada/auth/dto/UpdateRoleRequest.java b/src/main/java/com/kurihada/auth/dto/UpdateRoleRequest.java new file mode 100644 index 0000000..551cd79 --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/UpdateRoleRequest.java @@ -0,0 +1,19 @@ +package com.kurihada.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 更新角色请求 + */ +@Data +public class UpdateRoleRequest { + + @NotBlank(message = "角色名称不能为空") + @Size(max = 50, message = "角色名称长度不能超过50个字符") + private String name; + + @Size(max = 255, message = "角色描述长度不能超过255个字符") + private String description; +} diff --git a/src/main/java/com/kurihada/auth/dto/UpdateUserRequest.java b/src/main/java/com/kurihada/auth/dto/UpdateUserRequest.java new file mode 100644 index 0000000..c9099ff --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/UpdateUserRequest.java @@ -0,0 +1,37 @@ +package com.kurihada.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 更新用户信息请求DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "更新用户信息请求") +public class UpdateUserRequest { + + @Schema(description = "真实姓名", example = "张三") + @Size(max = 100, message = "真实姓名长度不能超过100个字符") + private String realName; + + @Schema(description = "邮箱", example = "user@example.com") + @Email(message = "邮箱格式不正确") + private String email; + + @Schema(description = "手机号", example = "13800138000") + @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") + private String phone; + + @Schema(description = "性别:0-未知,1-男,2-女", example = "1") + private Integer gender; + + @Schema(description = "头像URL", example = "https://example.com/avatar.jpg") + private String avatar; +} diff --git a/src/main/java/com/kurihada/auth/dto/UserDTO.java b/src/main/java/com/kurihada/auth/dto/UserDTO.java new file mode 100644 index 0000000..8a004e4 --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/UserDTO.java @@ -0,0 +1,43 @@ +package com.kurihada.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Set; + +/** + * 用户信息DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserDTO { + + private Long id; + + private String username; + + private String realName; + + private String email; + + private String phone; + + private Integer gender; + + private String avatar; + + private Integer status; + + private LocalDateTime lastLoginTime; + + private LocalDateTime createTime; + + private Set roles; + + private Set permissions; +} diff --git a/src/main/java/com/kurihada/auth/dto/UserPermissionCache.java b/src/main/java/com/kurihada/auth/dto/UserPermissionCache.java new file mode 100644 index 0000000..9238b78 --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/UserPermissionCache.java @@ -0,0 +1,83 @@ +package com.kurihada.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * 用户权限缓存DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UserPermissionCache implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 用户ID + */ + private Long userId; + + /** + * 租户ID + */ + private Long tenantId; + + /** + * 用户名 + */ + private String username; + + /** + * 角色代码列表(初始化为空列表,避免 null) + */ + private List roleCodes = new ArrayList<>(); + + /** + * 角色名称列表(初始化为空列表,避免 null) + */ + private List roleNames = new ArrayList<>(); + + /** + * 权限代码集合(初始化为空集合,避免 null) + */ + private Set permissionCodes = new HashSet<>(); + + /** + * 权限资源集合(API路径,初始化为空集合,避免 null) + */ + private Set resources = new HashSet<>(); + + /** + * 缓存时间戳 + */ + private Long cacheTime; + + /** + * 检查是否拥有某个权限 + */ + public boolean hasPermission(String permissionCode) { + return permissionCodes != null && permissionCodes.contains(permissionCode); + } + + /** + * 检查是否拥有某个角色 + */ + public boolean hasRole(String roleCode) { + return roleCodes != null && roleCodes.contains(roleCode); + } + + /** + * 检查是否有权限访问某个资源 + */ + public boolean hasResource(String resource) { + return resources != null && resources.contains(resource); + } +} diff --git a/src/main/java/com/kurihada/auth/dto/VerifyEmailRequest.java b/src/main/java/com/kurihada/auth/dto/VerifyEmailRequest.java new file mode 100644 index 0000000..bdb7ab7 --- /dev/null +++ b/src/main/java/com/kurihada/auth/dto/VerifyEmailRequest.java @@ -0,0 +1,18 @@ +package com.kurihada.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 邮箱验证请求 + */ +@Data +public class VerifyEmailRequest { + + @NotBlank(message = "验证令牌不能为空") + private String token; + + @NotNull(message = "租户ID不能为空") + private Long tenantId; +} diff --git a/src/main/java/com/kurihada/auth/entity/AuditLog.java b/src/main/java/com/kurihada/auth/entity/AuditLog.java new file mode 100644 index 0000000..82602d7 --- /dev/null +++ b/src/main/java/com/kurihada/auth/entity/AuditLog.java @@ -0,0 +1,96 @@ +package com.kurihada.auth.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 审计日志实体 + * 用于记录系统中的重要操作 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@TableName("audit_log") +public class AuditLog { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 操作类型(LOGIN, LOGOUT, CREATE_USER, UPDATE_USER, DELETE_USER, ASSIGN_ROLE, etc.) + */ + @TableField("action_type") + private String actionType; + + /** + * 操作用户ID + */ + @TableField("user_id") + private Long userId; + + /** + * 操作用户名 + */ + @TableField("username") + private String username; + + /** + * 租户ID + */ + @TableField("tenant_id") + private Long tenantId; + + /** + * 目标资源类型(USER, ROLE, PERMISSION) + */ + @TableField("resource_type") + private String resourceType; + + /** + * 目标资源ID + */ + @TableField("resource_id") + private String resourceId; + + /** + * 操作详情 + */ + @TableField("details") + private String details; + + /** + * 操作结果(SUCCESS, FAILURE) + */ + @TableField("result") + private String result; + + /** + * 错误信息(如果失败) + */ + @TableField("error_message") + private String errorMessage; + + /** + * 请求IP + */ + @TableField("ip_address") + private String ipAddress; + + /** + * User Agent + */ + @TableField("user_agent") + private String userAgent; + + /** + * 创建时间 + */ + @TableField(value = "create_time", fill = FieldFill.INSERT) + private LocalDateTime createTime; +} diff --git a/src/main/java/com/kurihada/auth/entity/Permission.java b/src/main/java/com/kurihada/auth/entity/Permission.java new file mode 100644 index 0000000..0db90c3 --- /dev/null +++ b/src/main/java/com/kurihada/auth/entity/Permission.java @@ -0,0 +1,87 @@ +package com.kurihada.auth.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 权限实体类 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@TableName("permissions") +public class Permission { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 权限名称 + */ + @TableField("name") + private String name; + + /** + * 权限代码 + */ + @TableField("code") + private String code; + + /** + * 租户ID(null表示公共权限) + */ + @TableField("tenant_id") + private Long tenantId; + + /** + * 权限描述 + */ + @TableField("description") + private String description; + + /** + * 权限类型(如:menu, button, api等) + */ + @TableField("type") + private String type; + + /** + * 资源路径 + */ + @TableField("resource") + private String resource; + + /** + * 父权限ID + */ + @TableField("parent_id") + private Long parentId; + + /** + * 排序 + */ + @TableField("sort") + private Integer sort; + + /** + * 状态(0:禁用,1:启用) + */ + @TableField("status") + private Integer status = 1; + + /** + * 创建时间 + */ + @TableField(value = "create_time", fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/kurihada/auth/entity/RefreshToken.java b/src/main/java/com/kurihada/auth/entity/RefreshToken.java new file mode 100644 index 0000000..18ef6b7 --- /dev/null +++ b/src/main/java/com/kurihada/auth/entity/RefreshToken.java @@ -0,0 +1,70 @@ +package com.kurihada.auth.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Refresh Token 实体类 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@TableName("refresh_tokens") +public class RefreshToken { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 用户ID + */ + @TableField("user_id") + private Long userId; + + /** + * Refresh Token + */ + @TableField("token") + private String token; + + /** + * 租户ID + */ + @TableField("tenant_id") + private Long tenantId; + + /** + * 过期时间 + */ + @TableField("expire_time") + private LocalDateTime expireTime; + + /** + * 是否已使用(已使用的token不能再次刷新) + */ + @TableField("used") + private Boolean used = false; + + /** + * 是否删除(0:未删除,1:已删除) + */ + @TableLogic + @TableField("deleted") + private Integer deleted = 0; + + /** + * 创建时间 + */ + @TableField(value = "create_time", fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/kurihada/auth/entity/Role.java b/src/main/java/com/kurihada/auth/entity/Role.java new file mode 100644 index 0000000..6ff2475 --- /dev/null +++ b/src/main/java/com/kurihada/auth/entity/Role.java @@ -0,0 +1,70 @@ +package com.kurihada.auth.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 角色实体类 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@TableName("roles") +public class Role { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 角色名称 + */ + @TableField("name") + private String name; + + /** + * 角色代码 + */ + @TableField("code") + private String code; + + /** + * 租户ID + */ + @TableField("tenant_id") + private Long tenantId; + + /** + * 角色描述 + */ + @TableField("description") + private String description; + + /** + * 状态(0:禁用,1:启用) + */ + @TableField("status") + private Integer status = 1; + + /** + * 创建时间 + */ + @TableField(value = "create_time", fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 角色拥有的权限集合(非数据库字段) + */ + @TableField(exist = false) + private List permissions; +} diff --git a/src/main/java/com/kurihada/auth/entity/RolePermission.java b/src/main/java/com/kurihada/auth/entity/RolePermission.java new file mode 100644 index 0000000..0c0dce2 --- /dev/null +++ b/src/main/java/com/kurihada/auth/entity/RolePermission.java @@ -0,0 +1,60 @@ +package com.kurihada.auth.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 角色权限关联实体类 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@TableName("role_permissions") +public class RolePermission { + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 角色ID + */ + @TableField("role_id") + private Long roleId; + + /** + * 权限ID + */ + @TableField("permission_id") + private Long permissionId; + + /** + * 租户ID + */ + @TableField("tenant_id") + private Long tenantId; + + /** + * 创建时间 + */ + @TableField(value = "create_time", fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 角色信息(非数据库字段) + */ + @TableField(exist = false) + private Role role; + + /** + * 权限信息(非数据库字段) + */ + @TableField(exist = false) + private Permission permission; +} diff --git a/src/main/java/com/kurihada/auth/entity/Tenant.java b/src/main/java/com/kurihada/auth/entity/Tenant.java new file mode 100644 index 0000000..afdff97 --- /dev/null +++ b/src/main/java/com/kurihada/auth/entity/Tenant.java @@ -0,0 +1,82 @@ +package com.kurihada.auth.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 租户实体类 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@TableName("tenants") +public class Tenant { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 租户代码(唯一标识) + */ + @TableField("code") + private String code; + + /** + * 租户名称 + */ + @TableField("name") + private String name; + + /** + * 联系人 + */ + @TableField("contact_name") + private String contactName; + + /** + * 联系电话 + */ + @TableField("contact_phone") + private String contactPhone; + + /** + * 联系邮箱 + */ + @TableField("contact_email") + private String contactEmail; + + /** + * 状态(0:禁用,1:启用) + */ + @TableField("status") + private Integer status = 1; + + /** + * 是否删除(0:未删除,1:已删除) + */ + @TableLogic + @TableField("deleted") + private Integer deleted = 0; + + /** + * 过期时间 + */ + @TableField("expire_time") + private LocalDateTime expireTime; + + /** + * 创建时间 + */ + @TableField(value = "create_time", fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/kurihada/auth/entity/User.java b/src/main/java/com/kurihada/auth/entity/User.java new file mode 100644 index 0000000..30bef82 --- /dev/null +++ b/src/main/java/com/kurihada/auth/entity/User.java @@ -0,0 +1,137 @@ +package com.kurihada.auth.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 用户实体类 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@TableName("users") +public class User { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 用户名 + */ + @TableField("username") + private String username; + + /** + * 密码(加密后) + */ + @TableField("password") + private String password; + + /** + * 租户ID + */ + @TableField("tenant_id") + private Long tenantId; + + /** + * 真实姓名 + */ + @TableField("real_name") + private String realName; + + /** + * 邮箱 + */ + @TableField("email") + private String email; + + /** + * 手机号 + */ + @TableField("phone") + private String phone; + + /** + * 性别(0:女,1:男,2:未知) + */ + @TableField("gender") + private Integer gender; + + /** + * 头像 + */ + @TableField("avatar") + private String avatar; + + /** + * 状态(0:禁用,1:启用) + */ + @TableField("status") + private Integer status = 1; + + /** + * 是否删除(0:未删除,1:已删除) + */ + @TableLogic + @TableField("deleted") + private Integer deleted = 0; + + /** + * 邮箱是否已验证(0:未验证,1:已验证) + */ + @TableField("email_verified") + private Integer emailVerified = 0; + + /** + * 邮箱验证时间 + */ + @TableField("email_verified_at") + private LocalDateTime emailVerifiedAt; + + /** + * 登录失败次数 + */ + @TableField("login_failed_count") + private Integer loginFailedCount = 0; + + /** + * 账户锁定时间 + */ + @TableField("account_locked_at") + private LocalDateTime accountLockedAt; + + /** + * 账户解锁时间 + */ + @TableField("account_unlock_at") + private LocalDateTime accountUnlockAt; + + /** + * 最后登录时间 + */ + @TableField("last_login_time") + private LocalDateTime lastLoginTime; + + /** + * 创建时间 + */ + @TableField(value = "create_time", fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 用户拥有的角色集合(非数据库字段) + */ + @TableField(exist = false) + private List roles; +} diff --git a/src/main/java/com/kurihada/auth/entity/UserRole.java b/src/main/java/com/kurihada/auth/entity/UserRole.java new file mode 100644 index 0000000..657a757 --- /dev/null +++ b/src/main/java/com/kurihada/auth/entity/UserRole.java @@ -0,0 +1,60 @@ +package com.kurihada.auth.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 用户角色关联实体类 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@TableName("user_roles") +public class UserRole { + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 用户ID + */ + @TableField("user_id") + private Long userId; + + /** + * 角色ID + */ + @TableField("role_id") + private Long roleId; + + /** + * 租户ID + */ + @TableField("tenant_id") + private Long tenantId; + + /** + * 创建时间 + */ + @TableField(value = "create_time", fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 用户信息(非数据库字段) + */ + @TableField(exist = false) + private User user; + + /** + * 角色信息(非数据库字段) + */ + @TableField(exist = false) + private Role role; +} diff --git a/src/main/java/com/kurihada/auth/exception/BusinessException.java b/src/main/java/com/kurihada/auth/exception/BusinessException.java new file mode 100644 index 0000000..9555e4c --- /dev/null +++ b/src/main/java/com/kurihada/auth/exception/BusinessException.java @@ -0,0 +1,27 @@ +package com.kurihada.auth.exception; + +import lombok.Getter; + +/** + * 业务异常类 + */ +@Getter +public class BusinessException extends RuntimeException { + + private final Integer code; + + public BusinessException(String message) { + super(message); + this.code = 500; + } + + public BusinessException(Integer code, String message) { + super(message); + this.code = code; + } + + public BusinessException(String message, Throwable cause) { + super(message, cause); + this.code = 500; + } +} diff --git a/src/main/java/com/kurihada/auth/exception/GlobalExceptionHandler.java b/src/main/java/com/kurihada/auth/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..1d3f5d0 --- /dev/null +++ b/src/main/java/com/kurihada/auth/exception/GlobalExceptionHandler.java @@ -0,0 +1,186 @@ +package com.kurihada.auth.exception; + +import com.kurihada.auth.dto.Result; +import com.baomidou.mybatisplus.core.exceptions.MybatisPlusException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.NoHandlerFoundException; + +import java.util.stream.Collectors; + +/** + * 全局异常处理器 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * 处理业务异常 + */ + @ExceptionHandler(BusinessException.class) + public Result handleBusinessException(BusinessException e) { + log.error("业务异常: {}", e.getMessage()); + return Result.error(e.getCode(), e.getMessage()); + } + + /** + * 处理限流异常 + */ + @ExceptionHandler(RateLimitException.class) + @ResponseStatus(HttpStatus.TOO_MANY_REQUESTS) + public Result handleRateLimitException(RateLimitException e) { + log.warn("触发限流: {}, 需等待: {}秒", e.getMessage(), e.getRemainingTime()); + return Result.error(429, e.getMessage()); + } + + /** + * 处理参数校验异常 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result handleValidationException(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + log.error("参数校验异常: {}", message); + return Result.error(400, message); + } + + /** + * 处理绑定异常 + */ + @ExceptionHandler(BindException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result handleBindException(BindException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + log.error("参数绑定异常: {}", message); + return Result.error(400, message); + } + + /** + * 处理Spring Security认证异常 + */ + @ExceptionHandler(BadCredentialsException.class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public Result handleBadCredentialsException(BadCredentialsException e) { + log.error("认证失败: {}", e.getMessage()); + return Result.error(401, "用户名或密码错误"); + } + + /** + * 处理Spring Security权限异常 + */ + @ExceptionHandler(AccessDeniedException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public Result handleAccessDeniedException(AccessDeniedException e) { + log.error("权限不足: {}", e.getMessage()); + return Result.error(403, "权限不足,访问被拒绝"); + } + + /** + * 处理数据库唯一键冲突异常 + */ + @ExceptionHandler(DuplicateKeyException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result handleDuplicateKeyException(DuplicateKeyException e) { + log.error("数据库唯一键冲突: {}", e.getMessage()); + return Result.error(400, "数据已存在,请勿重复添加"); + } + + /** + * 处理数据访问异常 + */ + @ExceptionHandler(DataAccessException.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public Result handleDataAccessException(DataAccessException e) { + log.error("数据访问异常: ", e); + return Result.error("数据访问失败,请稍后重试"); + } + + /** + * 处理MyBatis Plus异常 + */ + @ExceptionHandler(MybatisPlusException.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public Result handleMybatisPlusException(MybatisPlusException e) { + log.error("MyBatis Plus异常: ", e); + return Result.error("数据操作失败"); + } + + /** + * 处理请求方法不支持异常 + */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED) + public Result handleMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { + log.error("请求方法不支持: {}", e.getMessage()); + return Result.error(405, "不支持的请求方法: " + e.getMethod()); + } + + /** + * 处理请求参数缺失异常 + */ + @ExceptionHandler(MissingServletRequestParameterException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result handleMissingParameterException(MissingServletRequestParameterException e) { + log.error("缺少请求参数: {}", e.getMessage()); + return Result.error(400, "缺少必要参数: " + e.getParameterName()); + } + + /** + * 处理参数类型不匹配异常 + */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result handleTypeMismatchException(MethodArgumentTypeMismatchException e) { + log.error("参数类型不匹配: {}", e.getMessage()); + return Result.error(400, "参数类型错误: " + e.getName()); + } + + /** + * 处理JSON解析异常 + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result handleJsonParseException(HttpMessageNotReadableException e) { + log.error("JSON解析异常: {}", e.getMessage()); + return Result.error(400, "请求数据格式错误"); + } + + /** + * 处理404异常 + */ + @ExceptionHandler(NoHandlerFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public Result handleNotFoundException(NoHandlerFoundException e) { + log.error("请求路径不存在: {}", e.getRequestURL()); + return Result.error(404, "请求的资源不存在"); + } + + /** + * 处理其他异常 + */ + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public Result handleException(Exception e) { + log.error("系统异常: ", e); + return Result.error("系统异常,请联系管理员"); + } +} diff --git a/src/main/java/com/kurihada/auth/exception/RateLimitException.java b/src/main/java/com/kurihada/auth/exception/RateLimitException.java new file mode 100644 index 0000000..1c01241 --- /dev/null +++ b/src/main/java/com/kurihada/auth/exception/RateLimitException.java @@ -0,0 +1,23 @@ +package com.kurihada.auth.exception; + +/** + * 限流异常 + */ +public class RateLimitException extends RuntimeException { + + private final int remainingTime; + + public RateLimitException(String message) { + super(message); + this.remainingTime = 0; + } + + public RateLimitException(String message, int remainingTime) { + super(message); + this.remainingTime = remainingTime; + } + + public int getRemainingTime() { + return remainingTime; + } +} diff --git a/src/main/java/com/kurihada/auth/handler/MyMetaObjectHandler.java b/src/main/java/com/kurihada/auth/handler/MyMetaObjectHandler.java new file mode 100644 index 0000000..a67c548 --- /dev/null +++ b/src/main/java/com/kurihada/auth/handler/MyMetaObjectHandler.java @@ -0,0 +1,35 @@ +package com.kurihada.auth.handler; + +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.reflection.MetaObject; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * MyBatis Plus 自动填充处理器 + */ +@Slf4j +@Component +public class MyMetaObjectHandler implements MetaObjectHandler { + + /** + * 插入时自动填充 + */ + @Override + public void insertFill(MetaObject metaObject) { + log.debug("开始插入填充..."); + this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); + this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); + } + + /** + * 更新时自动填充 + */ + @Override + public void updateFill(MetaObject metaObject) { + log.debug("开始更新填充..."); + this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); + } +} diff --git a/src/main/java/com/kurihada/auth/interceptor/TenantInterceptor.java b/src/main/java/com/kurihada/auth/interceptor/TenantInterceptor.java new file mode 100644 index 0000000..bc87733 --- /dev/null +++ b/src/main/java/com/kurihada/auth/interceptor/TenantInterceptor.java @@ -0,0 +1,68 @@ +package com.kurihada.auth.interceptor; + +import com.kurihada.auth.context.TenantContext; +import com.kurihada.auth.util.JwtUtil; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.ModelAndView; + +/** + * 租户拦截器 - 从请求中提取租户ID并设置到TenantContext + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class TenantInterceptor implements HandlerInterceptor { + + private final JwtUtil jwtUtil; + + @Value("${jwt.header}") + private String tokenHeader; + + @Value("${jwt.prefix}") + private String tokenPrefix; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String jwt = getJwtFromRequest(request); + + if (StringUtils.hasText(jwt) && jwtUtil.validateToken(jwt)) { + // 从JWT的claims中获取tenantId + Long tenantId = jwtUtil.getTenantIdFromToken(jwt); + if (tenantId != null) { + TenantContext.setTenantId(tenantId); + log.debug("设置当前租户ID: {}", tenantId); + } + } + + return true; + } + + @Override + public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) { + // 请求处理完成后清除租户上下文 + TenantContext.clear(); + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + // 确保清除租户上下文(异常情况下也要清除) + TenantContext.clear(); + } + + private String getJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader(tokenHeader); + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(tokenPrefix + " ")) { + return bearerToken.substring(7); + } + + return null; + } +} diff --git a/src/main/java/com/kurihada/auth/mapper/AuditLogMapper.java b/src/main/java/com/kurihada/auth/mapper/AuditLogMapper.java new file mode 100644 index 0000000..cb2fc97 --- /dev/null +++ b/src/main/java/com/kurihada/auth/mapper/AuditLogMapper.java @@ -0,0 +1,12 @@ +package com.kurihada.auth.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.kurihada.auth.entity.AuditLog; +import org.apache.ibatis.annotations.Mapper; + +/** + * 审计日志 Mapper + */ +@Mapper +public interface AuditLogMapper extends BaseMapper { +} diff --git a/src/main/java/com/kurihada/auth/mapper/PermissionMapper.java b/src/main/java/com/kurihada/auth/mapper/PermissionMapper.java new file mode 100644 index 0000000..e6b39a2 --- /dev/null +++ b/src/main/java/com/kurihada/auth/mapper/PermissionMapper.java @@ -0,0 +1,12 @@ +package com.kurihada.auth.mapper; + +import com.kurihada.auth.entity.Permission; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * 权限Mapper接口 + */ +@Mapper +public interface PermissionMapper extends BaseMapper { +} diff --git a/src/main/java/com/kurihada/auth/mapper/RefreshTokenMapper.java b/src/main/java/com/kurihada/auth/mapper/RefreshTokenMapper.java new file mode 100644 index 0000000..f74f27d --- /dev/null +++ b/src/main/java/com/kurihada/auth/mapper/RefreshTokenMapper.java @@ -0,0 +1,12 @@ +package com.kurihada.auth.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.kurihada.auth.entity.RefreshToken; +import org.apache.ibatis.annotations.Mapper; + +/** + * Refresh Token Mapper + */ +@Mapper +public interface RefreshTokenMapper extends BaseMapper { +} diff --git a/src/main/java/com/kurihada/auth/mapper/RoleMapper.java b/src/main/java/com/kurihada/auth/mapper/RoleMapper.java new file mode 100644 index 0000000..7711334 --- /dev/null +++ b/src/main/java/com/kurihada/auth/mapper/RoleMapper.java @@ -0,0 +1,12 @@ +package com.kurihada.auth.mapper; + +import com.kurihada.auth.entity.Role; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * 角色Mapper接口 + */ +@Mapper +public interface RoleMapper extends BaseMapper { +} diff --git a/src/main/java/com/kurihada/auth/mapper/RolePermissionMapper.java b/src/main/java/com/kurihada/auth/mapper/RolePermissionMapper.java new file mode 100644 index 0000000..bda7be4 --- /dev/null +++ b/src/main/java/com/kurihada/auth/mapper/RolePermissionMapper.java @@ -0,0 +1,70 @@ +package com.kurihada.auth.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.kurihada.auth.entity.RolePermission; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 角色权限关联Mapper + */ +@Mapper +public interface RolePermissionMapper extends BaseMapper { + + /** + * 根据角色ID查询权限ID列表 + */ + @Select("SELECT permission_id FROM role_permissions WHERE role_id = #{roleId}") + List selectPermissionIdsByRoleId(Long roleId); + + /** + * 根据权限ID查询角色ID列表 + */ + @Select("SELECT role_id FROM role_permissions WHERE permission_id = #{permissionId}") + List selectRoleIdsByPermissionId(Long permissionId); + + /** + * 为角色分配权限 + */ + @Insert("INSERT INTO role_permissions(role_id, permission_id) VALUES(#{roleId}, #{permissionId})") + int insert(Long roleId, Long permissionId); + + /** + * 批量为角色分配权限 + */ + @Insert("") + int batchInsert(Long roleId, List permissionIds); + + /** + * 删除角色的所有权限 + */ + @Delete("DELETE FROM role_permissions WHERE role_id = #{roleId}") + int deleteByRoleId(Long roleId); + + /** + * 删除权限的所有角色关联 + */ + @Delete("DELETE FROM role_permissions WHERE permission_id = #{permissionId}") + int deleteByPermissionId(Long permissionId); + + /** + * 删除指定角色的指定权限 + */ + @Delete("DELETE FROM role_permissions WHERE role_id = #{roleId} AND permission_id = #{permissionId}") + int deleteByRoleIdAndPermissionId(Long roleId, Long permissionId); + + /** + * 检查角色是否拥有指定权限 + */ + @Select("SELECT COUNT(*) FROM role_permissions WHERE role_id = #{roleId} AND permission_id = #{permissionId}") + int existsByRoleIdAndPermissionId(Long roleId, Long permissionId); +} diff --git a/src/main/java/com/kurihada/auth/mapper/TenantMapper.java b/src/main/java/com/kurihada/auth/mapper/TenantMapper.java new file mode 100644 index 0000000..7725e70 --- /dev/null +++ b/src/main/java/com/kurihada/auth/mapper/TenantMapper.java @@ -0,0 +1,12 @@ +package com.kurihada.auth.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.kurihada.auth.entity.Tenant; +import org.apache.ibatis.annotations.Mapper; + +/** + * 租户Mapper + */ +@Mapper +public interface TenantMapper extends BaseMapper { +} diff --git a/src/main/java/com/kurihada/auth/mapper/UserMapper.java b/src/main/java/com/kurihada/auth/mapper/UserMapper.java new file mode 100644 index 0000000..253adb7 --- /dev/null +++ b/src/main/java/com/kurihada/auth/mapper/UserMapper.java @@ -0,0 +1,28 @@ +package com.kurihada.auth.mapper; + +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import com.kurihada.auth.entity.User; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** + * 用户Mapper接口 + */ +@Mapper +public interface UserMapper extends BaseMapper { + + /** + * 根据用户名查询用户(包含角色和权限,一次查询) + * 忽略租户过滤器,因为登录时还没有租户上下文 + */ + @InterceptorIgnore(tenantLine = "true") + User selectUserWithRolesAndPermissions(@Param("username") String username); + + /** + * 根据用户ID查询用户(包含角色和权限,一次查询) + * 忽略租户过滤器,用于刷新token等场景 + */ + @InterceptorIgnore(tenantLine = "true") + User selectUserWithRolesAndPermissionsById(@Param("userId") Long userId); +} diff --git a/src/main/java/com/kurihada/auth/mapper/UserRoleMapper.java b/src/main/java/com/kurihada/auth/mapper/UserRoleMapper.java new file mode 100644 index 0000000..a1e1685 --- /dev/null +++ b/src/main/java/com/kurihada/auth/mapper/UserRoleMapper.java @@ -0,0 +1,93 @@ +package com.kurihada.auth.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.kurihada.auth.entity.UserRole; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 用户角色关联Mapper + */ +@Mapper +public interface UserRoleMapper extends BaseMapper { + + /** + * 根据用户ID查询角色ID列表 + */ + @Select("SELECT role_id FROM user_roles WHERE user_id = #{userId}") + List selectRoleIdsByUserId(Long userId); + + /** + * 根据角色ID查询用户ID列表 + */ + @Select("SELECT user_id FROM user_roles WHERE role_id = #{roleId}") + List selectUserIdsByRoleId(Long roleId); + + /** + * 为用户分配角色 + */ + @Insert("INSERT INTO user_roles(user_id, role_id) VALUES(#{userId}, #{roleId})") + int insert(Long userId, Long roleId); + + /** + * 批量为用户分配角色 + */ + @Insert("") + int batchInsert(Long userId, List roleIds); + + /** + * 批量为角色分配用户 + */ + @Insert("") + int batchInsertUsers(Long roleId, List userIds); + + /** + * 删除用户的所有角色 + */ + @Delete("DELETE FROM user_roles WHERE user_id = #{userId}") + int deleteByUserId(Long userId); + + /** + * 删除角色的所有用户关联 + */ + @Delete("DELETE FROM user_roles WHERE role_id = #{roleId}") + int deleteByRoleId(Long roleId); + + /** + * 删除指定用户的指定角色 + */ + @Delete("DELETE FROM user_roles WHERE user_id = #{userId} AND role_id = #{roleId}") + int deleteByUserIdAndRoleId(Long userId, Long roleId); + + /** + * 检查用户是否拥有指定角色 + */ + @Select("SELECT COUNT(*) FROM user_roles WHERE user_id = #{userId} AND role_id = #{roleId}") + int existsByUserIdAndRoleId(Long userId, Long roleId); + + /** + * 统计角色的用户数量 + */ + @Select("SELECT COUNT(*) FROM user_roles WHERE role_id = #{roleId}") + int countUsersByRoleId(Long roleId); + + /** + * 统计用户的角色数量 + */ + @Select("SELECT COUNT(*) FROM user_roles WHERE user_id = #{userId}") + int countRolesByUserId(Long userId); +} diff --git a/src/main/java/com/kurihada/auth/security/JwtAccessDeniedHandler.java b/src/main/java/com/kurihada/auth/security/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..c0ba45c --- /dev/null +++ b/src/main/java/com/kurihada/auth/security/JwtAccessDeniedHandler.java @@ -0,0 +1,36 @@ +package com.kurihada.auth.security; + +import com.kurihada.auth.dto.Result; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * JWT访问拒绝处理器 - 处理授权失败 + */ +@Slf4j +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, ServletException { + log.error("访问被拒绝: {}", accessDeniedException.getMessage()); + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + + Result result = Result.error(403, "权限不足,访问被拒绝"); + + ObjectMapper objectMapper = new ObjectMapper(); + response.getWriter().write(objectMapper.writeValueAsString(result)); + } +} diff --git a/src/main/java/com/kurihada/auth/security/JwtAuthenticationEntryPoint.java b/src/main/java/com/kurihada/auth/security/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..f94b5bf --- /dev/null +++ b/src/main/java/com/kurihada/auth/security/JwtAuthenticationEntryPoint.java @@ -0,0 +1,36 @@ +package com.kurihada.auth.security; + +import com.kurihada.auth.dto.Result; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * JWT认证入口点 - 处理认证失败 + */ +@Slf4j +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + log.error("认证失败: {}", authException.getMessage()); + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + + Result result = Result.error(401, "未授权,请先登录"); + + ObjectMapper objectMapper = new ObjectMapper(); + response.getWriter().write(objectMapper.writeValueAsString(result)); + } +} diff --git a/src/main/java/com/kurihada/auth/security/JwtAuthenticationFilter.java b/src/main/java/com/kurihada/auth/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..3656dc0 --- /dev/null +++ b/src/main/java/com/kurihada/auth/security/JwtAuthenticationFilter.java @@ -0,0 +1,111 @@ +package com.kurihada.auth.security; + +import com.kurihada.auth.context.TenantContext; +import com.kurihada.auth.service.PermissionCacheService; +import com.kurihada.auth.util.JwtUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * JWT认证过滤器 + */ +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final UserDetailsService userDetailsService; + private final com.kurihada.auth.service.TokenBlacklistService tokenBlacklistService; + private final PermissionCacheService permissionCacheService; + + @Value("${jwt.header}") + private String tokenHeader; + + @Value("${jwt.prefix}") + private String tokenPrefix; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + try { + String jwt = getJwtFromRequest(request); + + // 检查 token 是否有效且不在黑名单中 + if (StringUtils.hasText(jwt) && jwtUtil.validateToken(jwt) && !tokenBlacklistService.isBlacklisted(jwt)) { + String username = jwtUtil.getUsernameFromToken(jwt); + Long userId = jwtUtil.getUserIdFromToken(jwt); + Long tenantId = jwtUtil.getTenantIdFromToken(jwt); + + // 设置租户上下文 + if (tenantId != null) { + TenantContext.setTenantId(tenantId); + } + + UserDetails userDetails = null; + + // 优先从缓存构建 UserDetails(避免数据库查询) + if (userId != null && tenantId != null) { + userDetails = permissionCacheService.buildUserDetailsFromCache(userId, tenantId, username); + if (userDetails != null) { + logger.debug("从缓存构建 UserDetails 成功: userId=" + userId + ", tenantId=" + tenantId); + } + } + + // 缓存未命中,回退到数据库查询 + if (userDetails == null) { + logger.debug("缓存未命中,从数据库加载 UserDetails: username=" + username); + userDetails = userDetailsService.loadUserByUsername(username); + } + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception e) { + logger.error("无法设置用户认证: {}", e); + } + + // 调用下一个过滤器 + filterChain.doFilter(request, response); + + // CRITICAL: 请求处理完成后清理租户上下文,防止 ThreadLocal 内存泄露 + // 注意:必须在 filterChain.doFilter() 之后执行,确保整个请求链都能访问到租户信息 + // SecurityContext 不需要手动清理,Spring Security 会自动处理 + TenantContext.clear(); + } + + /** + * 从请求中获取JWT token + */ + private String getJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader(tokenHeader); + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(tokenPrefix + " ")) { + return bearerToken.substring(7); + } + + return null; + } +} diff --git a/src/main/java/com/kurihada/auth/security/UserDetailsServiceImpl.java b/src/main/java/com/kurihada/auth/security/UserDetailsServiceImpl.java new file mode 100644 index 0000000..05cb9dd --- /dev/null +++ b/src/main/java/com/kurihada/auth/security/UserDetailsServiceImpl.java @@ -0,0 +1,91 @@ +package com.kurihada.auth.security; + +import com.kurihada.auth.entity.Permission; +import com.kurihada.auth.entity.Role; +import com.kurihada.auth.entity.User; +import com.kurihada.auth.mapper.PermissionMapper; +import com.kurihada.auth.mapper.RoleMapper; +import com.kurihada.auth.mapper.RolePermissionMapper; +import com.kurihada.auth.mapper.UserMapper; +import com.kurihada.auth.mapper.UserRoleMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +/** + * UserDetailsService实现类 + */ +@Service +@RequiredArgsConstructor +public class UserDetailsServiceImpl implements UserDetailsService { + + private final UserMapper userMapper; + private final RoleMapper roleMapper; + private final PermissionMapper permissionMapper; + private final UserRoleMapper userRoleMapper; + private final RolePermissionMapper rolePermissionMapper; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + // 使用优化后的联表查询,一次性加载用户、角色和权限 + User user = userMapper.selectUserWithRolesAndPermissions(username); + + if (user == null) { + throw new UsernameNotFoundException("用户不存在: " + username); + } + + if (user.getStatus() == 0) { + throw new UsernameNotFoundException("用户已被禁用: " + username); + } + + return buildUserDetails(user); + } + + /** + * 构建UserDetails对象 + */ + private UserDetails buildUserDetails(User user) { + Collection authorities = getAuthorities(user); + + return org.springframework.security.core.userdetails.User.builder() + .username(user.getUsername()) + .password(user.getPassword()) + .authorities(authorities) + .accountExpired(false) + .accountLocked(user.getStatus() == 0) + .credentialsExpired(false) + .disabled(user.getDeleted() == 1) + .build(); + } + + /** + * 获取用户权限集合 + */ + private Collection getAuthorities(User user) { + Set authorities = new HashSet<>(); + + // 添加角色权限 + if (user.getRoles() != null) { + for (Role role : user.getRoles()) { + authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getCode())); + + // 添加角色对应的权限 + if (role.getPermissions() != null) { + for (Permission permission : role.getPermissions()) { + authorities.add(new SimpleGrantedAuthority(permission.getCode())); + } + } + } + } + + return authorities; + } +} diff --git a/src/main/java/com/kurihada/auth/service/AccountLockService.java b/src/main/java/com/kurihada/auth/service/AccountLockService.java new file mode 100644 index 0000000..80969ce --- /dev/null +++ b/src/main/java/com/kurihada/auth/service/AccountLockService.java @@ -0,0 +1,50 @@ +package com.kurihada.auth.service; + +import com.kurihada.auth.entity.User; + +/** + * 账户锁定服务接口 + * 功能:管理账户登录失败次数和锁定状态 + */ +public interface AccountLockService { + + /** + * 检查账户是否被锁定 + * + * @param user 用户对象 + * @return true-已锁定,false-未锁定 + */ + boolean isAccountLocked(User user); + + /** + * 记录登录失败 + * 如果失败次数达到阈值,则锁定账户 + * + * @param user 用户对象 + */ + void recordLoginFailure(User user); + + /** + * 重置登录失败次数 + * 在登录成功后调用 + * + * @param user 用户对象 + */ + void resetLoginFailureCount(User user); + + /** + * 解锁账户 + * 手动解锁或自动解锁时调用 + * + * @param user 用户对象 + */ + void unlockAccount(User user); + + /** + * 获取账户剩余锁定时间(秒) + * + * @param user 用户对象 + * @return 剩余锁定时间(秒),如果未锁定则返回0 + */ + long getRemainingLockTime(User user); +} diff --git a/src/main/java/com/kurihada/auth/service/AuditLogService.java b/src/main/java/com/kurihada/auth/service/AuditLogService.java new file mode 100644 index 0000000..0bfe502 --- /dev/null +++ b/src/main/java/com/kurihada/auth/service/AuditLogService.java @@ -0,0 +1,68 @@ +package com.kurihada.auth.service; + +import com.kurihada.auth.dto.AuditLogQueryRequest; +import com.kurihada.auth.dto.AuditLogResponse; +import com.kurihada.auth.dto.PageResult; +import com.kurihada.auth.entity.AuditLog; +import jakarta.servlet.http.HttpServletRequest; + +/** + * 审计日志服务接口 + */ +public interface AuditLogService { + + /** + * 记录审计日志 + * + * @param auditLog 审计日志对象 + */ + void log(AuditLog auditLog); + + /** + * 记录成功的操作 + * + * @param actionType 操作类型 + * @param userId 用户ID + * @param username 用户名 + * @param tenantId 租户ID + * @param resourceType 资源类型 + * @param resourceId 资源ID + * @param details 操作详情 + * @param request HTTP 请求对象 + */ + void logSuccess(String actionType, Long userId, String username, Long tenantId, + String resourceType, String resourceId, String details, HttpServletRequest request); + + /** + * 记录失败的操作 + * + * @param actionType 操作类型 + * @param userId 用户ID + * @param username 用户名 + * @param tenantId 租户ID + * @param resourceType 资源类型 + * @param resourceId 资源ID + * @param details 操作详情 + * @param errorMessage 错误信息 + * @param request HTTP 请求对象 + */ + void logFailure(String actionType, Long userId, String username, Long tenantId, + String resourceType, String resourceId, String details, + String errorMessage, HttpServletRequest request); + + /** + * 分页查询审计日志 + * + * @param request 查询请求参数 + * @return 分页结果 + */ + PageResult queryAuditLogs(AuditLogQueryRequest request); + + /** + * 根据ID获取审计日志详情 + * + * @param id 日志ID + * @return 审计日志详情 + */ + AuditLogResponse getAuditLogById(Long id); +} diff --git a/src/main/java/com/kurihada/auth/service/AuthService.java b/src/main/java/com/kurihada/auth/service/AuthService.java new file mode 100644 index 0000000..6840c9d --- /dev/null +++ b/src/main/java/com/kurihada/auth/service/AuthService.java @@ -0,0 +1,632 @@ +package com.kurihada.auth.service; + +import com.kurihada.auth.dto.ForgotPasswordRequest; +import com.kurihada.auth.dto.LoginRequest; +import com.kurihada.auth.dto.LoginResponse; +import com.kurihada.auth.dto.RegisterRequest; +import com.kurihada.auth.dto.ResetPasswordRequest; +import com.kurihada.auth.entity.Permission; +import com.kurihada.auth.entity.RefreshToken; +import com.kurihada.auth.entity.Role; +import com.kurihada.auth.entity.User; +import com.kurihada.auth.exception.BusinessException; +import com.kurihada.auth.mapper.PermissionMapper; +import com.kurihada.auth.mapper.RoleMapper; +import com.kurihada.auth.mapper.RolePermissionMapper; +import com.kurihada.auth.mapper.UserMapper; +import com.kurihada.auth.mapper.UserRoleMapper; +import com.kurihada.auth.util.JwtUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 认证服务类 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserMapper userMapper; + private final RoleMapper roleMapper; + private final PermissionMapper permissionMapper; + private final UserRoleMapper userRoleMapper; + private final RolePermissionMapper rolePermissionMapper; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + private final AuthenticationManager authenticationManager; + private final RefreshTokenService refreshTokenService; + private final TokenBlacklistService tokenBlacklistService; + private final com.kurihada.auth.util.TenantResolver tenantResolver; + private final PermissionCacheService permissionCacheService; + private final com.kurihada.auth.service.UserRoleService userRoleService; + private final EmailService emailService; + private final PasswordResetTokenService passwordResetTokenService; + private final EmailVerificationTokenService emailVerificationTokenService; + private final AccountLockService accountLockService; + + @Value("${registration.auto-assign-default-role:true}") + private boolean autoAssignDefaultRole; + + @Value("${registration.default-role-code:USER}") + private String defaultRoleCode; + + @Value("${registration.require-email-verification:true}") + private boolean requireEmailVerification; + + /** + * 用户登录 + */ + @Transactional + public LoginResponse login(LoginRequest request, jakarta.servlet.http.HttpServletRequest httpRequest) { + // 解析租户ID + Long tenantId = tenantResolver.resolveTenantId( + httpRequest, + request.getTenantId(), + request.getTenantCode() + ); + log.info("用户 {} 登录,租户ID: {}", request.getUsername(), tenantId); + + // 先查询用户信息以检查账户锁定状态 + User user = userMapper.selectUserWithRolesAndPermissions(request.getUsername()); + + if (user == null) { + throw new BusinessException("用户名或密码错误"); + } + + // 验证用户是否属于指定租户 + if (!user.getTenantId().equals(tenantId)) { + log.warn("用户 {} 不属于租户 {}, 实际租户: {}", request.getUsername(), tenantId, user.getTenantId()); + throw new BusinessException("用户名或密码错误"); // 不暴露租户信息 + } + + // 检查账户是否被锁定 + if (accountLockService.isAccountLocked(user)) { + long remainingSeconds = accountLockService.getRemainingLockTime(user); + long remainingMinutes = remainingSeconds / 60; + log.warn("用户 {} 账户已被锁定,剩余时间: {} 分钟", request.getUsername(), remainingMinutes); + throw new BusinessException(String.format("账户已被锁定,请在 %d 分钟后重试", remainingMinutes)); + } + + // 进行密码认证 + Authentication authentication; + try { + authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken( + request.getUsername(), + request.getPassword() + ) + ); + } catch (Exception e) { + // 认证失败,记录失败次数 + accountLockService.recordLoginFailure(user); + log.warn("用户 {} 登录失败: {}", request.getUsername(), e.getMessage()); + + // 检查是否刚刚被锁定 + if (accountLockService.isAccountLocked(user)) { + long remainingMinutes = accountLockService.getRemainingLockTime(user) / 60; + throw new BusinessException(String.format("登录失败次数过多,账户已被锁定 %d 分钟", remainingMinutes)); + } + + throw new BusinessException("用户名或密码错误"); + } + + // 检查邮箱是否已验证(如果启用了邮箱验证功能) + if (requireEmailVerification && user.getEmail() != null && user.getEmailVerified() == 0) { + log.warn("用户 {} 尝试登录但邮箱未验证", request.getUsername()); + throw new BusinessException("请先验证您的邮箱地址后再登录。如未收到验证邮件,可申请重新发送。"); + } + + // 登录成功,重置失败次数 + accountLockService.resetLoginFailureCount(user); + + // 更新最后登录时间 + user.setLastLoginTime(LocalDateTime.now()); + userMapper.updateById(user); + + // 缓存用户权限(登录时预热缓存) + permissionCacheService.cacheUserPermissions(user.getId(), user.getTenantId(), user); + log.debug("已缓存用户权限: userId={}, tenantId={}", user.getId(), user.getTenantId()); + + // 生成JWT token,包含租户ID和用户ID + Map claims = new HashMap<>(); + claims.put("userId", user.getId()); + if (user.getTenantId() != null) { + claims.put("tenantId", user.getTenantId()); + } + String token = jwtUtil.generateToken(authentication.getName(), claims); + + // 生成 Refresh Token + String refreshToken = refreshTokenService.createRefreshToken(user); + + // 构建响应 + return LoginResponse.builder() + .token(token) + .refreshToken(refreshToken) + .tokenType("Bearer") + .userId(user.getId()) + .username(user.getUsername()) + .realName(user.getRealName()) + .roles(getRoleCodes(user)) + .permissions(getPermissionCodes(user)) + .build(); + } + + /** + * 用户注册 + */ + @Transactional + public void register(RegisterRequest request, jakarta.servlet.http.HttpServletRequest httpRequest) { + // 解析租户ID + Long tenantId = tenantResolver.resolveTenantId( + httpRequest, + request.getTenantId(), + request.getTenantCode() + ); + // 验证用户名是否已存在 + Long count = userMapper.selectCount(new LambdaQueryWrapper() + .eq(User::getUsername, request.getUsername())); + if (count > 0) { + throw new BusinessException("用户名已存在"); + } + + // 验证邮箱是否已存在 + if (request.getEmail() != null) { + Long emailCount = userMapper.selectCount(new LambdaQueryWrapper() + .eq(User::getEmail, request.getEmail())); + if (emailCount > 0) { + throw new BusinessException("邮箱已被使用"); + } + } + + // 验证两次密码是否一致 + if (!request.getPassword().equals(request.getConfirmPassword())) { + throw new BusinessException("两次密码输入不一致"); + } + + // 创建用户 + User user = new User(); + user.setUsername(request.getUsername()); + user.setPassword(passwordEncoder.encode(request.getPassword())); + user.setEmail(request.getEmail()); + user.setRealName(request.getRealName()); + user.setPhone(request.getPhone()); + user.setStatus(1); + user.setDeleted(0); + + // 设置租户ID + user.setTenantId(tenantId); + + userMapper.insert(user); + log.info("用户注册成功: {}, 租户ID: {}", request.getUsername(), tenantId); + + // 自动分配默认角色 + assignDefaultRole(user.getId(), tenantId); + + // 发送邮箱验证邮件(如果启用且有邮箱) + if (requireEmailVerification && user.getEmail() != null) { + sendVerificationEmail(user); + } + } + + /** + * 为新注册用户分配默认角色 + */ + private void assignDefaultRole(Long userId, Long tenantId) { + // 检查是否启用自动分配 + if (!autoAssignDefaultRole) { + log.debug("自动分配默认角色功能已禁用"); + return; + } + + try { + // 查找默认角色 + Role defaultRole = roleMapper.selectOne( + new LambdaQueryWrapper() + .eq(Role::getCode, defaultRoleCode) + .eq(Role::getTenantId, tenantId) + ); + + if (defaultRole != null) { + // 分配角色给用户 + boolean assigned = userRoleService.assignRoleToUser(userId, defaultRole.getId()); + if (assigned) { + log.info("已为新用户自动分配默认角色: userId={}, roleId={}, roleCode={}, tenantId={}", + userId, defaultRole.getId(), defaultRoleCode, tenantId); + } + } else { + log.warn("未找到默认角色 {},无法为新用户分配角色: userId={}, tenantId={}", + defaultRoleCode, userId, tenantId); + } + } catch (Exception e) { + // 分配角色失败不影响注册流程,只记录日志 + log.error("为新用户分配默认角色失败: userId={}, tenantId={}, roleCode={}, error={}", + userId, tenantId, defaultRoleCode, e.getMessage(), e); + } + } + + /** + * 获取用户角色代码集合 + */ +// private Set getRoleCodes(User user) { +// if (user.getRoles() == null) { +// return Set.of(); +// } +// return user.getRoles().stream() +// .map(Role::getCode) +// .collect(Collectors.toSet()); +// } + + /** + * 刷新 Access Token + */ + @Transactional + public LoginResponse refreshAccessToken(String refreshTokenStr) { + // 验证 Refresh Token + RefreshToken refreshToken = refreshTokenService.validateRefreshToken(refreshTokenStr); + + // 获取用户信息 + User user = userMapper.selectUserWithRolesAndPermissionsById(refreshToken.getUserId()); + if (user == null) { + throw new BusinessException("用户不存在"); + } + + if (user.getStatus() == 0) { + throw new BusinessException("用户已被禁用"); + } + + // 标记旧的 Refresh Token 为已使用 + refreshTokenService.markTokenAsUsed(refreshToken.getId()); + + // 缓存用户权限(刷新token时更新缓存) + permissionCacheService.cacheUserPermissions(user.getId(), user.getTenantId(), user); + + // 生成新的 Access Token + Map claims = new HashMap<>(); + if (user.getTenantId() != null) { + claims.put("tenantId", user.getTenantId()); + } + String newAccessToken = jwtUtil.generateToken(user.getUsername(), claims); + + // 生成新的 Refresh Token + String newRefreshToken = refreshTokenService.createRefreshToken(user); + + // 构建响应 + return LoginResponse.builder() + .token(newAccessToken) + .refreshToken(newRefreshToken) + .tokenType("Bearer") + .userId(user.getId()) + .username(user.getUsername()) + .realName(user.getRealName()) + .roles(getRoleCodes(user)) + .permissions(getPermissionCodes(user)) + .build(); + } + + /** + * 用户退出登录 + */ + @Transactional + public void logout(String token, Long userId, Long tenantId) { + // 将 Access Token 加入黑名单 + if (token != null) { + tokenBlacklistService.addToBlacklist(token); + } + + // 删除用户的所有 Refresh Token + if (userId != null) { + refreshTokenService.deleteUserTokens(userId); + } + + // 清除用户权限缓存 + if (userId != null && tenantId != null) { + permissionCacheService.evictUserPermissions(userId, tenantId); + log.debug("已清除用户权限缓存: userId={}, tenantId={}", userId, tenantId); + } + + log.info("用户 {} 已退出登录", userId); + } + + /** + * 获取用户角色代码集合 + */ + private Set getRoleCodes(User user) { + if (user.getRoles() == null) { + return Set.of(); + } + return user.getRoles().stream() + .map(Role::getCode) + .collect(Collectors.toSet()); + } + + /** + * 获取用户权限代码集合 + */ + private Set getPermissionCodes(User user) { + if (user.getRoles() == null) { + return Set.of(); + } + return user.getRoles().stream() + .flatMap(role -> role.getPermissions() != null ? role.getPermissions().stream() : java.util.stream.Stream.empty()) + .map(Permission::getCode) + .collect(Collectors.toSet()); + } + + /** + * 忘记密码 - 发送重置邮件 + * + * @param request 忘记密码请求 + */ + public void forgotPassword(ForgotPasswordRequest request) { + String email = request.getEmail(); + Long tenantId = request.getTenantId(); + + log.info("Processing forgot password request for email: {} in tenant: {}", email, tenantId); + + try { + // 设置租户上下文,让MyBatis Plus自动添加租户隔离条件 + com.kurihada.auth.context.TenantContext.setTenantId(tenantId); + + // 查询用户是否存在(租户ID由插件自动添加) + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(User::getEmail, email); + queryWrapper.eq(User::getDeleted, 0); // 未删除的用户 + User user = userMapper.selectOne(queryWrapper); + + // 无论用户是否存在,都返回相同的消息(安全考虑,防止邮箱枚举) + if (user == null) { + log.warn("Password reset requested for non-existent email: {} in tenant: {}", email, tenantId); + // 不抛出异常,避免暴露用户是否存在 + return; + } + + // 检查用户状态 + if (user.getStatus() != 1) { + log.warn("Password reset requested for disabled user: {} in tenant: {}", email, tenantId); + // 不抛出异常,避免暴露用户状态 + return; + } + + // 生成重置令牌(将邮箱和租户ID组合存储) + String resetToken = passwordResetTokenService.generateResetToken(email, tenantId); + + // 构建重置链接 + String resetUrl = "http://localhost:5173/reset-password"; // TODO: 从配置文件读取 + + // 发送重置邮件 + try { + emailService.sendPasswordResetEmail(email, user.getUsername(), resetToken, resetUrl); + log.info("Password reset email sent successfully to: {} in tenant: {}", email, tenantId); + } catch (Exception e) { + log.error("Failed to send password reset email to: {} in tenant: {}", email, tenantId, e); + throw new BusinessException("发送重置邮件失败,请稍后重试"); + } + } finally { + // 清除租户上下文,避免影响其他请求 + com.kurihada.auth.context.TenantContext.clear(); + } + } + + /** + * 重置密码 + * + * @param request 重置密码请求 + */ + @Transactional + public void resetPassword(ResetPasswordRequest request) { + Long tenantId = request.getTenantId(); + log.info("Processing password reset request for tenant: {}", tenantId); + + // 验证两次密码是否一致 + if (!request.getNewPassword().equals(request.getConfirmPassword())) { + throw new BusinessException("两次输入的密码不一致"); + } + + // 验证令牌并获取邮箱 + String email = passwordResetTokenService.validateTokenAndGetEmail(request.getToken(), tenantId); + if (email == null) { + throw new BusinessException("重置令牌无效或已过期"); + } + + try { + // 设置租户上下文,让MyBatis Plus自动添加租户隔离条件 + com.kurihada.auth.context.TenantContext.setTenantId(tenantId); + + // 查询用户(租户ID由插件自动添加) + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(User::getEmail, email); + queryWrapper.eq(User::getDeleted, 0); + User user = userMapper.selectOne(queryWrapper); + + if (user == null) { + throw new BusinessException("用户不存在"); + } + + if (user.getStatus() != 1) { + throw new BusinessException("用户账号已被禁用"); + } + + // 记录用户信息用于调试 + log.debug("Reset password for user - ID: {}, Username: {}, TenantId: {}", + user.getId(), user.getUsername(), user.getTenantId()); + + // 更新密码 + user.setPassword(passwordEncoder.encode(request.getNewPassword())); + user.setUpdateTime(LocalDateTime.now()); + userMapper.updateById(user); + + // 使令牌失效 + passwordResetTokenService.invalidateToken(request.getToken()); + + // 清除该用户的所有权限缓存 + permissionCacheService.evictUserPermissions(user.getId(), user.getTenantId()); + log.info("User permission cache evicted for userId: {}, tenantId: {}", + user.getId(), user.getTenantId()); + + // 可选:将该用户的所有已登录token加入黑名单,强制重新登录 + // 这里暂不实现,允许用户在密码重置后继续使用旧token直到过期 + + log.info("Password reset successfully for user: {} in tenant: {}", user.getUsername(), tenantId); + } finally { + // 清除租户上下文,避免影响其他请求 + com.kurihada.auth.context.TenantContext.clear(); + } + } + + /** + * 发送邮箱验证邮件 + * + * @param user 用户对象 + */ + private void sendVerificationEmail(User user) { + try { + // 生成验证令牌 + String verificationToken = emailVerificationTokenService.generateVerificationToken( + user.getEmail(), + user.getId(), + user.getTenantId() + ); + + // 构建验证链接 + String verificationUrl = "http://localhost:5173/verify-email"; // TODO: 从配置文件读取 + + // 发送验证邮件 + emailService.sendEmailVerification( + user.getEmail(), + user.getUsername(), + verificationToken, + verificationUrl + ); + + log.info("Email verification sent to: {} (userId: {}, tenantId: {})", + user.getEmail(), user.getId(), user.getTenantId()); + } catch (Exception e) { + // 发送邮件失败不影响注册流程,只记录日志 + log.error("Failed to send verification email to: {} (userId: {})", + user.getEmail(), user.getId(), e); + } + } + + /** + * 验证邮箱 + * + * @param token 验证令牌 + * @param tenantId 租户ID + */ + @Transactional + public void verifyEmail(String token, Long tenantId) { + // 验证令牌并获取用户信息 + Map tokenData = emailVerificationTokenService.validateTokenAndGetUserInfo(token); + if (tokenData == null) { + throw new BusinessException("验证令牌无效或已过期"); + } + + Long userId = convertToLong(tokenData.get("userId")); + Long storedTenantId = convertToLong(tokenData.get("tenantId")); + String email = (String) tokenData.get("email"); + + // 验证租户ID是否匹配 + if (!tenantId.equals(storedTenantId)) { + log.warn("Tenant ID mismatch for email verification. Expected: {}, Got: {}", storedTenantId, tenantId); + throw new BusinessException("验证令牌无效"); + } + + try { + // 设置租户上下文 + com.kurihada.auth.context.TenantContext.setTenantId(tenantId); + + // 查询用户 + User user = userMapper.selectById(userId); + if (user == null) { + throw new BusinessException("用户不存在"); + } + + // 检查邮箱是否匹配 + if (!email.equals(user.getEmail())) { + throw new BusinessException("验证令牌与用户邮箱不匹配"); + } + + // 检查是否已验证 + if (user.getEmailVerified() == 1) { + log.info("Email already verified for user: {} (userId: {})", user.getUsername(), userId); + return; // 已验证,直接返回 + } + + // 更新用户邮箱验证状态 + user.setEmailVerified(1); + user.setEmailVerifiedAt(LocalDateTime.now()); + userMapper.updateById(user); + + // 使令牌失效 + emailVerificationTokenService.invalidateToken(token); + + log.info("Email verified successfully for user: {} (userId: {}, email: {})", + user.getUsername(), userId, email); + } finally { + com.kurihada.auth.context.TenantContext.clear(); + } + } + + /** + * 重新发送验证邮件 + * + * @param email 用户邮箱 + * @param tenantId 租户ID + */ + public void resendVerificationEmail(String email, Long tenantId) { + try { + com.kurihada.auth.context.TenantContext.setTenantId(tenantId); + + // 查询用户 + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(User::getEmail, email); + queryWrapper.eq(User::getDeleted, 0); + User user = userMapper.selectOne(queryWrapper); + + if (user == null) { + // 为了安全,不暴露用户是否存在 + log.warn("Resend verification requested for non-existent email: {} in tenant: {}", email, tenantId); + return; + } + + // 检查是否已验证 + if (user.getEmailVerified() == 1) { + throw new BusinessException("邮箱已验证,无需重新发送"); + } + + // 发送验证邮件 + sendVerificationEmail(user); + + log.info("Verification email resent to: {} (userId: {})", email, user.getId()); + } finally { + com.kurihada.auth.context.TenantContext.clear(); + } + } + + /** + * 辅助方法:将 Object 转换为 Long + */ + private Long convertToLong(Object value) { + if (value instanceof Integer) { + return ((Integer) value).longValue(); + } else if (value instanceof Long) { + return (Long) value; + } + throw new BusinessException("Invalid token data"); + } +} diff --git a/src/main/java/com/kurihada/auth/service/EmailService.java b/src/main/java/com/kurihada/auth/service/EmailService.java new file mode 100644 index 0000000..a76c624 --- /dev/null +++ b/src/main/java/com/kurihada/auth/service/EmailService.java @@ -0,0 +1,149 @@ +package com.kurihada.auth.service; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; + +/** + * 邮件服务 + * 负责发送各类邮件通知 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class EmailService { + + private final JavaMailSender mailSender; + private final SpringTemplateEngine templateEngine; + + @Value("${spring.mail.username}") + private String from; + + /** + * 发送密码重置邮件 + * + * @param to 收件人邮箱 + * @param username 用户名 + * @param resetToken 重置令牌 + * @param resetUrl 重置链接 + */ + @Async + public void sendPasswordResetEmail(String to, String username, String resetToken, String resetUrl) { + try { + Context context = new Context(); + context.setVariable("username", username); + context.setVariable("resetUrl", resetUrl + "?token=" + resetToken); + context.setVariable("validMinutes", 60); // 1小时 + + String htmlContent = templateEngine.process("password-reset-email", context); + + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom(from); + helper.setTo(to); + helper.setSubject("密码重置请求 - Auth System"); + helper.setText(htmlContent, true); + + mailSender.send(message); + log.info("Password reset email sent to: {}", to); + } catch (MessagingException e) { + log.error("Failed to send password reset email to: {}", to, e); + throw new RuntimeException("发送密码重置邮件失败", e); + } + } + + /** + * 发送邮箱验证邮件 + * + * @param to 收件人邮箱 + * @param username 用户名 + * @param verificationToken 验证令牌 + * @param verificationUrl 验证链接 + */ + @Async + public void sendEmailVerification(String to, String username, String verificationToken, String verificationUrl) { + try { + Context context = new Context(); + context.setVariable("username", username); + context.setVariable("verificationUrl", verificationUrl + "?token=" + verificationToken); + context.setVariable("validHours", 24); // 24小时 + + String htmlContent = templateEngine.process("email-verification", context); + + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom(from); + helper.setTo(to); + helper.setSubject("验证您的邮箱 - Auth System"); + helper.setText(htmlContent, true); + + mailSender.send(message); + log.info("Email verification sent to: {}", to); + } catch (MessagingException e) { + log.error("Failed to send email verification to: {}", to, e); + throw new RuntimeException("发送验证邮件失败", e); + } + } + + /** + * 发送纯文本邮件(备用方案) + * + * @param to 收件人 + * @param subject 主题 + * @param content 内容 + */ + @Async + public void sendSimpleEmail(String to, String subject, String content) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom(from); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(content, false); + + mailSender.send(message); + log.info("Simple email sent to: {}", to); + } catch (MessagingException e) { + log.error("Failed to send simple email to: {}", to, e); + throw new RuntimeException("发送邮件失败", e); + } + } + + /** + * 发送HTML邮件 + * + * @param to 收件人 + * @param subject 主题 + * @param htmlContent HTML内容 + */ + @Async + public void sendHtmlEmail(String to, String subject, String htmlContent) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom(from); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(htmlContent, true); + + mailSender.send(message); + log.info("HTML email sent to: {}", to); + } catch (MessagingException e) { + log.error("Failed to send HTML email to: {}", to, e); + throw new RuntimeException("发送HTML邮件失败", e); + } + } +} diff --git a/src/main/java/com/kurihada/auth/service/EmailVerificationTokenService.java b/src/main/java/com/kurihada/auth/service/EmailVerificationTokenService.java new file mode 100644 index 0000000..1a253dc --- /dev/null +++ b/src/main/java/com/kurihada/auth/service/EmailVerificationTokenService.java @@ -0,0 +1,110 @@ +package com.kurihada.auth.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * 邮箱验证Token服务 + * 负责生成、验证和管理邮箱验证令牌 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class EmailVerificationTokenService { + + private final RedisTemplate redisTemplate; + + @Value("${email.verification-token-expiration:86400}") // 默认24小时 + private long tokenExpirationSeconds; + + private static final String VERIFICATION_TOKEN_PREFIX = "email_verification:"; + private static final int TOKEN_LENGTH = 32; + + /** + * 生成邮箱验证令牌 + * + * @param email 用户邮箱 + * @param userId 用户ID + * @param tenantId 租户ID + * @return 验证令牌 + */ + public String generateVerificationToken(String email, Long userId, Long tenantId) { + // 生成安全的随机令牌 + SecureRandom random = new SecureRandom(); + byte[] bytes = new byte[TOKEN_LENGTH]; + random.nextBytes(bytes); + String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + + // 存储到Redis,key为token,value为包含email、userId和tenantId的Map + String key = VERIFICATION_TOKEN_PREFIX + token; + Map tokenData = new HashMap<>(); + tokenData.put("email", email); + tokenData.put("userId", userId); + tokenData.put("tenantId", tenantId); + + redisTemplate.opsForValue().set(key, tokenData, tokenExpirationSeconds, TimeUnit.SECONDS); + + log.info("Generated email verification token for email: {} (userId: {}, tenantId: {})", + email, userId, tenantId); + return token; + } + + /** + * 验证令牌并获取用户信息 + * + * @param token 验证令牌 + * @return 包含email、userId和tenantId的Map,如果令牌无效则返回null + */ + public Map validateTokenAndGetUserInfo(String token) { + String key = VERIFICATION_TOKEN_PREFIX + token; + Object tokenDataObj = redisTemplate.opsForValue().get(key); + + if (tokenDataObj == null) { + log.warn("Invalid or expired email verification token"); + return null; + } + + try { + @SuppressWarnings("unchecked") + Map tokenData = (Map) tokenDataObj; + + log.debug("Validated email verification token for email: {}", tokenData.get("email")); + return tokenData; + } catch (ClassCastException e) { + log.error("Error parsing token data", e); + return null; + } + } + + /** + * 使令牌失效(用于邮箱验证成功后) + * + * @param token 验证令牌 + */ + public void invalidateToken(String token) { + String key = VERIFICATION_TOKEN_PREFIX + token; + redisTemplate.delete(key); + log.info("Invalidated email verification token"); + } + + /** + * 检查用户是否有未使用的验证令牌 + * + * @param userId 用户ID + * @return true表示存在活跃令牌 + */ + public boolean hasActiveVerificationToken(Long userId) { + // 简化实现:直接返回false,允许重新发送验证邮件 + // 实际应用中可以通过用户ID索引来查询 + return false; + } +} diff --git a/src/main/java/com/kurihada/auth/service/PasswordResetTokenService.java b/src/main/java/com/kurihada/auth/service/PasswordResetTokenService.java new file mode 100644 index 0000000..46e0a12 --- /dev/null +++ b/src/main/java/com/kurihada/auth/service/PasswordResetTokenService.java @@ -0,0 +1,128 @@ +package com.kurihada.auth.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * 密码重置Token服务 + * 负责生成、验证和管理密码重置令牌 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PasswordResetTokenService { + + private final RedisTemplate redisTemplate; + + @Value("${password.reset-token-expiration:3600}") + private long tokenExpirationSeconds; + + private static final String RESET_TOKEN_PREFIX = "password_reset:"; + private static final int TOKEN_LENGTH = 32; + + /** + * 生成密码重置令牌 + * + * @param email 用户邮箱 + * @param tenantId 租户ID + * @return 重置令牌 + */ + public String generateResetToken(String email, Long tenantId) { + // 生成安全的随机令牌 + SecureRandom random = new SecureRandom(); + byte[] bytes = new byte[TOKEN_LENGTH]; + random.nextBytes(bytes); + String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + + // 存储到Redis,key为token,value为包含email和tenantId的Map + String key = RESET_TOKEN_PREFIX + token; + Map tokenData = new HashMap<>(); + tokenData.put("email", email); + tokenData.put("tenantId", tenantId); + + redisTemplate.opsForValue().set(key, tokenData, tokenExpirationSeconds, TimeUnit.SECONDS); + + log.info("Generated password reset token for email: {} in tenant: {}", email, tenantId); + return token; + } + + /** + * 验证重置令牌并获取关联的邮箱 + * + * @param token 重置令牌 + * @param tenantId 租户ID(用于验证租户匹配) + * @return 关联的邮箱,如果令牌无效或租户不匹配则返回null + */ + public String validateTokenAndGetEmail(String token, Long tenantId) { + String key = RESET_TOKEN_PREFIX + token; + Object tokenDataObj = redisTemplate.opsForValue().get(key); + + if (tokenDataObj == null) { + log.warn("Invalid or expired password reset token"); + return null; + } + + try { + @SuppressWarnings("unchecked") + Map tokenData = (Map) tokenDataObj; + + String email = (String) tokenData.get("email"); + Object storedTenantIdObj = tokenData.get("tenantId"); + + // 将存储的 tenantId 转换为 Long 类型 + Long storedTenantId = null; + if (storedTenantIdObj instanceof Integer) { + storedTenantId = ((Integer) storedTenantIdObj).longValue(); + } else if (storedTenantIdObj instanceof Long) { + storedTenantId = (Long) storedTenantIdObj; + } + + // 验证租户ID是否匹配 + if (!tenantId.equals(storedTenantId)) { + log.warn("Tenant ID mismatch for password reset token. Expected: {}, Got: {}", + storedTenantId, tenantId); + return null; + } + + log.debug("Validated password reset token for email: {} in tenant: {}", email, tenantId); + return email; + } catch (ClassCastException e) { + log.error("Error parsing token data", e); + return null; + } + } + + /** + * 使令牌失效(用于密码重置成功后) + * + * @param token 重置令牌 + */ + public void invalidateToken(String token) { + String key = RESET_TOKEN_PREFIX + token; + redisTemplate.delete(key); + log.info("Invalidated password reset token"); + } + + /** + * 检查邮箱在指定租户下是否有活跃的重置令牌 + * + * @param email 用户邮箱 + * @param tenantId 租户ID + * @return true表示存在活跃令牌 + */ + public boolean hasActiveResetToken(String email, Long tenantId) { + // 这里可以通过扫描Redis键来实现,但为了性能考虑, + // 我们可以在生成token时额外存储一个反向映射 + // 简化实现:直接返回false,允许生成新令牌 + return false; + } +} diff --git a/src/main/java/com/kurihada/auth/service/PermissionCacheService.java b/src/main/java/com/kurihada/auth/service/PermissionCacheService.java new file mode 100644 index 0000000..38e3d66 --- /dev/null +++ b/src/main/java/com/kurihada/auth/service/PermissionCacheService.java @@ -0,0 +1,94 @@ +package com.kurihada.auth.service; + +import com.kurihada.auth.dto.UserPermissionCache; +import com.kurihada.auth.entity.User; + +/** + * 权限缓存服务接口 + */ +public interface PermissionCacheService { + + /** + * 获取用户权限缓存 + * @param userId 用户ID + * @param tenantId 租户ID + * @return 用户权限缓存 + */ + UserPermissionCache getUserPermissions(Long userId, Long tenantId); + + /** + * 缓存用户权限 + * @param userId 用户ID + * @param tenantId 租户ID + * @param user 用户对象(包含角色和权限) + */ + void cacheUserPermissions(Long userId, Long tenantId, User user); + + /** + * 删除用户权限缓存 + * @param userId 用户ID + * @param tenantId 租户ID + */ + void evictUserPermissions(Long userId, Long tenantId); + + /** + * 删除租户下所有用户的权限缓存 + * @param tenantId 租户ID + */ + void evictTenantPermissions(Long tenantId); + + /** + * 删除拥有指定角色的所有用户的权限缓存 + * @param roleId 角色ID + * @param tenantId 租户ID + */ + void evictRoleRelatedPermissions(Long roleId, Long tenantId); + + /** + * 检查用户是否拥有指定权限 + * @param userId 用户ID + * @param tenantId 租户ID + * @param permissionCode 权限代码 + * @return true-有权限,false-无权限 + */ + boolean hasPermission(Long userId, Long tenantId, String permissionCode); + + /** + * 检查用户是否拥有指定角色 + * @param userId 用户ID + * @param tenantId 租户ID + * @param roleCode 角色代码 + * @return true-有角色,false-无角色 + */ + boolean hasRole(Long userId, Long tenantId, String roleCode); + + /** + * 检查用户是否有权访问指定资源 + * @param userId 用户ID + * @param tenantId 租户ID + * @param resource 资源路径 + * @return true-有权限,false-无权限 + */ + boolean hasResource(Long userId, Long tenantId, String resource); + + /** + * 预热用户权限缓存 + * @param userId 用户ID + * @param tenantId 租户ID + */ + void warmUpUserPermissions(Long userId, Long tenantId); + + /** + * 清空所有权限缓存 + */ + void clearAllPermissionCache(); + + /** + * 从缓存构建 UserDetails(用于 JWT 认证过滤器) + * @param userId 用户ID + * @param tenantId 租户ID + * @param username 用户名 + * @return UserDetails 对象,如果缓存未命中则返回null + */ + org.springframework.security.core.userdetails.UserDetails buildUserDetailsFromCache(Long userId, Long tenantId, String username); +} diff --git a/src/main/java/com/kurihada/auth/service/PermissionService.java b/src/main/java/com/kurihada/auth/service/PermissionService.java new file mode 100644 index 0000000..601a9bd --- /dev/null +++ b/src/main/java/com/kurihada/auth/service/PermissionService.java @@ -0,0 +1,51 @@ +package com.kurihada.auth.service; + +import com.kurihada.auth.dto.CreatePermissionRequest; +import com.kurihada.auth.dto.PageRequest; +import com.kurihada.auth.dto.PageResult; +import com.kurihada.auth.dto.PermissionDTO; +import com.kurihada.auth.dto.PermissionTreeNode; +import com.kurihada.auth.dto.UpdatePermissionRequest; + +import java.util.List; + +/** + * 权限服务接口 + */ +public interface PermissionService { + + /** + * 分页查询权限 + */ + PageResult getPermissionsPage(PageRequest pageRequest); + + /** + * 根据ID获取权限 + */ + PermissionDTO getPermissionById(Long id); + + /** + * 创建新权限 + */ + PermissionDTO createPermission(CreatePermissionRequest request); + + /** + * 更新权限 + */ + PermissionDTO updatePermission(Long id, UpdatePermissionRequest request); + + /** + * 删除权限 + */ + void deletePermission(Long id); + + /** + * 切换权限状态 + */ + void togglePermissionStatus(Long id); + + /** + * 获取权限树形结构 + */ + List getPermissionTree(); +} diff --git a/src/main/java/com/kurihada/auth/service/RefreshTokenService.java b/src/main/java/com/kurihada/auth/service/RefreshTokenService.java new file mode 100644 index 0000000..a29309f --- /dev/null +++ b/src/main/java/com/kurihada/auth/service/RefreshTokenService.java @@ -0,0 +1,102 @@ +package com.kurihada.auth.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.kurihada.auth.entity.RefreshToken; +import com.kurihada.auth.entity.User; +import com.kurihada.auth.exception.BusinessException; +import com.kurihada.auth.mapper.RefreshTokenMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Refresh Token 服务 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + + private final RefreshTokenMapper refreshTokenMapper; + + @Value("${jwt.refresh-expiration}") + private Long refreshExpiration; + + /** + * 创建 Refresh Token + */ + @Transactional + public String createRefreshToken(User user) { + // 生成唯一的 refresh token + String token = UUID.randomUUID().toString().replace("-", ""); + + RefreshToken refreshToken = new RefreshToken(); + refreshToken.setUserId(user.getId()); + refreshToken.setToken(token); + refreshToken.setTenantId(user.getTenantId()); + refreshToken.setExpireTime(LocalDateTime.now().plusSeconds(refreshExpiration / 1000)); + refreshToken.setUsed(false); + + refreshTokenMapper.insert(refreshToken); + + log.info("为用户 {} 创建 Refresh Token", user.getUsername()); + return token; + } + + /** + * 验证 Refresh Token + */ + public RefreshToken validateRefreshToken(String token) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(RefreshToken::getToken, token) + .eq(RefreshToken::getUsed, false) + .gt(RefreshToken::getExpireTime, LocalDateTime.now()); + + RefreshToken refreshToken = refreshTokenMapper.selectOne(queryWrapper); + + if (refreshToken == null) { + throw new BusinessException("Refresh Token 无效或已过期"); + } + + return refreshToken; + } + + /** + * 标记 Refresh Token 为已使用 + */ + @Transactional + public void markTokenAsUsed(Long tokenId) { + RefreshToken refreshToken = refreshTokenMapper.selectById(tokenId); + if (refreshToken != null) { + refreshToken.setUsed(true); + refreshTokenMapper.updateById(refreshToken); + } + } + + /** + * 删除用户的所有 Refresh Token(用于退出登录) + */ + @Transactional + public void deleteUserTokens(Long userId) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(RefreshToken::getUserId, userId); + refreshTokenMapper.delete(queryWrapper); + log.info("删除用户 {} 的所有 Refresh Token", userId); + } + + /** + * 清理过期的 Refresh Token(定时任务) + */ + @Transactional + public void cleanExpiredTokens() { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.lt(RefreshToken::getExpireTime, LocalDateTime.now()); + int count = refreshTokenMapper.delete(queryWrapper); + log.info("清理了 {} 个过期的 Refresh Token", count); + } +} diff --git a/src/main/java/com/kurihada/auth/service/RolePermissionService.java b/src/main/java/com/kurihada/auth/service/RolePermissionService.java new file mode 100644 index 0000000..2b42577 --- /dev/null +++ b/src/main/java/com/kurihada/auth/service/RolePermissionService.java @@ -0,0 +1,98 @@ +package com.kurihada.auth.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.kurihada.auth.entity.Permission; +import com.kurihada.auth.entity.RolePermission; + +import java.util.List; + +/** + * 角色权限关联服务接口 + */ +public interface RolePermissionService extends IService { + + /** + * 为角色分配权限 + * + * @param roleId 角色ID + * @param permissionId 权限ID + * @return 是否成功 + */ + boolean assignPermissionToRole(Long roleId, Long permissionId); + + /** + * 批量为角色分配权限 + * + * @param roleId 角色ID + * @param permissionIds 权限ID列表 + * @return 是否成功 + */ + boolean assignPermissionsToRole(Long roleId, List permissionIds); + + /** + * 移除角色的指定权限 + * + * @param roleId 角色ID + * @param permissionId 权限ID + * @return 是否成功 + */ + boolean removePermissionFromRole(Long roleId, Long permissionId); + + /** + * 移除角色的所有权限 + * + * @param roleId 角色ID + * @return 是否成功 + */ + boolean removeAllPermissionsFromRole(Long roleId); + + /** + * 移除权限的所有角色关联 + * + * @param permissionId 权限ID + * @return 是否成功 + */ + boolean removeAllRolesFromPermission(Long permissionId); + + /** + * 获取角色的所有权限ID列表 + * + * @param roleId 角色ID + * @return 权限ID列表 + */ + List getPermissionIdsByRoleId(Long roleId); + + /** + * 获取角色的所有权限详情列表 + * + * @param roleId 角色ID + * @return 权限详情列表 + */ + List getPermissionsByRoleId(Long roleId); + + /** + * 获取拥有指定权限的所有角色ID列表 + * + * @param permissionId 权限ID + * @return 角色ID列表 + */ + List getRoleIdsByPermissionId(Long permissionId); + + /** + * 检查角色是否拥有指定权限 + * + * @param roleId 角色ID + * @param permissionId 权限ID + * @return 是否拥有 + */ + boolean hasPermission(Long roleId, Long permissionId); + + /** + * 更新角色的权限(先删除所有旧权限,再添加新权限) + * + * @param roleId 角色ID + * @param permissionIds 新的权限ID列表 + * @return 是否成功 + */ + boolean updateRolePermissions(Long roleId, List permissionIds); +} diff --git a/src/main/java/com/kurihada/auth/service/RoleService.java b/src/main/java/com/kurihada/auth/service/RoleService.java new file mode 100644 index 0000000..b281600 --- /dev/null +++ b/src/main/java/com/kurihada/auth/service/RoleService.java @@ -0,0 +1,45 @@ +package com.kurihada.auth.service; + +import com.kurihada.auth.dto.CreateRoleRequest; +import com.kurihada.auth.dto.PageRequest; +import com.kurihada.auth.dto.PageResult; +import com.kurihada.auth.dto.RoleDTO; +import com.kurihada.auth.dto.UpdateRoleRequest; + +import java.util.List; + +/** + * 角色服务接口 + */ +public interface RoleService { + + /** + * 分页查询角色 + */ + PageResult getRolesPage(PageRequest pageRequest); + + /** + * 根据ID获取角色 + */ + RoleDTO getRoleById(Long id); + + /** + * 创建新角色 + */ + RoleDTO createRole(CreateRoleRequest request); + + /** + * 更新角色 + */ + RoleDTO updateRole(Long id, UpdateRoleRequest request); + + /** + * 删除角色 + */ + void deleteRole(Long id); + + /** + * 切换角色状态 + */ + void toggleRoleStatus(Long id); +} diff --git a/src/main/java/com/kurihada/auth/service/TokenBlacklistService.java b/src/main/java/com/kurihada/auth/service/TokenBlacklistService.java new file mode 100644 index 0000000..7bdffcf --- /dev/null +++ b/src/main/java/com/kurihada/auth/service/TokenBlacklistService.java @@ -0,0 +1,71 @@ +package com.kurihada.auth.service; + +import com.kurihada.auth.util.JwtUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.concurrent.TimeUnit; + +/** + * Token 黑名单服务 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TokenBlacklistService { + + private final RedisTemplate redisTemplate; + private final JwtUtil jwtUtil; + + private static final String BLACKLIST_PREFIX = "token:blacklist:"; + + /** + * 将 token 加入黑名单 + */ + public void addToBlacklist(String token) { + try { + // 获取 token 的过期时间 + Date expirationDate = jwtUtil.getExpirationDateFromToken(token); + long expiration = expirationDate.getTime() - System.currentTimeMillis(); + + if (expiration > 0) { + // 将 token 加入黑名单,过期时间与 token 本身的过期时间一致 + String key = BLACKLIST_PREFIX + token; + redisTemplate.opsForValue().set(key, "1", expiration, TimeUnit.MILLISECONDS); + log.info("Token 已加入黑名单,剩余有效期: {} 毫秒", expiration); + } + } catch (Exception e) { + log.error("添加 token 到黑名单失败: {}", e.getMessage()); + } + } + + /** + * 检查 token 是否在黑名单中 + */ + public boolean isBlacklisted(String token) { + try { + String key = BLACKLIST_PREFIX + token; + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } catch (Exception e) { + log.error("检查 token 黑名单状态失败: {}", e.getMessage()); + // 发生异常时,为了安全起见,返回 false(允许通过) + return false; + } + } + + /** + * 从黑名单中移除 token(通常不需要,因为会自动过期) + */ + public void removeFromBlacklist(String token) { + try { + String key = BLACKLIST_PREFIX + token; + redisTemplate.delete(key); + log.info("Token 已从黑名单中移除"); + } catch (Exception e) { + log.error("从黑名单移除 token 失败: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/kurihada/auth/service/UserRoleService.java b/src/main/java/com/kurihada/auth/service/UserRoleService.java new file mode 100644 index 0000000..bb84242 --- /dev/null +++ b/src/main/java/com/kurihada/auth/service/UserRoleService.java @@ -0,0 +1,141 @@ +package com.kurihada.auth.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.kurihada.auth.entity.Role; +import com.kurihada.auth.entity.User; +import com.kurihada.auth.entity.UserRole; + +import java.util.List; + +/** + * 用户角色关联服务接口 + */ +public interface UserRoleService extends IService { + + /** + * 为用户分配角色 + * + * @param userId 用户ID + * @param roleId 角色ID + * @return 是否成功 + */ + boolean assignRoleToUser(Long userId, Long roleId); + + /** + * 批量为用户分配角色 + * + * @param userId 用户ID + * @param roleIds 角色ID列表 + * @return 是否成功 + */ + boolean assignRolesToUser(Long userId, List roleIds); + + /** + * 批量为角色分配用户 + * + * @param roleId 角色ID + * @param userIds 用户ID列表 + * @return 是否成功 + */ + boolean assignUsersToRole(Long roleId, List userIds); + + /** + * 移除用户的指定角色 + * + * @param userId 用户ID + * @param roleId 角色ID + * @return 是否成功 + */ + boolean removeRoleFromUser(Long userId, Long roleId); + + /** + * 移除用户的所有角色 + * + * @param userId 用户ID + * @return 是否成功 + */ + boolean removeAllRolesFromUser(Long userId); + + /** + * 移除角色的所有用户关联 + * + * @param roleId 角色ID + * @return 是否成功 + */ + boolean removeAllUsersFromRole(Long roleId); + + /** + * 获取用户的所有角色ID列表 + * + * @param userId 用户ID + * @return 角色ID列表 + */ + List getRoleIdsByUserId(Long userId); + + /** + * 获取用户的所有角色详情列表 + * + * @param userId 用户ID + * @return 角色详情列表 + */ + List getRolesByUserId(Long userId); + + /** + * 获取拥有指定角色的所有用户ID列表 + * + * @param roleId 角色ID + * @return 用户ID列表 + */ + List getUserIdsByRoleId(Long roleId); + + /** + * 获取拥有指定角色的所有用户详情列表 + * + * @param roleId 角色ID + * @return 用户详情列表 + */ + List getUsersByRoleId(Long roleId); + + /** + * 检查用户是否拥有指定角色 + * + * @param userId 用户ID + * @param roleId 角色ID + * @return 是否拥有 + */ + boolean hasRole(Long userId, Long roleId); + + /** + * 检查用户是否拥有指定角色代码 + * + * @param userId 用户ID + * @param roleCode 角色代码 + * @return 是否拥有 + */ + boolean hasRoleByCode(Long userId, String roleCode); + + /** + * 更新用户的角色(先删除所有旧角色,再添加新角色) + * + * @param userId 用户ID + * @param roleIds 新的角色ID列表 + * @return 是否成功 + */ + boolean updateUserRoles(Long userId, List roleIds); + + /** + * 统计角色的用户数量 + * + * @param roleId 角色ID + * @return 用户数量 + */ + int countUsersByRoleId(Long roleId); + + /** + * 统计用户的角色数量 + * + * @param userId 用户ID + * @return 角色数量 + */ + int countRolesByUserId(Long userId); +} diff --git a/src/main/java/com/kurihada/auth/service/UserService.java b/src/main/java/com/kurihada/auth/service/UserService.java new file mode 100644 index 0000000..cc0214a --- /dev/null +++ b/src/main/java/com/kurihada/auth/service/UserService.java @@ -0,0 +1,466 @@ +package com.kurihada.auth.service; + +import com.kurihada.auth.dto.ChangePasswordRequest; +import com.kurihada.auth.dto.PageRequest; +import com.kurihada.auth.dto.PageResult; +import com.kurihada.auth.dto.PermissionDTO; +import com.kurihada.auth.dto.PermissionTreeNode; +import com.kurihada.auth.dto.UserDTO; +import com.kurihada.auth.dto.UpdateUserRequest; +import com.kurihada.auth.entity.Permission; +import com.kurihada.auth.entity.Role; +import com.kurihada.auth.entity.User; +import com.kurihada.auth.exception.BusinessException; +import com.kurihada.auth.mapper.PermissionMapper; +import com.kurihada.auth.mapper.RoleMapper; +import com.kurihada.auth.mapper.RolePermissionMapper; +import com.kurihada.auth.mapper.UserMapper; +import com.kurihada.auth.mapper.UserRoleMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 用户服务类 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserMapper userMapper; + private final RoleMapper roleMapper; + private final PermissionMapper permissionMapper; + private final UserRoleMapper userRoleMapper; + private final RolePermissionMapper rolePermissionMapper; + private final PasswordEncoder passwordEncoder; + private final PermissionCacheService permissionCacheService; + + /** + * 获取当前登录用户信息 + */ + public UserDTO getCurrentUser() { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + // 使用优化后的联表查询 + User user = userMapper.selectUserWithRolesAndPermissions(username); + + if (user == null) { + throw new BusinessException("用户不存在"); + } + + return convertToDTO(user); + } + + /** + * 根据ID获取用户信息 + */ + public UserDTO getUserById(Long id) { + // 使用优化后的联表查询 + User user = userMapper.selectUserWithRolesAndPermissionsById(id); + if (user == null) { + throw new BusinessException("用户不存在"); + } + + return convertToDTO(user); + } + + /** + * 分页查询用户列表 + */ + public PageResult getUsersPage(PageRequest pageRequest) { + // 构建查询条件 + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(User::getDeleted, 0); + + // 搜索条件 + if (StringUtils.hasText(pageRequest.getKeyword())) { + queryWrapper.and(wrapper -> wrapper + .like(User::getUsername, pageRequest.getKeyword()) + .or() + .like(User::getRealName, pageRequest.getKeyword()) + .or() + .like(User::getEmail, pageRequest.getKeyword()) + .or() + .like(User::getPhone, pageRequest.getKeyword()) + ); + } + + // 排序 + if (StringUtils.hasText(pageRequest.getSortBy())) { + boolean isAsc = "asc".equalsIgnoreCase(pageRequest.getSortDirection()); + switch (pageRequest.getSortBy()) { + case "username" -> queryWrapper.orderBy(true, isAsc, User::getUsername); + case "realName" -> queryWrapper.orderBy(true, isAsc, User::getRealName); + case "email" -> queryWrapper.orderBy(true, isAsc, User::getEmail); + case "status" -> queryWrapper.orderBy(true, isAsc, User::getStatus); + case "createTime" -> queryWrapper.orderBy(true, isAsc, User::getCreateTime); + case "lastLoginTime" -> queryWrapper.orderBy(true, isAsc, User::getLastLoginTime); + default -> queryWrapper.orderByDesc(User::getCreateTime); + } + } else { + queryWrapper.orderByDesc(User::getCreateTime); + } + + // 查询总数 + long total = userMapper.selectCount(queryWrapper); + + // 查询分页数据 + queryWrapper.last("LIMIT " + pageRequest.getOffset() + ", " + pageRequest.getLimit()); + List users = userMapper.selectList(queryWrapper); + + // 转换为DTO + List userDTOs = users.stream() + .map(this::convertToDTO) + .collect(Collectors.toList()); + + return PageResult.of(userDTOs, total, pageRequest.getPage(), pageRequest.getSize()); + } + + /** + * 删除用户(逻辑删除) + */ + public void deleteUser(Long id) { + User user = userMapper.selectById(id); + if (user == null) { + throw new BusinessException("用户不存在"); + } + + // MyBatis Plus的逻辑删除会自动处理 + userMapper.deleteById(id); + log.info("用户已删除: {}", user.getUsername()); + } + + /** + * 更新用户信息(管理员操作) + */ + @Transactional(rollbackFor = Exception.class) + public UserDTO updateUser(Long id, com.kurihada.auth.dto.UpdateUserRequest request) { + User user = userMapper.selectById(id); + if (user == null) { + throw new BusinessException("用户不存在"); + } + + // 更新用户信息 + if (request.getRealName() != null) { + user.setRealName(request.getRealName()); + } + if (request.getEmail() != null) { + // 检查邮箱是否已被其他用户使用 + Long count = userMapper.selectCount(new LambdaQueryWrapper() + .eq(User::getEmail, request.getEmail()) + .ne(User::getId, id)); + if (count > 0) { + throw new BusinessException("邮箱已被其他用户使用"); + } + user.setEmail(request.getEmail()); + } + if (request.getPhone() != null) { + // 检查手机号是否已被其他用户使用 + Long count = userMapper.selectCount(new LambdaQueryWrapper() + .eq(User::getPhone, request.getPhone()) + .ne(User::getId, id)); + if (count > 0) { + throw new BusinessException("手机号已被其他用户使用"); + } + user.setPhone(request.getPhone()); + } + if (request.getGender() != null) { + user.setGender(request.getGender()); + } + if (request.getAvatar() != null) { + user.setAvatar(request.getAvatar()); + } + + userMapper.updateById(user); + log.info("用户信息已更新: {}", user.getUsername()); + + // 返回更新后的用户信息 + User updatedUser = userMapper.selectUserWithRolesAndPermissions(user.getUsername()); + return convertToDTO(updatedUser); + } + + /** + * 更新当前用户信息(用户自己操作) + */ + @Transactional(rollbackFor = Exception.class) + public UserDTO updateCurrentUser(com.kurihada.auth.dto.UpdateUserRequest request) { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + User user = userMapper.selectOne(new LambdaQueryWrapper() + .eq(User::getUsername, username)); + + if (user == null) { + throw new BusinessException("用户不存在"); + } + + // 更新用户信息 + if (request.getRealName() != null) { + user.setRealName(request.getRealName()); + } + if (request.getEmail() != null) { + // 检查邮箱是否已被其他用户使用 + Long count = userMapper.selectCount(new LambdaQueryWrapper() + .eq(User::getEmail, request.getEmail()) + .ne(User::getId, user.getId())); + if (count > 0) { + throw new BusinessException("邮箱已被其他用户使用"); + } + user.setEmail(request.getEmail()); + } + if (request.getPhone() != null) { + // 检查手机号是否已被其他用户使用 + Long count = userMapper.selectCount(new LambdaQueryWrapper() + .eq(User::getPhone, request.getPhone()) + .ne(User::getId, user.getId())); + if (count > 0) { + throw new BusinessException("手机号已被其他用户使用"); + } + user.setPhone(request.getPhone()); + } + if (request.getGender() != null) { + user.setGender(request.getGender()); + } + if (request.getAvatar() != null) { + user.setAvatar(request.getAvatar()); + } + + userMapper.updateById(user); + log.info("用户 {} 更新了自己的信息", user.getUsername()); + + // 返回更新后的用户信息 + User updatedUser = userMapper.selectUserWithRolesAndPermissions(user.getUsername()); + return convertToDTO(updatedUser); + } + + /** + * 启用/禁用用户 + */ + public void toggleUserStatus(Long id) { + User user = userMapper.selectById(id); + if (user == null) { + throw new BusinessException("用户不存在"); + } + + user.setStatus(user.getStatus() == 1 ? 0 : 1); + userMapper.updateById(user); + log.info("用户状态已更新: {}, 新状态: {}", user.getUsername(), user.getStatus()); + } + + /** + * 转换为DTO + */ + private UserDTO convertToDTO(User user) { + // 角色和权限已经通过联表查询加载,无需再次查询 + return UserDTO.builder() + .id(user.getId()) + .username(user.getUsername()) + .realName(user.getRealName()) + .email(user.getEmail()) + .phone(user.getPhone()) + .gender(user.getGender()) + .avatar(user.getAvatar()) + .status(user.getStatus()) + .lastLoginTime(user.getLastLoginTime()) + .createTime(user.getCreateTime()) + .roles(getRoleCodes(user)) + .permissions(getPermissionCodes(user)) + .build(); + } + + /** + * 获取用户角色代码集合 + */ + private Set getRoleCodes(User user) { + if (user.getRoles() == null) { + return Set.of(); + } + return user.getRoles().stream() + .map(Role::getCode) + .collect(Collectors.toSet()); + } + + /** + * 获取用户权限代码集合 + */ + private Set getPermissionCodes(User user) { + if (user.getRoles() == null) { + return Set.of(); + } + return user.getRoles().stream() + .flatMap(role -> role.getPermissions() != null ? role.getPermissions().stream() : java.util.stream.Stream.empty()) + .map(Permission::getCode) + .collect(Collectors.toSet()); + } + + /** + * 修改当前用户密码 + */ + @Transactional(rollbackFor = Exception.class) + public void changePassword(ChangePasswordRequest request) { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + User user = userMapper.selectOne(new LambdaQueryWrapper() + .eq(User::getUsername, username)); + + if (user == null) { + throw new BusinessException("用户不存在"); + } + + // 验证旧密码 + if (!passwordEncoder.matches(request.getOldPassword(), user.getPassword())) { + throw new BusinessException("旧密码不正确"); + } + + // 验证新密码不能与旧密码相同 + if (request.getOldPassword().equals(request.getNewPassword())) { + throw new BusinessException("新密码不能与旧密码相同"); + } + + // 更新密码 + user.setPassword(passwordEncoder.encode(request.getNewPassword())); + userMapper.updateById(user); + log.info("用户 {} 已修改密码", username); + + // 清除用户权限缓存(可选,因为密码修改不影响权限) + if (user.getTenantId() != null) { + permissionCacheService.evictUserPermissions(user.getId(), user.getTenantId()); + } + } + + /** + * 获取当前用户的所有权限 + */ + public List getCurrentUserPermissions() { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + User user = userMapper.selectUserWithRolesAndPermissions(username); + + if (user == null) { + throw new BusinessException("用户不存在"); + } + + // 获取用户所有角色的权限 + Set permissionIds = new HashSet<>(); + if (user.getRoles() != null) { + for (Role role : user.getRoles()) { + List rolePermissionIds = rolePermissionMapper.selectPermissionIdsByRoleId(role.getId()); + permissionIds.addAll(rolePermissionIds); + } + } + + if (permissionIds.isEmpty()) { + return List.of(); + } + + // 查询权限详情 + List permissions = permissionMapper.selectBatchIds(permissionIds); + + return permissions.stream() + .filter(p -> p.getStatus() == 1) // 只返回启用的权限 + .map(this::convertPermissionToDTO) + .collect(Collectors.toList()); + } + + /** + * 获取当前用户的菜单树 + */ + public List getCurrentUserMenus() { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + User user = userMapper.selectUserWithRolesAndPermissions(username); + + if (user == null) { + throw new BusinessException("用户不存在"); + } + + // 获取用户所有角色的权限 + Set permissionIds = new HashSet<>(); + if (user.getRoles() != null) { + for (Role role : user.getRoles()) { + List rolePermissionIds = rolePermissionMapper.selectPermissionIdsByRoleId(role.getId()); + permissionIds.addAll(rolePermissionIds); + } + } + + if (permissionIds.isEmpty()) { + return List.of(); + } + + // 查询权限详情,只查询菜单类型的权限 + List permissions = permissionMapper.selectList( + new LambdaQueryWrapper() + .in(Permission::getId, permissionIds) + .eq(Permission::getType, "menu") + .eq(Permission::getStatus, 1) + .orderByAsc(Permission::getSort) + ); + + // 构建树形结构 + Map nodeMap = new HashMap<>(); + for (Permission permission : permissions) { + PermissionTreeNode node = convertPermissionToTreeNode(permission); + nodeMap.put(permission.getId(), node); + } + + // 构建树 + List rootNodes = new ArrayList<>(); + for (PermissionTreeNode node : nodeMap.values()) { + if (node.getParentId() == null || node.getParentId() == 0) { + rootNodes.add(node); + } else { + PermissionTreeNode parent = nodeMap.get(node.getParentId()); + if (parent != null) { + parent.getChildren().add(node); + } else { + rootNodes.add(node); + } + } + } + + return rootNodes; + } + + /** + * 转换权限为DTO + */ + private PermissionDTO convertPermissionToDTO(Permission permission) { + return PermissionDTO.builder() + .id(permission.getId()) + .name(permission.getName()) + .code(permission.getCode()) + .tenantId(permission.getTenantId()) + .description(permission.getDescription()) + .type(permission.getType()) + .resource(permission.getResource()) + .parentId(permission.getParentId()) + .sort(permission.getSort()) + .status(permission.getStatus()) + .createTime(permission.getCreateTime()) + .updateTime(permission.getUpdateTime()) + .build(); + } + + /** + * 转换权限为树节点 + */ + private PermissionTreeNode convertPermissionToTreeNode(Permission permission) { + return PermissionTreeNode.builder() + .id(permission.getId()) + .name(permission.getName()) + .code(permission.getCode()) + .tenantId(permission.getTenantId()) + .type(permission.getType()) + .resource(permission.getResource()) + .parentId(permission.getParentId()) + .sort(permission.getSort()) + .status(permission.getStatus()) + .createTime(permission.getCreateTime()) + .updateTime(permission.getUpdateTime()) + .children(new ArrayList<>()) + .build(); + } +} diff --git a/src/main/java/com/kurihada/auth/service/impl/AccountLockServiceImpl.java b/src/main/java/com/kurihada/auth/service/impl/AccountLockServiceImpl.java new file mode 100644 index 0000000..fdc5621 --- /dev/null +++ b/src/main/java/com/kurihada/auth/service/impl/AccountLockServiceImpl.java @@ -0,0 +1,146 @@ +package com.kurihada.auth.service.impl; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.kurihada.auth.entity.User; +import com.kurihada.auth.mapper.UserMapper; +import com.kurihada.auth.service.AccountLockService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; + +/** + * 账户锁定服务实现类 + * 功能:管理账户登录失败次数和锁定状态 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AccountLockServiceImpl implements AccountLockService { + + private final UserMapper userMapper; + + /** + * 最大登录失败次数(默认5次) + */ + @Value("${account-lock.max-failed-attempts:5}") + private int maxFailedAttempts; + + /** + * 账户锁定时长(分钟,默认30分钟) + */ + @Value("${account-lock.lock-duration-minutes:30}") + private int lockDurationMinutes; + + @Override + public boolean isAccountLocked(User user) { + if (user.getAccountLockedAt() == null || user.getAccountUnlockAt() == null) { + return false; + } + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime unlockTime = user.getAccountUnlockAt(); + + // 如果当前时间超过解锁时间,自动解锁 + if (now.isAfter(unlockTime)) { + log.info("账户锁定时间已过,自动解锁用户: {}", user.getUsername()); + unlockAccount(user); + return false; + } + + return true; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void recordLoginFailure(User user) { + int currentFailedCount = user.getLoginFailedCount() != null ? user.getLoginFailedCount() : 0; + int newFailedCount = currentFailedCount + 1; + + log.warn("用户 {} 登录失败,失败次数: {}/{}", user.getUsername(), newFailedCount, maxFailedAttempts); + + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(User::getId, user.getId()); + + if (newFailedCount >= maxFailedAttempts) { + // 达到最大失败次数,锁定账户 + LocalDateTime lockedAt = LocalDateTime.now(); + LocalDateTime unlockAt = lockedAt.plusMinutes(lockDurationMinutes); + + updateWrapper.set(User::getLoginFailedCount, newFailedCount) + .set(User::getAccountLockedAt, lockedAt) + .set(User::getAccountUnlockAt, unlockAt); + + log.warn("用户 {} 登录失败次数达到 {} 次,账户已锁定,解锁时间: {}", + user.getUsername(), maxFailedAttempts, unlockAt); + } else { + // 只增加失败次数 + updateWrapper.set(User::getLoginFailedCount, newFailedCount); + } + + userMapper.update(null, updateWrapper); + + // 更新当前对象的状态 + user.setLoginFailedCount(newFailedCount); + if (newFailedCount >= maxFailedAttempts) { + user.setAccountLockedAt(LocalDateTime.now()); + user.setAccountUnlockAt(LocalDateTime.now().plusMinutes(lockDurationMinutes)); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void resetLoginFailureCount(User user) { + if (user.getLoginFailedCount() != null && user.getLoginFailedCount() > 0) { + log.info("重置用户 {} 的登录失败次数", user.getUsername()); + + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(User::getId, user.getId()) + .set(User::getLoginFailedCount, 0) + .set(User::getAccountLockedAt, null) + .set(User::getAccountUnlockAt, null); + + userMapper.update(null, updateWrapper); + + // 更新当前对象的状态 + user.setLoginFailedCount(0); + user.setAccountLockedAt(null); + user.setAccountUnlockAt(null); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void unlockAccount(User user) { + log.info("解锁用户账户: {}", user.getUsername()); + + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(User::getId, user.getId()) + .set(User::getLoginFailedCount, 0) + .set(User::getAccountLockedAt, null) + .set(User::getAccountUnlockAt, null); + + userMapper.update(null, updateWrapper); + + // 更新当前对象的状态 + user.setLoginFailedCount(0); + user.setAccountLockedAt(null); + user.setAccountUnlockAt(null); + } + + @Override + public long getRemainingLockTime(User user) { + if (!isAccountLocked(user)) { + return 0; + } + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime unlockTime = user.getAccountUnlockAt(); + + return ChronoUnit.SECONDS.between(now, unlockTime); + } +} diff --git a/src/main/java/com/kurihada/auth/service/impl/AuditLogServiceImpl.java b/src/main/java/com/kurihada/auth/service/impl/AuditLogServiceImpl.java new file mode 100644 index 0000000..f89691e --- /dev/null +++ b/src/main/java/com/kurihada/auth/service/impl/AuditLogServiceImpl.java @@ -0,0 +1,232 @@ +package com.kurihada.auth.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.StringUtils; +import com.kurihada.auth.dto.AuditLogQueryRequest; +import com.kurihada.auth.dto.AuditLogResponse; +import com.kurihada.auth.dto.PageResult; +import com.kurihada.auth.entity.AuditLog; +import com.kurihada.auth.exception.BusinessException; +import com.kurihada.auth.mapper.AuditLogMapper; +import com.kurihada.auth.service.AuditLogService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 审计日志服务实现 + */ +@Slf4j +@Service +public class AuditLogServiceImpl implements AuditLogService { + + @Autowired + private AuditLogMapper auditLogMapper; + + @Override + @Async // 异步记录日志,不影响主流程性能 + public void log(AuditLog auditLog) { + try { + auditLogMapper.insert(auditLog); + log.debug("审计日志记录成功: {}", auditLog); + } catch (Exception e) { + // 记录日志失败不应该影响主业务流程 + log.error("审计日志记录失败: {}", auditLog, e); + } + } + + @Override + public void logSuccess(String actionType, Long userId, String username, Long tenantId, + String resourceType, String resourceId, String details, + HttpServletRequest request) { + AuditLog auditLog = buildAuditLog(actionType, userId, username, tenantId, + resourceType, resourceId, details, "SUCCESS", null, request); + log(auditLog); + } + + @Override + public void logFailure(String actionType, Long userId, String username, Long tenantId, + String resourceType, String resourceId, String details, + String errorMessage, HttpServletRequest request) { + AuditLog auditLog = buildAuditLog(actionType, userId, username, tenantId, + resourceType, resourceId, details, "FAILURE", errorMessage, request); + log(auditLog); + } + + /** + * 构建审计日志对象 + */ + private AuditLog buildAuditLog(String actionType, Long userId, String username, + Long tenantId, String resourceType, String resourceId, + String details, String result, String errorMessage, + HttpServletRequest request) { + return AuditLog.builder() + .actionType(actionType) + .userId(userId) + .username(username) + .tenantId(tenantId) + .resourceType(resourceType) + .resourceId(resourceId) + .details(details) + .result(result) + .errorMessage(errorMessage) + .ipAddress(getClientIp(request)) + .userAgent(request != null ? request.getHeader("User-Agent") : null) + .build(); + } + + /** + * 获取客户端真实IP地址 + */ + private String getClientIp(HttpServletRequest request) { + if (request == null) { + return null; + } + + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("Proxy-Client-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("WL-Proxy-Client-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("HTTP_CLIENT_IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("HTTP_X_FORWARDED_FOR"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + + // 对于多级代理,取第一个IP + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + + return ip; + } + + @Override + public PageResult queryAuditLogs(AuditLogQueryRequest request) { + // 构建查询条件 + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + + // 用户ID + if (request.getUserId() != null) { + queryWrapper.eq(AuditLog::getUserId, request.getUserId()); + } + + // 用户名(模糊查询) + if (StringUtils.isNotBlank(request.getUsername())) { + queryWrapper.like(AuditLog::getUsername, request.getUsername()); + } + + // 操作类型 + if (StringUtils.isNotBlank(request.getActionType())) { + queryWrapper.eq(AuditLog::getActionType, request.getActionType()); + } + + // 资源类型 + if (StringUtils.isNotBlank(request.getResourceType())) { + queryWrapper.eq(AuditLog::getResourceType, request.getResourceType()); + } + + // 资源ID + if (StringUtils.isNotBlank(request.getResourceId())) { + queryWrapper.eq(AuditLog::getResourceId, request.getResourceId()); + } + + // 操作结果 + if (StringUtils.isNotBlank(request.getResult())) { + queryWrapper.eq(AuditLog::getResult, request.getResult()); + } + + // 租户ID + if (request.getTenantId() != null) { + queryWrapper.eq(AuditLog::getTenantId, request.getTenantId()); + } + + // IP地址 + if (StringUtils.isNotBlank(request.getIpAddress())) { + queryWrapper.eq(AuditLog::getIpAddress, request.getIpAddress()); + } + + // 时间范围查询 + if (request.getStartDate() != null) { + queryWrapper.ge(AuditLog::getCreateTime, request.getStartDate()); + } + if (request.getEndDate() != null) { + queryWrapper.le(AuditLog::getCreateTime, request.getEndDate()); + } + + // 关键词搜索(在详情中搜索) + if (StringUtils.isNotBlank(request.getKeyword())) { + queryWrapper.like(AuditLog::getDetails, request.getKeyword()); + } + + // 排序(默认按创建时间降序) + if (StringUtils.isNotBlank(request.getSortBy())) { + if ("desc".equalsIgnoreCase(request.getSortDirection())) { + queryWrapper.orderByDesc(getColumnByField(request.getSortBy())); + } else { + queryWrapper.orderByAsc(getColumnByField(request.getSortBy())); + } + } else { + queryWrapper.orderByDesc(AuditLog::getCreateTime); + } + + // 查询总数 + Long total = auditLogMapper.selectCount(queryWrapper); + + // 分页查询 + queryWrapper.last("LIMIT " + request.getLimit() + " OFFSET " + request.getOffset()); + List auditLogs = auditLogMapper.selectList(queryWrapper); + + // 转换为DTO + List responses = auditLogs.stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + + return PageResult.of(responses, total, request.getPage(), request.getSize()); + } + + @Override + public AuditLogResponse getAuditLogById(Long id) { + AuditLog auditLog = auditLogMapper.selectById(id); + if (auditLog == null) { + throw new BusinessException("审计日志不存在"); + } + return convertToResponse(auditLog); + } + + /** + * 将 AuditLog 实体转换为 AuditLogResponse DTO + */ + private AuditLogResponse convertToResponse(AuditLog auditLog) { + AuditLogResponse response = new AuditLogResponse(); + BeanUtils.copyProperties(auditLog, response); + return response; + } + + /** + * 根据字段名获取排序列 + */ + private com.baomidou.mybatisplus.core.toolkit.support.SFunction getColumnByField(String field) { + return switch (field) { + case "actionType" -> AuditLog::getActionType; + case "userId" -> AuditLog::getUserId; + case "username" -> AuditLog::getUsername; + case "result" -> AuditLog::getResult; + case "createTime" -> AuditLog::getCreateTime; + default -> AuditLog::getCreateTime; + }; + } +} diff --git a/src/main/java/com/kurihada/auth/service/impl/PermissionCacheServiceImpl.java b/src/main/java/com/kurihada/auth/service/impl/PermissionCacheServiceImpl.java new file mode 100644 index 0000000..2a4746e --- /dev/null +++ b/src/main/java/com/kurihada/auth/service/impl/PermissionCacheServiceImpl.java @@ -0,0 +1,314 @@ +package com.kurihada.auth.service.impl; + +import com.kurihada.auth.constant.CacheConstants; +import com.kurihada.auth.dto.UserPermissionCache; +import com.kurihada.auth.entity.Permission; +import com.kurihada.auth.entity.Role; +import com.kurihada.auth.entity.User; +import com.kurihada.auth.mapper.UserMapper; +import com.kurihada.auth.mapper.UserRoleMapper; +import com.kurihada.auth.service.PermissionCacheService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 权限缓存服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PermissionCacheServiceImpl implements PermissionCacheService { + + private final RedisTemplate redisTemplate; + private final UserMapper userMapper; + private final UserRoleMapper userRoleMapper; + + @Override + public UserPermissionCache getUserPermissions(Long userId, Long tenantId) { + String cacheKey = CacheConstants.buildUserPermissionKey(userId, tenantId); + + // 尝试从缓存获取 + UserPermissionCache cache = (UserPermissionCache) redisTemplate.opsForValue().get(cacheKey); + + if (cache != null) { + log.debug("从缓存获取用户权限: userId={}, tenantId={}", userId, tenantId); + return cache; + } + + log.debug("缓存未命中,从数据库加载用户权限: userId={}, tenantId={}", userId, tenantId); + + // 缓存未命中,从数据库加载 + return loadAndCacheUserPermissions(userId, tenantId); + } + + @Override + public void cacheUserPermissions(Long userId, Long tenantId, User user) { + if (user == null) { + log.warn("用户对象为空,无法缓存权限: userId={}, tenantId={}", userId, tenantId); + return; + } + + UserPermissionCache cache = buildUserPermissionCache(user, tenantId); + String cacheKey = CacheConstants.buildUserPermissionKey(userId, tenantId); + + redisTemplate.opsForValue().set( + cacheKey, + cache, + CacheConstants.PERMISSION_CACHE_TTL, + TimeUnit.SECONDS + ); + + // 将用户ID添加到租户用户集合中(用于批量删除) + String tenantUsersKey = CacheConstants.buildTenantUsersKey(tenantId); + redisTemplate.opsForSet().add(tenantUsersKey, userId); + redisTemplate.expire(tenantUsersKey, CacheConstants.PERMISSION_CACHE_TTL, TimeUnit.SECONDS); + + log.info("已缓存用户权限: userId={}, tenantId={}, roles={}, permissions={}", + userId, tenantId, cache.getRoleCodes().size(), cache.getPermissionCodes().size()); + } + + @Override + public void evictUserPermissions(Long userId, Long tenantId) { + String cacheKey = CacheConstants.buildUserPermissionKey(userId, tenantId); + Boolean deleted = redisTemplate.delete(cacheKey); + + if (Boolean.TRUE.equals(deleted)) { + log.info("已删除用户权限缓存: userId={}, tenantId={}", userId, tenantId); + } + + // 从租户用户集合中移除 + String tenantUsersKey = CacheConstants.buildTenantUsersKey(tenantId); + redisTemplate.opsForSet().remove(tenantUsersKey, userId); + } + + @Override + public void evictTenantPermissions(Long tenantId) { + String tenantUsersKey = CacheConstants.buildTenantUsersKey(tenantId); + + // 获取租户下所有用户ID + Set userIds = redisTemplate.opsForSet().members(tenantUsersKey); + + if (CollectionUtils.isEmpty(userIds)) { + log.debug("租户下没有缓存的用户: tenantId={}", tenantId); + return; + } + + // 批量删除用户权限缓存 + int count = 0; + for (Object userId : userIds) { + if (userId instanceof Long) { + String cacheKey = CacheConstants.buildUserPermissionKey((Long) userId, tenantId); + Boolean deleted = redisTemplate.delete(cacheKey); + if (Boolean.TRUE.equals(deleted)) { + count++; + } + } + } + + // 删除租户用户集合 + redisTemplate.delete(tenantUsersKey); + + log.info("已删除租户下所有用户权限缓存: tenantId={}, count={}", tenantId, count); + } + + @Override + public void evictRoleRelatedPermissions(Long roleId, Long tenantId) { + // 获取拥有该角色的所有用户ID(直接使用 Mapper 避免循环依赖) + List userIds = userRoleMapper.selectUserIdsByRoleId(roleId); + + if (CollectionUtils.isEmpty(userIds)) { + log.debug("没有用户拥有该角色: roleId={}, tenantId={}", roleId, tenantId); + return; + } + + // 批量删除相关用户的权限缓存 + int count = 0; + for (Long userId : userIds) { + evictUserPermissions(userId, tenantId); + count++; + } + + log.info("已删除角色关联的用户权限缓存: roleId={}, tenantId={}, userCount={}", + roleId, tenantId, count); + } + + @Override + public boolean hasPermission(Long userId, Long tenantId, String permissionCode) { + UserPermissionCache cache = getUserPermissions(userId, tenantId); + return cache != null && cache.hasPermission(permissionCode); + } + + @Override + public boolean hasRole(Long userId, Long tenantId, String roleCode) { + UserPermissionCache cache = getUserPermissions(userId, tenantId); + return cache != null && cache.hasRole(roleCode); + } + + @Override + public boolean hasResource(Long userId, Long tenantId, String resource) { + UserPermissionCache cache = getUserPermissions(userId, tenantId); + return cache != null && cache.hasResource(resource); + } + + @Override + public void warmUpUserPermissions(Long userId, Long tenantId) { + log.info("预热用户权限缓存: userId={}, tenantId={}", userId, tenantId); + loadAndCacheUserPermissions(userId, tenantId); + } + + @Override + public void clearAllPermissionCache() { + Set keys = redisTemplate.keys(CacheConstants.USER_PERMISSIONS_KEY + "*"); + + if (!CollectionUtils.isEmpty(keys)) { + Long deletedCount = redisTemplate.delete(keys); + log.info("已清空所有权限缓存: count={}", deletedCount); + } + + // 清空租户用户集合 + Set tenantKeys = redisTemplate.keys(CacheConstants.TENANT_USERS_KEY + "*"); + if (!CollectionUtils.isEmpty(tenantKeys)) { + redisTemplate.delete(tenantKeys); + } + } + + /** + * 从数据库加载并缓存用户权限 + */ + private UserPermissionCache loadAndCacheUserPermissions(Long userId, Long tenantId) { + // 从数据库查询用户及其角色、权限 + User user = userMapper.selectUserWithRolesAndPermissionsById(userId); + + if (user == null) { + log.warn("用户不存在: userId={}", userId); + return null; + } + + // 验证租户 + if (!user.getTenantId().equals(tenantId)) { + log.warn("用户租户不匹配: userId={}, userTenantId={}, requestTenantId={}", + userId, user.getTenantId(), tenantId); + return null; + } + + // 构建并缓存权限信息 + UserPermissionCache cache = buildUserPermissionCache(user, tenantId); + String cacheKey = CacheConstants.buildUserPermissionKey(userId, tenantId); + + redisTemplate.opsForValue().set( + cacheKey, + cache, + CacheConstants.PERMISSION_CACHE_TTL, + TimeUnit.SECONDS + ); + + // 将用户ID添加到租户用户集合 + String tenantUsersKey = CacheConstants.buildTenantUsersKey(tenantId); + redisTemplate.opsForSet().add(tenantUsersKey, userId); + redisTemplate.expire(tenantUsersKey, CacheConstants.PERMISSION_CACHE_TTL, TimeUnit.SECONDS); + + log.info("已从数据库加载并缓存用户权限: userId={}, tenantId={}", userId, tenantId); + + return cache; + } + + /** + * 构建用户权限缓存对象 + */ + private UserPermissionCache buildUserPermissionCache(User user, Long tenantId) { + UserPermissionCache cache = new UserPermissionCache(); + cache.setUserId(user.getId()); + cache.setTenantId(tenantId); + cache.setUsername(user.getUsername()); + cache.setCacheTime(System.currentTimeMillis()); + + // 初始化空集合,避免 null + List roleCodes = new java.util.ArrayList<>(); + List roleNames = new java.util.ArrayList<>(); + Set permissionCodes = new HashSet<>(); + Set resources = new HashSet<>(); + + List roles = user.getRoles(); + if (!CollectionUtils.isEmpty(roles)) { + // 提取角色代码和名称 + roleCodes = roles.stream() + .map(Role::getCode) + .collect(Collectors.toList()); + roleNames = roles.stream() + .map(Role::getName) + .collect(Collectors.toList()); + + // 提取所有权限 + for (Role role : roles) { + List permissions = role.getPermissions(); + if (!CollectionUtils.isEmpty(permissions)) { + permissionCodes.addAll(permissions.stream() + .map(Permission::getCode) + .collect(Collectors.toSet())); + + // 提取资源路径 + resources.addAll(permissions.stream() + .map(Permission::getResource) + .filter(resource -> resource != null && !resource.isEmpty()) + .collect(Collectors.toSet())); + } + } + } + + // 设置集合(即使为空也设置,避免返回 null) + cache.setRoleCodes(roleCodes); + cache.setRoleNames(roleNames); + cache.setPermissionCodes(permissionCodes); + cache.setResources(resources); + + return cache; + } + + @Override + public org.springframework.security.core.userdetails.UserDetails buildUserDetailsFromCache(Long userId, Long tenantId, String username) { + // 从缓存获取用户权限 + UserPermissionCache cache = getUserPermissions(userId, tenantId); + + if (cache == null) { + log.debug("缓存未命中,无法构建 UserDetails: userId={}, tenantId={}", userId, tenantId); + return null; + } + + // 构建权限列表(角色 + 权限) + Set authorities = new HashSet<>(); + + // 添加角色权限(ROLE_ 前缀) + if (cache.getRoleCodes() != null) { + cache.getRoleCodes().forEach(roleCode -> + authorities.add(new org.springframework.security.core.authority.SimpleGrantedAuthority("ROLE_" + roleCode)) + ); + } + + // 添加资源权限 + if (cache.getPermissionCodes() != null) { + cache.getPermissionCodes().forEach(permissionCode -> + authorities.add(new org.springframework.security.core.authority.SimpleGrantedAuthority(permissionCode)) + ); + } + + // 构建 UserDetails 对象(使用缓存中的数据,不需要密码) + return org.springframework.security.core.userdetails.User.builder() + .username(username) + .password("") // JWT 认证不需要密码验证 + .authorities(authorities) + .accountExpired(false) + .accountLocked(false) + .credentialsExpired(false) + .disabled(false) + .build(); + } +} diff --git a/src/main/java/com/kurihada/auth/service/impl/PermissionServiceImpl.java b/src/main/java/com/kurihada/auth/service/impl/PermissionServiceImpl.java new file mode 100644 index 0000000..88a168a --- /dev/null +++ b/src/main/java/com/kurihada/auth/service/impl/PermissionServiceImpl.java @@ -0,0 +1,488 @@ +package com.kurihada.auth.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.kurihada.auth.context.TenantContext; +import com.kurihada.auth.dto.CreatePermissionRequest; +import com.kurihada.auth.dto.PageRequest; +import com.kurihada.auth.dto.PageResult; +import com.kurihada.auth.dto.PermissionDTO; +import com.kurihada.auth.dto.PermissionTreeNode; +import com.kurihada.auth.dto.UpdatePermissionRequest; +import com.kurihada.auth.entity.AuditLog; +import com.kurihada.auth.entity.Permission; +import com.kurihada.auth.entity.RolePermission; +import com.kurihada.auth.entity.User; +import com.kurihada.auth.exception.BusinessException; +import com.kurihada.auth.mapper.PermissionMapper; +import com.kurihada.auth.mapper.RolePermissionMapper; +import com.kurihada.auth.mapper.UserMapper; +import com.kurihada.auth.service.AuditLogService; +import com.kurihada.auth.service.PermissionCacheService; +import com.kurihada.auth.service.PermissionService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 权限服务实现类 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PermissionServiceImpl implements PermissionService { + + private final PermissionMapper permissionMapper; + private final RolePermissionMapper rolePermissionMapper; + private final UserMapper userMapper; + private final PermissionCacheService permissionCacheService; + private final AuditLogService auditLogService; + + // 系统内置权限,不允许删除 + private static final Set SYSTEM_PERMISSIONS = new HashSet<>(Arrays.asList( + "system:manage", "user:manage", "role:manage", "permission:manage" + )); + + @Override + public PageResult getPermissionsPage(PageRequest pageRequest) { + Long tenantId = TenantContext.getTenantId(); + + // 构建查询条件 + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.and(wrapper -> wrapper + .isNull(Permission::getTenantId) + .or() + .eq(tenantId != null, Permission::getTenantId, tenantId) + ); + + // 搜索条件 + if (StringUtils.hasText(pageRequest.getKeyword())) { + queryWrapper.and(wrapper -> wrapper + .like(Permission::getName, pageRequest.getKeyword()) + .or() + .like(Permission::getCode, pageRequest.getKeyword()) + .or() + .like(Permission::getDescription, pageRequest.getKeyword()) + .or() + .like(Permission::getResource, pageRequest.getKeyword()) + ); + } + + // 排序 + if (StringUtils.hasText(pageRequest.getSortBy())) { + boolean isAsc = "asc".equalsIgnoreCase(pageRequest.getSortDirection()); + switch (pageRequest.getSortBy()) { + case "name" -> queryWrapper.orderBy(true, isAsc, Permission::getName); + case "code" -> queryWrapper.orderBy(true, isAsc, Permission::getCode); + case "type" -> queryWrapper.orderBy(true, isAsc, Permission::getType); + case "sort" -> queryWrapper.orderBy(true, isAsc, Permission::getSort); + case "status" -> queryWrapper.orderBy(true, isAsc, Permission::getStatus); + case "createTime" -> queryWrapper.orderBy(true, isAsc, Permission::getCreateTime); + default -> queryWrapper.orderByAsc(Permission::getSort).orderByDesc(Permission::getCreateTime); + } + } else { + queryWrapper.orderByAsc(Permission::getSort).orderByDesc(Permission::getCreateTime); + } + + // 查询总数 + Long total = permissionMapper.selectCount(queryWrapper); + + // 分页查询 - 使用 MyBatis Plus 的安全分页方法 + com.baomidou.mybatisplus.extension.plugins.pagination.Page page = + new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>( + pageRequest.getPage(), + pageRequest.getSize() + ); + page.setSearchCount(false); // 已经查询过总数,避免重复查询 + + com.baomidou.mybatisplus.extension.plugins.pagination.Page result = + permissionMapper.selectPage(page, queryWrapper); + List permissions = result.getRecords(); + + List permissionDTOs = permissions.stream() + .map(this::convertToDTO) + .collect(Collectors.toList()); + + return PageResult.of(permissionDTOs, total, pageRequest.getPage(), pageRequest.getSize()); + } + + @Override + public PermissionDTO getPermissionById(Long id) { + Permission permission = permissionMapper.selectById(id); + if (permission == null) { + throw new BusinessException("权限不存在"); + } + + // 验证租户权限 + Long tenantId = TenantContext.getTenantId(); + if (permission.getTenantId() != null && tenantId != null && !tenantId.equals(permission.getTenantId())) { + throw new BusinessException("无权访问该权限"); + } + + return convertToDTO(permission); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public PermissionDTO createPermission(CreatePermissionRequest request) { + Long tenantId = TenantContext.getTenantId(); + User currentUser = getCurrentUser(); + + // 验证权限代码格式(小写字母、数字、冒号、下划线) + if (!request.getCode().matches("^[a-z0-9:_]+$")) { + auditLogPermission("CREATE_PERMISSION", null, "FAILURE", "权限代码格式不正确", request, currentUser, tenantId); + throw new BusinessException("权限代码只能包含小写字母、数字、冒号和下划线"); + } + + // 检查权限代码是否已存在 + Long count = permissionMapper.selectCount(new LambdaQueryWrapper() + .eq(Permission::getCode, request.getCode()) + .and(wrapper -> wrapper + .isNull(Permission::getTenantId) + .or() + .eq(tenantId != null, Permission::getTenantId, tenantId) + )); + + if (count > 0) { + auditLogPermission("CREATE_PERMISSION", null, "FAILURE", "权限代码已存在", request, currentUser, tenantId); + throw new BusinessException("权限代码已存在"); + } + + // 创建权限 + Permission permission = new Permission(); + permission.setName(request.getName()); + permission.setCode(request.getCode()); + permission.setDescription(request.getDescription()); + permission.setType(request.getType()); + permission.setResource(request.getResource()); + permission.setParentId(request.getParentId()); + permission.setSort(request.getSort()); + permission.setTenantId(tenantId); + permission.setStatus(1); + + permissionMapper.insert(permission); + log.info("权限创建成功: {}, ID: {}", permission.getName(), permission.getId()); + + // 记录审计日志 + auditLogPermission("CREATE_PERMISSION", permission.getId(), "SUCCESS", null, permission, currentUser, tenantId); + + return convertToDTO(permission); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public PermissionDTO updatePermission(Long id, UpdatePermissionRequest request) { + Permission permission = permissionMapper.selectById(id); + if (permission == null) { + throw new BusinessException("权限不存在"); + } + + Long tenantId = TenantContext.getTenantId(); + User currentUser = getCurrentUser(); + + // 验证租户权限(公共权限不能修改) + if (permission.getTenantId() == null) { + auditLogPermission("UPDATE_PERMISSION", id, "FAILURE", "不能修改系统公共权限", request, currentUser, tenantId); + throw new BusinessException("不能修改系统公共权限"); + } + + if (tenantId != null && !tenantId.equals(permission.getTenantId())) { + auditLogPermission("UPDATE_PERMISSION", id, "FAILURE", "无权修改该权限", request, currentUser, tenantId); + throw new BusinessException("无权修改该权限"); + } + + String oldName = permission.getName(); + String oldDescription = permission.getDescription(); + + // 更新权限信息 + permission.setName(request.getName()); + permission.setDescription(request.getDescription()); + permission.setType(request.getType()); + permission.setResource(request.getResource()); + permission.setParentId(request.getParentId()); + if (request.getSort() != null) { + permission.setSort(request.getSort()); + } + + permissionMapper.updateById(permission); + log.info("权限更新成功: {}, ID: {}", permission.getName(), permission.getId()); + + // 清理相关权限缓存 + permissionCacheService.clearAllPermissionCache(); + + // 记录审计日志 + String details = String.format("更新前: name=%s, description=%s; 更新后: name=%s, description=%s", + oldName, oldDescription, permission.getName(), permission.getDescription()); + auditLogPermission("UPDATE_PERMISSION", id, "SUCCESS", details, permission, currentUser, tenantId); + + return convertToDTO(permission); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deletePermission(Long id) { + Permission permission = permissionMapper.selectById(id); + if (permission == null) { + throw new BusinessException("权限不存在"); + } + + Long tenantId = TenantContext.getTenantId(); + User currentUser = getCurrentUser(); + + // 验证租户权限(公共权限不能删除) + if (permission.getTenantId() == null) { + auditLogPermission("DELETE_PERMISSION", id, "FAILURE", "不能删除系统公共权限", permission, currentUser, tenantId); + throw new BusinessException("不能删除系统公共权限"); + } + + if (tenantId != null && !tenantId.equals(permission.getTenantId())) { + auditLogPermission("DELETE_PERMISSION", id, "FAILURE", "无权删除该权限", permission, currentUser, tenantId); + throw new BusinessException("无权删除该权限"); + } + + // 防止删除系统内置权限 + if (SYSTEM_PERMISSIONS.contains(permission.getCode())) { + auditLogPermission("DELETE_PERMISSION", id, "FAILURE", "不能删除系统内置权限", permission, currentUser, tenantId); + throw new BusinessException("不能删除系统内置权限: " + permission.getCode()); + } + + // 检查是否有角色正在使用该权限 + Long roleCount = rolePermissionMapper.selectCount(new LambdaQueryWrapper() + .eq(RolePermission::getPermissionId, id)); + if (roleCount > 0) { + String errorMsg = String.format("该权限正被 %d 个角色使用,无法删除", roleCount); + auditLogPermission("DELETE_PERMISSION", id, "FAILURE", errorMsg, permission, currentUser, tenantId); + throw new BusinessException(errorMsg); + } + + // 删除权限 + permissionMapper.deleteById(id); + log.info("权限删除成功: {}, ID: {}", permission.getName(), permission.getId()); + + // 清理权限缓存 + permissionCacheService.clearAllPermissionCache(); + + // 记录审计日志 + auditLogPermission("DELETE_PERMISSION", id, "SUCCESS", null, permission, currentUser, tenantId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void togglePermissionStatus(Long id) { + Permission permission = permissionMapper.selectById(id); + if (permission == null) { + throw new BusinessException("权限不存在"); + } + + Long tenantId = TenantContext.getTenantId(); + User currentUser = getCurrentUser(); + + // 验证租户权限(公共权限不能修改状态) + if (permission.getTenantId() == null) { + auditLogPermission("TOGGLE_PERMISSION_STATUS", id, "FAILURE", "不能修改系统公共权限状态", permission, currentUser, tenantId); + throw new BusinessException("不能修改系统公共权限状态"); + } + + if (tenantId != null && !tenantId.equals(permission.getTenantId())) { + auditLogPermission("TOGGLE_PERMISSION_STATUS", id, "FAILURE", "无权修改该权限", permission, currentUser, tenantId); + throw new BusinessException("无权修改该权限"); + } + + // 防止禁用系统内置权限 + if (SYSTEM_PERMISSIONS.contains(permission.getCode()) && permission.getStatus() == 1) { + auditLogPermission("TOGGLE_PERMISSION_STATUS", id, "FAILURE", "不能禁用系统内置权限", permission, currentUser, tenantId); + throw new BusinessException("不能禁用系统内置权限: " + permission.getCode()); + } + + int oldStatus = permission.getStatus(); + int newStatus = oldStatus == 1 ? 0 : 1; + permission.setStatus(newStatus); + permissionMapper.updateById(permission); + log.info("权限状态已更新: {}, 新状态: {}", permission.getName(), permission.getStatus()); + + // 清理权限缓存 + permissionCacheService.clearAllPermissionCache(); + + // 记录审计日志 + String details = String.format("状态从 %s 变更为 %s", + oldStatus == 1 ? "启用" : "禁用", + newStatus == 1 ? "启用" : "禁用"); + auditLogPermission("TOGGLE_PERMISSION_STATUS", id, "SUCCESS", details, permission, currentUser, tenantId); + } + + @Override + public List getPermissionTree() { + Long tenantId = TenantContext.getTenantId(); + + // 查询所有权限(公共权限 + 当前租户权限) + List allPermissions = permissionMapper.selectList( + new LambdaQueryWrapper() + .and(wrapper -> wrapper + .isNull(Permission::getTenantId) + .or() + .eq(tenantId != null, Permission::getTenantId, tenantId) + ) + .eq(Permission::getStatus, 1) // 只查询启用的权限 + .orderByAsc(Permission::getSort) + .orderByDesc(Permission::getCreateTime) + ); + + // 将所有权限转换为树节点,并建立 ID -> 节点的映射 + Map nodeMap = new HashMap<>(); + for (Permission permission : allPermissions) { + PermissionTreeNode node = convertToTreeNode(permission); + nodeMap.put(permission.getId(), node); + } + + // 构建树形结构 + List rootNodes = new ArrayList<>(); + for (PermissionTreeNode node : nodeMap.values()) { + if (node.getParentId() == null || node.getParentId() == 0) { + // 根节点 + rootNodes.add(node); + } else { + // 子节点,添加到父节点的 children 中 + PermissionTreeNode parent = nodeMap.get(node.getParentId()); + if (parent != null) { + parent.getChildren().add(node); + } else { + // 如果找不到父节点,作为根节点处理 + rootNodes.add(node); + } + } + } + + return rootNodes; + } + + /** + * 转换为DTO + */ + private PermissionDTO convertToDTO(Permission permission) { + return PermissionDTO.builder() + .id(permission.getId()) + .name(permission.getName()) + .code(permission.getCode()) + .tenantId(permission.getTenantId()) + .description(permission.getDescription()) + .type(permission.getType()) + .resource(permission.getResource()) + .parentId(permission.getParentId()) + .sort(permission.getSort()) + .status(permission.getStatus()) + .createTime(permission.getCreateTime()) + .updateTime(permission.getUpdateTime()) + .build(); + } + + /** + * 转换为树节点 + */ + private PermissionTreeNode convertToTreeNode(Permission permission) { + return PermissionTreeNode.builder() + .id(permission.getId()) + .name(permission.getName()) + .code(permission.getCode()) + .tenantId(permission.getTenantId()) + .type(permission.getType()) + .resource(permission.getResource()) + .parentId(permission.getParentId()) + .sort(permission.getSort()) + .status(permission.getStatus()) + .createTime(permission.getCreateTime()) + .updateTime(permission.getUpdateTime()) + .children(new ArrayList<>()) + .build(); + } + + /** + * 获取当前登录用户 + */ + private User getCurrentUser() { + try { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.isAuthenticated()) { + String username = authentication.getName(); + if (!"anonymousUser".equals(username)) { + return userMapper.selectUserWithRolesAndPermissions(username); + } + } + } catch (Exception e) { + log.warn("获取当前用户信息失败: {}", e.getMessage()); + } + return null; + } + + /** + * 获取客户端IP地址 + */ + private String getClientIp() { + try { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes != null) { + HttpServletRequest request = attributes.getRequest(); + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("X-Real-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + return ip; + } + } catch (Exception e) { + log.warn("获取客户端IP失败: {}", e.getMessage()); + } + return null; + } + + /** + * 获取User Agent + */ + private String getUserAgent() { + try { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes != null) { + return attributes.getRequest().getHeader("User-Agent"); + } + } catch (Exception e) { + log.warn("获取User-Agent失败: {}", e.getMessage()); + } + return null; + } + + /** + * 记录权限操作审计日志 + */ + private void auditLogPermission(String actionType, Long permissionId, String result, + String errorMessage, Object details, User currentUser, Long tenantId) { + try { + AuditLog auditLog = AuditLog.builder() + .actionType(actionType) + .userId(currentUser != null ? currentUser.getId() : null) + .username(currentUser != null ? currentUser.getUsername() : "system") + .tenantId(tenantId != null ? tenantId : (currentUser != null ? currentUser.getTenantId() : null)) + .resourceType("PERMISSION") + .resourceId(permissionId != null ? String.valueOf(permissionId) : null) + .details(details != null ? details.toString() : null) + .result(result) + .errorMessage(errorMessage) + .ipAddress(getClientIp()) + .userAgent(getUserAgent()) + .build(); + + auditLogService.log(auditLog); + } catch (Exception e) { + log.error("记录审计日志失败: {}", e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/kurihada/auth/service/impl/RolePermissionServiceImpl.java b/src/main/java/com/kurihada/auth/service/impl/RolePermissionServiceImpl.java new file mode 100644 index 0000000..4b5ed3a --- /dev/null +++ b/src/main/java/com/kurihada/auth/service/impl/RolePermissionServiceImpl.java @@ -0,0 +1,238 @@ +package com.kurihada.auth.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.kurihada.auth.entity.Permission; +import com.kurihada.auth.entity.Role; +import com.kurihada.auth.entity.RolePermission; +import com.kurihada.auth.exception.BusinessException; +import com.kurihada.auth.mapper.PermissionMapper; +import com.kurihada.auth.mapper.RoleMapper; +import com.kurihada.auth.mapper.RolePermissionMapper; +import com.kurihada.auth.service.RolePermissionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 角色权限关联服务实现类 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RolePermissionServiceImpl extends ServiceImpl implements RolePermissionService { + + private final RolePermissionMapper rolePermissionMapper; + private final RoleMapper roleMapper; + private final PermissionMapper permissionMapper; + private final com.kurihada.auth.service.PermissionCacheService permissionCacheService; + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean assignPermissionToRole(Long roleId, Long permissionId) { + // 验证角色是否存在 + Role role = roleMapper.selectById(roleId); + if (role == null) { + throw new BusinessException("角色不存在"); + } + + // 验证权限是否存在 + Permission permission = permissionMapper.selectById(permissionId); + if (permission == null) { + throw new BusinessException("权限不存在"); + } + + // 验证租户一致性(权限的 tenantId 可能为 null,表示公共权限) + if (permission.getTenantId() != null && !permission.getTenantId().equals(role.getTenantId())) { + throw new BusinessException("权限和角色不属于同一租户,无法分配"); + } + + // 检查是否已经存在该关联 + if (hasPermission(roleId, permissionId)) { + log.warn("角色ID: {} 已经拥有权限ID: {}", roleId, permissionId); + return true; + } + + // 创建角色权限关联,包含租户ID + RolePermission rolePermission = new RolePermission(); + rolePermission.setRoleId(roleId); + rolePermission.setPermissionId(permissionId); + rolePermission.setTenantId(role.getTenantId()); + + int result = rolePermissionMapper.insert(rolePermission); + if (result > 0) { + log.info("成功为角色ID: {} 分配权限ID: {},租户ID: {}", roleId, permissionId, role.getTenantId()); + + // 清除相关用户的权限缓存 + permissionCacheService.evictRoleRelatedPermissions(roleId, role.getTenantId()); + log.debug("已清除角色 {} 关联用户的权限缓存", roleId); + + return true; + } + return false; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean assignPermissionsToRole(Long roleId, List permissionIds) { + // 验证角色是否存在 + Role role = roleMapper.selectById(roleId); + if (role == null) { + throw new BusinessException("角色不存在"); + } + + if (permissionIds == null || permissionIds.isEmpty()) { + throw new BusinessException("权限ID列表不能为空"); + } + + // 验证所有权限是否存在,并检查租户一致性 + List permissions = permissionMapper.selectBatchIds(permissionIds); + if (permissions.size() != permissionIds.size()) { + throw new BusinessException("部分权限不存在"); + } + + // 验证所有权限都属于同一租户或为公共权限 + boolean allValidTenant = permissions.stream() + .allMatch(permission -> permission.getTenantId() == null || + permission.getTenantId().equals(role.getTenantId())); + if (!allValidTenant) { + throw new BusinessException("部分权限与角色不属于同一租户,无法分配"); + } + + // 过滤掉已存在的关联 + List existingPermissionIds = getPermissionIdsByRoleId(roleId); + List newPermissionIds = permissionIds.stream() + .filter(pid -> !existingPermissionIds.contains(pid)) + .collect(Collectors.toList()); + + if (newPermissionIds.isEmpty()) { + log.warn("所有权限已经分配给角色ID: {}", roleId); + return true; + } + + // 批量创建角色权限关联 + List rolePermissions = newPermissionIds.stream() + .map(permissionId -> { + RolePermission rolePermission = new RolePermission(); + rolePermission.setRoleId(roleId); + rolePermission.setPermissionId(permissionId); + rolePermission.setTenantId(role.getTenantId()); + return rolePermission; + }) + .collect(Collectors.toList()); + + boolean result = saveBatch(rolePermissions); + if (result) { + log.info("成功为角色ID: {} 批量分配 {} 个权限,租户ID: {}", roleId, rolePermissions.size(), role.getTenantId()); + + // 清除相关用户的权限缓存 + permissionCacheService.evictRoleRelatedPermissions(roleId, role.getTenantId()); + log.debug("已清除角色 {} 关联用户的权限缓存", roleId); + + return true; + } + return false; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean removePermissionFromRole(Long roleId, Long permissionId) { + // 获取角色信息以获取租户ID + Role role = roleMapper.selectById(roleId); + + int result = rolePermissionMapper.deleteByRoleIdAndPermissionId(roleId, permissionId); + if (result > 0) { + log.info("成功移除角色ID: {} 的权限ID: {}", roleId, permissionId); + + // 清除相关用户的权限缓存 + if (role != null) { + permissionCacheService.evictRoleRelatedPermissions(roleId, role.getTenantId()); + log.debug("已清除角色 {} 关联用户的权限缓存", roleId); + } + + return true; + } + log.warn("角色ID: {} 没有权限ID: {},无需移除", roleId, permissionId); + return false; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean removeAllPermissionsFromRole(Long roleId) { + // 获取角色信息以获取租户ID + Role role = roleMapper.selectById(roleId); + + int result = rolePermissionMapper.deleteByRoleId(roleId); + log.info("成功移除角色ID: {} 的所有权限,共 {} 条", roleId, result); + + // 清除相关用户的权限缓存 + if (role != null) { + permissionCacheService.evictRoleRelatedPermissions(roleId, role.getTenantId()); + log.debug("已清除角色 {} 关联用户的权限缓存", roleId); + } + + return true; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean removeAllRolesFromPermission(Long permissionId) { + int result = rolePermissionMapper.deleteByPermissionId(permissionId); + log.info("成功移除权限ID: {} 的所有角色关联,共 {} 条", permissionId, result); + return true; + } + + @Override + public List getPermissionIdsByRoleId(Long roleId) { + return rolePermissionMapper.selectPermissionIdsByRoleId(roleId); + } + + @Override + public List getPermissionsByRoleId(Long roleId) { + List permissionIds = getPermissionIdsByRoleId(roleId); + if (permissionIds.isEmpty()) { + return List.of(); + } + + return permissionMapper.selectBatchIds(permissionIds); + } + + @Override + public List getRoleIdsByPermissionId(Long permissionId) { + return rolePermissionMapper.selectRoleIdsByPermissionId(permissionId); + } + + @Override + public boolean hasPermission(Long roleId, Long permissionId) { + int count = rolePermissionMapper.existsByRoleIdAndPermissionId(roleId, permissionId); + return count > 0; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean updateRolePermissions(Long roleId, List permissionIds) { + // 验证角色是否存在 + if (roleMapper.selectById(roleId) == null) { + throw new BusinessException("角色不存在"); + } + + // 先删除角色的所有权限 + removeAllPermissionsFromRole(roleId); + + // 如果新权限列表为空,则只删除不添加 + if (permissionIds == null || permissionIds.isEmpty()) { + log.info("角色ID: {} 的权限已清空", roleId); + return true; + } + + // 添加新的权限 + boolean result = assignPermissionsToRole(roleId, permissionIds); + if (result) { + log.info("成功更新角色ID: {} 的权限,共 {} 个", roleId, permissionIds.size()); + } + return result; + } +} diff --git a/src/main/java/com/kurihada/auth/service/impl/RoleServiceImpl.java b/src/main/java/com/kurihada/auth/service/impl/RoleServiceImpl.java new file mode 100644 index 0000000..7357e03 --- /dev/null +++ b/src/main/java/com/kurihada/auth/service/impl/RoleServiceImpl.java @@ -0,0 +1,398 @@ +package com.kurihada.auth.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.kurihada.auth.entity.AuditLog; +import com.kurihada.auth.context.TenantContext; +import com.kurihada.auth.dto.CreateRoleRequest; +import com.kurihada.auth.dto.PageRequest; +import com.kurihada.auth.dto.PageResult; +import com.kurihada.auth.dto.RoleDTO; +import com.kurihada.auth.dto.UpdateRoleRequest; +import com.kurihada.auth.entity.Role; +import com.kurihada.auth.entity.RolePermission; +import com.kurihada.auth.entity.User; +import com.kurihada.auth.exception.BusinessException; +import com.kurihada.auth.mapper.RoleMapper; +import com.kurihada.auth.mapper.RolePermissionMapper; +import com.kurihada.auth.mapper.UserMapper; +import com.kurihada.auth.mapper.UserRoleMapper; +import com.kurihada.auth.service.AuditLogService; +import com.kurihada.auth.service.PermissionCacheService; +import com.kurihada.auth.service.RoleService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 角色服务实现类 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RoleServiceImpl implements RoleService { + + private final RoleMapper roleMapper; + private final RolePermissionMapper rolePermissionMapper; + private final UserRoleMapper userRoleMapper; + private final UserMapper userMapper; + private final PermissionCacheService permissionCacheService; + private final AuditLogService auditLogService; + + // 系统内置角色,不允许删除 + private static final Set SYSTEM_ROLES = new HashSet<>(Arrays.asList("ADMIN", "SUPER_ADMIN")); + + @Override + public PageResult getRolesPage(PageRequest pageRequest) { + Long tenantId = TenantContext.getTenantId(); + + // 构建查询条件 + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(tenantId != null, Role::getTenantId, tenantId); + + // 搜索条件 + if (StringUtils.hasText(pageRequest.getKeyword())) { + queryWrapper.and(wrapper -> wrapper + .like(Role::getName, pageRequest.getKeyword()) + .or() + .like(Role::getCode, pageRequest.getKeyword()) + .or() + .like(Role::getDescription, pageRequest.getKeyword()) + ); + } + + // 排序 + if (StringUtils.hasText(pageRequest.getSortBy())) { + boolean isAsc = "asc".equalsIgnoreCase(pageRequest.getSortDirection()); + switch (pageRequest.getSortBy()) { + case "name" -> queryWrapper.orderBy(true, isAsc, Role::getName); + case "code" -> queryWrapper.orderBy(true, isAsc, Role::getCode); + case "status" -> queryWrapper.orderBy(true, isAsc, Role::getStatus); + case "createTime" -> queryWrapper.orderBy(true, isAsc, Role::getCreateTime); + default -> queryWrapper.orderByDesc(Role::getCreateTime); + } + } else { + queryWrapper.orderByDesc(Role::getCreateTime); + } + + // 查询总数 + Long total = roleMapper.selectCount(queryWrapper); + + // 分页查询 - 使用 MyBatis Plus 的安全分页方法 + com.baomidou.mybatisplus.extension.plugins.pagination.Page page = + new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>( + pageRequest.getPage(), + pageRequest.getSize() + ); + page.setSearchCount(false); // 已经查询过总数,避免重复查询 + + com.baomidou.mybatisplus.extension.plugins.pagination.Page result = + roleMapper.selectPage(page, queryWrapper); + List roles = result.getRecords(); + + List roleDTOs = roles.stream() + .map(this::convertToDTO) + .collect(Collectors.toList()); + + return PageResult.of(roleDTOs, total, pageRequest.getPage(), pageRequest.getSize()); + } + + @Override + public RoleDTO getRoleById(Long id) { + Role role = roleMapper.selectById(id); + if (role == null) { + throw new BusinessException("角色不存在"); + } + + // 验证租户权限 + Long tenantId = TenantContext.getTenantId(); + if (tenantId != null && !tenantId.equals(role.getTenantId())) { + throw new BusinessException("无权访问该角色"); + } + + return convertToDTO(role); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public RoleDTO createRole(CreateRoleRequest request) { + Long tenantId = TenantContext.getTenantId(); + User currentUser = getCurrentUser(); + + // 验证角色代码格式(大写字母和下划线) + if (!request.getCode().matches("^[A-Z_]+$")) { + auditLogRole("CREATE_ROLE", null, "FAILURE", "角色代码格式不正确", request, currentUser, tenantId); + throw new BusinessException("角色代码只能包含大写字母和下划线"); + } + + // 检查角色代码是否已存在 + Long count = roleMapper.selectCount(new LambdaQueryWrapper() + .eq(Role::getCode, request.getCode()) + .eq(tenantId != null, Role::getTenantId, tenantId)); + + if (count > 0) { + auditLogRole("CREATE_ROLE", null, "FAILURE", "角色代码已存在", request, currentUser, tenantId); + throw new BusinessException("角色代码已存在"); + } + + // 创建角色 + Role role = new Role(); + role.setName(request.getName()); + role.setCode(request.getCode()); + role.setDescription(request.getDescription()); + role.setTenantId(tenantId); + role.setStatus(1); + + roleMapper.insert(role); + log.info("角色创建成功: {}, ID: {}", role.getName(), role.getId()); + + // 记录审计日志 + auditLogRole("CREATE_ROLE", role.getId(), "SUCCESS", null, role, currentUser, tenantId); + + return convertToDTO(role); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public RoleDTO updateRole(Long id, UpdateRoleRequest request) { + Role role = roleMapper.selectById(id); + if (role == null) { + throw new BusinessException("角色不存在"); + } + + Long tenantId = TenantContext.getTenantId(); + User currentUser = getCurrentUser(); + + // 验证租户权限 + if (tenantId != null && !tenantId.equals(role.getTenantId())) { + auditLogRole("UPDATE_ROLE", id, "FAILURE", "无权修改该角色", request, currentUser, tenantId); + throw new BusinessException("无权修改该角色"); + } + + String oldName = role.getName(); + String oldDescription = role.getDescription(); + + // 更新角色信息 + role.setName(request.getName()); + role.setDescription(request.getDescription()); + + roleMapper.updateById(role); + log.info("角色更新成功: {}, ID: {}", role.getName(), role.getId()); + + // 清理相关用户的权限缓存 + permissionCacheService.evictRoleRelatedPermissions(id, tenantId); + + // 记录审计日志 + String details = String.format("更新前: name=%s, description=%s; 更新后: name=%s, description=%s", + oldName, oldDescription, role.getName(), role.getDescription()); + auditLogRole("UPDATE_ROLE", id, "SUCCESS", details, role, currentUser, tenantId); + + return convertToDTO(role); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteRole(Long id) { + Role role = roleMapper.selectById(id); + if (role == null) { + throw new BusinessException("角色不存在"); + } + + Long tenantId = TenantContext.getTenantId(); + User currentUser = getCurrentUser(); + + // 验证租户权限 + if (tenantId != null && !tenantId.equals(role.getTenantId())) { + auditLogRole("DELETE_ROLE", id, "FAILURE", "无权删除该角色", role, currentUser, tenantId); + throw new BusinessException("无权删除该角色"); + } + + // 防止删除系统内置角色 + if (SYSTEM_ROLES.contains(role.getCode())) { + auditLogRole("DELETE_ROLE", id, "FAILURE", "不能删除系统内置角色", role, currentUser, tenantId); + throw new BusinessException("不能删除系统内置角色: " + role.getCode()); + } + + // 检查是否有用户正在使用该角色 + int userCount = userRoleMapper.countUsersByRoleId(id); + if (userCount > 0) { + String errorMsg = String.format("该角色正被 %d 个用户使用,无法删除", userCount); + auditLogRole("DELETE_ROLE", id, "FAILURE", errorMsg, role, currentUser, tenantId); + throw new BusinessException(errorMsg); + } + + // 删除角色和权限的关联 + rolePermissionMapper.delete(new LambdaQueryWrapper() + .eq(RolePermission::getRoleId, id)); + + // 删除角色 + roleMapper.deleteById(id); + log.info("角色删除成功: {}, ID: {}", role.getName(), role.getId()); + + // 清理相关用户的权限缓存 + permissionCacheService.evictRoleRelatedPermissions(id, tenantId); + + // 记录审计日志 + auditLogRole("DELETE_ROLE", id, "SUCCESS", null, role, currentUser, tenantId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void toggleRoleStatus(Long id) { + Role role = roleMapper.selectById(id); + if (role == null) { + throw new BusinessException("角色不存在"); + } + + Long tenantId = TenantContext.getTenantId(); + User currentUser = getCurrentUser(); + + // 验证租户权限 + if (tenantId != null && !tenantId.equals(role.getTenantId())) { + auditLogRole("TOGGLE_ROLE_STATUS", id, "FAILURE", "无权修改该角色", role, currentUser, tenantId); + throw new BusinessException("无权修改该角色"); + } + + // 防止禁用系统内置角色 + if (SYSTEM_ROLES.contains(role.getCode()) && role.getStatus() == 1) { + auditLogRole("TOGGLE_ROLE_STATUS", id, "FAILURE", "不能禁用系统内置角色", role, currentUser, tenantId); + throw new BusinessException("不能禁用系统内置角色: " + role.getCode()); + } + + int oldStatus = role.getStatus(); + int newStatus = oldStatus == 1 ? 0 : 1; + role.setStatus(newStatus); + roleMapper.updateById(role); + log.info("角色状态已更新: {}, 新状态: {}", role.getName(), role.getStatus()); + + // 清理相关用户的权限缓存 + permissionCacheService.evictRoleRelatedPermissions(id, tenantId); + + // 记录审计日志 + String details = String.format("状态从 %s 变更为 %s", + oldStatus == 1 ? "启用" : "禁用", + newStatus == 1 ? "启用" : "禁用"); + auditLogRole("TOGGLE_ROLE_STATUS", id, "SUCCESS", details, role, currentUser, tenantId); + } + + /** + * 转换为DTO + */ + private RoleDTO convertToDTO(Role role) { + // 获取角色的权限 + List rolePermissions = rolePermissionMapper.selectList( + new LambdaQueryWrapper() + .eq(RolePermission::getRoleId, role.getId()) + ); + + Set permissionCodes = rolePermissions.stream() + .map(rp -> String.valueOf(rp.getPermissionId())) + .collect(Collectors.toSet()); + + return RoleDTO.builder() + .id(role.getId()) + .name(role.getName()) + .code(role.getCode()) + .tenantId(role.getTenantId()) + .description(role.getDescription()) + .status(role.getStatus()) + .createTime(role.getCreateTime()) + .updateTime(role.getUpdateTime()) + .permissions(permissionCodes) + .build(); + } + + /** + * 获取当前登录用户 + */ + private User getCurrentUser() { + try { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.isAuthenticated()) { + String username = authentication.getName(); + if (!"anonymousUser".equals(username)) { + return userMapper.selectUserWithRolesAndPermissions(username); + } + } + } catch (Exception e) { + log.warn("获取当前用户信息失败: {}", e.getMessage()); + } + return null; + } + + /** + * 获取客户端IP地址 + */ + private String getClientIp() { + try { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes != null) { + HttpServletRequest request = attributes.getRequest(); + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("X-Real-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + return ip; + } + } catch (Exception e) { + log.warn("获取客户端IP失败: {}", e.getMessage()); + } + return null; + } + + /** + * 获取User Agent + */ + private String getUserAgent() { + try { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes != null) { + return attributes.getRequest().getHeader("User-Agent"); + } + } catch (Exception e) { + log.warn("获取User-Agent失败: {}", e.getMessage()); + } + return null; + } + + /** + * 记录角色操作审计日志 + */ + private void auditLogRole(String actionType, Long roleId, String result, + String errorMessage, Object details, User currentUser, Long tenantId) { + try { + AuditLog auditLog = AuditLog.builder() + .actionType(actionType) + .userId(currentUser != null ? currentUser.getId() : null) + .username(currentUser != null ? currentUser.getUsername() : "system") + .tenantId(tenantId != null ? tenantId : (currentUser != null ? currentUser.getTenantId() : null)) + .resourceType("ROLE") + .resourceId(roleId != null ? String.valueOf(roleId) : null) + .details(details != null ? details.toString() : null) + .result(result) + .errorMessage(errorMessage) + .ipAddress(getClientIp()) + .userAgent(getUserAgent()) + .build(); + + auditLogService.log(auditLog); + } catch (Exception e) { + log.error("记录审计日志失败: {}", e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/kurihada/auth/service/impl/UserRoleServiceImpl.java b/src/main/java/com/kurihada/auth/service/impl/UserRoleServiceImpl.java new file mode 100644 index 0000000..e2304eb --- /dev/null +++ b/src/main/java/com/kurihada/auth/service/impl/UserRoleServiceImpl.java @@ -0,0 +1,318 @@ +package com.kurihada.auth.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.kurihada.auth.entity.Role; +import com.kurihada.auth.entity.User; +import com.kurihada.auth.entity.UserRole; +import com.kurihada.auth.exception.BusinessException; +import com.kurihada.auth.mapper.RoleMapper; +import com.kurihada.auth.mapper.UserMapper; +import com.kurihada.auth.mapper.UserRoleMapper; +import com.kurihada.auth.service.UserRoleService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 用户角色关联服务实现类 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UserRoleServiceImpl extends ServiceImpl implements UserRoleService { + + private final UserRoleMapper userRoleMapper; + private final UserMapper userMapper; + private final RoleMapper roleMapper; + private final com.kurihada.auth.service.PermissionCacheService permissionCacheService; + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean assignRoleToUser(Long userId, Long roleId) { + // 验证用户是否存在 + User user = userMapper.selectById(userId); + if (user == null) { + throw new BusinessException("用户不存在"); + } + + // 验证角色是否存在 + Role role = roleMapper.selectById(roleId); + if (role == null) { + throw new BusinessException("角色不存在"); + } + + // 验证租户一致性 + if (!user.getTenantId().equals(role.getTenantId())) { + throw new BusinessException("用户和角色不属于同一租户,无法分配"); + } + + // 检查是否已经存在该关联 + if (hasRole(userId, roleId)) { + log.warn("用户ID: {} 已经拥有角色ID: {}", userId, roleId); + return true; + } + + // 创建用户角色关联,包含租户ID + UserRole userRole = new UserRole(); + userRole.setUserId(userId); + userRole.setRoleId(roleId); + userRole.setTenantId(user.getTenantId()); + + int result = userRoleMapper.insert(userRole); + if (result > 0) { + log.info("成功为用户ID: {} 分配角色ID: {},租户ID: {}", userId, roleId, user.getTenantId()); + + // 清除用户权限缓存 + permissionCacheService.evictUserPermissions(userId, user.getTenantId()); + log.debug("已清除用户 {} 的权限缓存", userId); + + return true; + } + return false; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean assignRolesToUser(Long userId, List roleIds) { + // 验证用户是否存在 + User user = userMapper.selectById(userId); + if (user == null) { + throw new BusinessException("用户不存在"); + } + + if (roleIds == null || roleIds.isEmpty()) { + throw new BusinessException("角色ID列表不能为空"); + } + + // 验证所有角色是否存在,并检查租户一致性 + List roles = roleMapper.selectBatchIds(roleIds); + if (roles.size() != roleIds.size()) { + throw new BusinessException("部分角色不存在"); + } + + // 验证所有角色都属于同一租户 + boolean allSameTenant = roles.stream() + .allMatch(role -> role.getTenantId().equals(user.getTenantId())); + if (!allSameTenant) { + throw new BusinessException("部分角色与用户不属于同一租户,无法分配"); + } + + // 过滤掉已存在的关联 + List existingRoleIds = getRoleIdsByUserId(userId); + List newRoleIds = roleIds.stream() + .filter(rid -> !existingRoleIds.contains(rid)) + .collect(Collectors.toList()); + + if (newRoleIds.isEmpty()) { + log.warn("所有角色已经分配给用户ID: {}", userId); + return true; + } + + // 批量创建用户角色关联 + List userRoles = newRoleIds.stream() + .map(roleId -> { + UserRole userRole = new UserRole(); + userRole.setUserId(userId); + userRole.setRoleId(roleId); + userRole.setTenantId(user.getTenantId()); + return userRole; + }) + .collect(Collectors.toList()); + + boolean result = saveBatch(userRoles); + if (result) { + log.info("成功为用户ID: {} 批量分配 {} 个角色,租户ID: {}", userId, userRoles.size(), user.getTenantId()); + + // 清除用户权限缓存 + permissionCacheService.evictUserPermissions(userId, user.getTenantId()); + log.debug("已清除用户 {} 的权限缓存", userId); + + return true; + } + return false; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean assignUsersToRole(Long roleId, List userIds) { + // 验证角色是否存在 + if (roleMapper.selectById(roleId) == null) { + throw new BusinessException("角色不存在"); + } + + if (userIds == null || userIds.isEmpty()) { + throw new BusinessException("用户ID列表不能为空"); + } + + // 验证所有用户是否存在 + long existingCount = userMapper.selectCount( + new LambdaQueryWrapper() + .in(User::getId, userIds) + ); + if (existingCount != userIds.size()) { + throw new BusinessException("部分用户不存在"); + } + + // 过滤掉已存在的关联 + List existingUserIds = getUserIdsByRoleId(roleId); + List newUserIds = userIds.stream() + .filter(uid -> !existingUserIds.contains(uid)) + .collect(Collectors.toList()); + + if (newUserIds.isEmpty()) { + log.warn("所有用户已经拥有角色ID: {}", roleId); + return true; + } + + int result = userRoleMapper.batchInsertUsers(roleId, newUserIds); + if (result > 0) { + log.info("成功为角色ID: {} 批量分配 {} 个用户", roleId, result); + return true; + } + return false; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean removeRoleFromUser(Long userId, Long roleId) { + // 获取用户信息以获取租户ID + User user = userMapper.selectById(userId); + + int result = userRoleMapper.deleteByUserIdAndRoleId(userId, roleId); + if (result > 0) { + log.info("成功移除用户ID: {} 的角色ID: {}", userId, roleId); + + // 清除用户权限缓存 + if (user != null) { + permissionCacheService.evictUserPermissions(userId, user.getTenantId()); + log.debug("已清除用户 {} 的权限缓存", userId); + } + + return true; + } + log.warn("用户ID: {} 没有角色ID: {},无需移除", userId, roleId); + return false; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean removeAllRolesFromUser(Long userId) { + // 获取用户信息以获取租户ID + User user = userMapper.selectById(userId); + + int result = userRoleMapper.deleteByUserId(userId); + log.info("成功移除用户ID: {} 的所有角色,共 {} 条", userId, result); + + // 清除用户权限缓存 + if (user != null) { + permissionCacheService.evictUserPermissions(userId, user.getTenantId()); + log.debug("已清除用户 {} 的权限缓存", userId); + } + + return true; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean removeAllUsersFromRole(Long roleId) { + // 获取角色信息以获取租户ID + Role role = roleMapper.selectById(roleId); + + int result = userRoleMapper.deleteByRoleId(roleId); + log.info("成功移除角色ID: {} 的所有用户关联,共 {} 条", roleId, result); + + // 清除该角色关联的所有用户的权限缓存 + if (role != null) { + permissionCacheService.evictRoleRelatedPermissions(roleId, role.getTenantId()); + log.debug("已清除角色 {} 关联用户的权限缓存", roleId); + } + + return true; + } + + @Override + public List getRoleIdsByUserId(Long userId) { + return userRoleMapper.selectRoleIdsByUserId(userId); + } + + @Override + public List getRolesByUserId(Long userId) { + List roleIds = getRoleIdsByUserId(userId); + if (roleIds.isEmpty()) { + return List.of(); + } + + return roleMapper.selectBatchIds(roleIds); + } + + @Override + public List getUserIdsByRoleId(Long roleId) { + return userRoleMapper.selectUserIdsByRoleId(roleId); + } + + @Override + public List getUsersByRoleId(Long roleId) { + List userIds = getUserIdsByRoleId(roleId); + if (userIds.isEmpty()) { + return List.of(); + } + + return userMapper.selectBatchIds(userIds); + } + + @Override + public boolean hasRole(Long userId, Long roleId) { + int count = userRoleMapper.existsByUserIdAndRoleId(userId, roleId); + return count > 0; + } + + @Override + public boolean hasRoleByCode(Long userId, String roleCode) { + // 获取用户的所有角色 + List roles = getRolesByUserId(userId); + + // 检查是否有匹配的角色代码 + return roles.stream() + .anyMatch(role -> role.getCode().equals(roleCode)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean updateUserRoles(Long userId, List roleIds) { + // 验证用户是否存在 + if (userMapper.selectById(userId) == null) { + throw new BusinessException("用户不存在"); + } + + // 先删除用户的所有角色 + removeAllRolesFromUser(userId); + + // 如果新角色列表为空,则只删除不添加 + if (roleIds == null || roleIds.isEmpty()) { + log.info("用户ID: {} 的角色已清空", userId); + return true; + } + + // 添加新的角色 + boolean result = assignRolesToUser(userId, roleIds); + if (result) { + log.info("成功更新用户ID: {} 的角色,共 {} 个", userId, roleIds.size()); + } + return result; + } + + @Override + public int countUsersByRoleId(Long roleId) { + return userRoleMapper.countUsersByRoleId(roleId); + } + + @Override + public int countRolesByUserId(Long userId) { + return userRoleMapper.countRolesByUserId(userId); + } +} diff --git a/src/main/java/com/kurihada/auth/util/IpUtil.java b/src/main/java/com/kurihada/auth/util/IpUtil.java new file mode 100644 index 0000000..ca1d9b9 --- /dev/null +++ b/src/main/java/com/kurihada/auth/util/IpUtil.java @@ -0,0 +1,160 @@ +package com.kurihada.auth.util; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.util.StringUtils; + +/** + * IP 工具类 + */ +public class IpUtil { + + private static final String UNKNOWN = "unknown"; + private static final String LOCALHOST_IPV4 = "127.0.0.1"; + private static final String LOCALHOST_IPV6 = "0:0:0:0:0:0:0:1"; + + /** + * 获取客户端真实 IP 地址 + * 支持通过代理、负载均衡等场景 + */ + public static String getIpAddress(HttpServletRequest request) { + if (request == null) { + return UNKNOWN; + } + + String ip = null; + + // 1. X-Forwarded-For: 通过代理时的原始IP + ip = request.getHeader("X-Forwarded-For"); + if (isValidIp(ip)) { + // 多次代理时,会有多个IP,取第一个非unknown的有效IP + int index = ip.indexOf(','); + if (index != -1) { + ip = ip.substring(0, index); + } + return ip.trim(); + } + + // 2. X-Real-IP: Nginx 代理设置的真实IP + ip = request.getHeader("X-Real-IP"); + if (isValidIp(ip)) { + return ip.trim(); + } + + // 3. Proxy-Client-IP: Apache 代理设置 + ip = request.getHeader("Proxy-Client-IP"); + if (isValidIp(ip)) { + return ip.trim(); + } + + // 4. WL-Proxy-Client-IP: WebLogic 代理设置 + ip = request.getHeader("WL-Proxy-Client-IP"); + if (isValidIp(ip)) { + return ip.trim(); + } + + // 5. HTTP_CLIENT_IP + ip = request.getHeader("HTTP_CLIENT_IP"); + if (isValidIp(ip)) { + return ip.trim(); + } + + // 6. HTTP_X_FORWARDED_FOR + ip = request.getHeader("HTTP_X_FORWARDED_FOR"); + if (isValidIp(ip)) { + return ip.trim(); + } + + // 7. 最后使用 request.getRemoteAddr() + ip = request.getRemoteAddr(); + + // 将 IPv6 的 localhost 转换为 IPv4 + if (LOCALHOST_IPV6.equals(ip)) { + ip = LOCALHOST_IPV4; + } + + return StringUtils.hasText(ip) ? ip.trim() : UNKNOWN; + } + + /** + * 验证 IP 是否有效 + */ + private static boolean isValidIp(String ip) { + return StringUtils.hasText(ip) && + !UNKNOWN.equalsIgnoreCase(ip); + } + + /** + * 判断是否为内网IP + */ + public static boolean isInternalIp(String ip) { + if (!StringUtils.hasText(ip)) { + return false; + } + + if (LOCALHOST_IPV4.equals(ip) || LOCALHOST_IPV6.equals(ip)) { + return true; + } + + // 私有地址范围: + // 10.0.0.0 - 10.255.255.255 + // 172.16.0.0 - 172.31.255.255 + // 192.168.0.0 - 192.168.255.255 + byte[] addr = textToNumericFormatV4(ip); + if (addr == null) { + return false; + } + + final byte b0 = addr[0]; + final byte b1 = addr[1]; + + // 10.x.x.x + final byte SECTION_1 = 0x0A; + // 172.16.x.x - 172.31.x.x + final byte SECTION_2 = (byte) 0xAC; + final byte SECTION_3 = (byte) 0x10; + final byte SECTION_4 = (byte) 0x1F; + // 192.168.x.x + final byte SECTION_5 = (byte) 0xC0; + final byte SECTION_6 = (byte) 0xA8; + + switch (b0) { + case SECTION_1: + return true; + case SECTION_2: + return b1 >= SECTION_3 && b1 <= SECTION_4; + case SECTION_5: + return b1 == SECTION_6; + default: + return false; + } + } + + /** + * 将 IPv4 地址字符串转换为字节数组 + */ + private static byte[] textToNumericFormatV4(String text) { + if (text == null || text.isEmpty()) { + return null; + } + + String[] parts = text.split("\\."); + if (parts.length != 4) { + return null; + } + + byte[] bytes = new byte[4]; + try { + for (int i = 0; i < 4; i++) { + int value = Integer.parseInt(parts[i]); + if (value < 0 || value > 255) { + return null; + } + bytes[i] = (byte) value; + } + } catch (NumberFormatException e) { + return null; + } + + return bytes; + } +} diff --git a/src/main/java/com/kurihada/auth/util/JwtUtil.java b/src/main/java/com/kurihada/auth/util/JwtUtil.java new file mode 100644 index 0000000..cd341b1 --- /dev/null +++ b/src/main/java/com/kurihada/auth/util/JwtUtil.java @@ -0,0 +1,155 @@ +package com.kurihada.auth.util; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +/** + * JWT工具类 + */ +@Component +public class JwtUtil { + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.expiration}") + private Long expiration; + + /** + * 获取签名密钥 + */ + private SecretKey getSigningKey() { + byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); + return Keys.hmacShaKeyFor(keyBytes); + } + + /** + * 从token中获取用户名 + */ + public String getUsernameFromToken(String token) { + return getClaimFromToken(token, Claims::getSubject); + } + + /** + * 从token中获取租户ID + */ + public Long getTenantIdFromToken(String token) { + Claims claims = getAllClaimsFromToken(token); + Object tenantId = claims.get("tenantId"); + if (tenantId instanceof Integer) { + return ((Integer) tenantId).longValue(); + } else if (tenantId instanceof Long) { + return (Long) tenantId; + } + return null; + } + + /** + * 从token中获取用户ID + */ + public Long getUserIdFromToken(String token) { + Claims claims = getAllClaimsFromToken(token); + Object userId = claims.get("userId"); + if (userId instanceof Integer) { + return ((Integer) userId).longValue(); + } else if (userId instanceof Long) { + return (Long) userId; + } + return null; + } + + /** + * 从token中获取过期时间 + */ + public Date getExpirationDateFromToken(String token) { + return getClaimFromToken(token, Claims::getExpiration); + } + + /** + * 从token中获取Claims + */ + public T getClaimFromToken(String token, Function claimsResolver) { + final Claims claims = getAllClaimsFromToken(token); + return claimsResolver.apply(claims); + } + + /** + * 从token中获取所有Claims + */ + private Claims getAllClaimsFromToken(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + /** + * 检查token是否过期 + */ + private Boolean isTokenExpired(String token) { + final Date expiration = getExpirationDateFromToken(token); + return expiration.before(new Date()); + } + + /** + * 生成token + */ + public String generateToken(UserDetails userDetails) { + Map claims = new HashMap<>(); + return doGenerateToken(claims, userDetails.getUsername()); + } + + /** + * 生成token(带额外信息) + */ + public String generateToken(String username, Map claims) { + return doGenerateToken(claims, username); + } + + /** + * 执行生成token + */ + private String doGenerateToken(Map claims, String subject) { + final Date createdDate = new Date(); + final Date expirationDate = new Date(createdDate.getTime() + expiration); + + return Jwts.builder() + .claims(claims) + .subject(subject) + .issuedAt(createdDate) + .expiration(expirationDate) + .signWith(getSigningKey()) + .compact(); + } + + /** + * 验证token + */ + public Boolean validateToken(String token, UserDetails userDetails) { + final String username = getUsernameFromToken(token); + return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); + } + + /** + * 验证token是否有效 + */ + public Boolean validateToken(String token) { + try { + return !isTokenExpired(token); + } catch (Exception e) { + return false; + } + } +} diff --git a/src/main/java/com/kurihada/auth/util/PasswordStrengthUtil.java b/src/main/java/com/kurihada/auth/util/PasswordStrengthUtil.java new file mode 100644 index 0000000..e23c3b4 --- /dev/null +++ b/src/main/java/com/kurihada/auth/util/PasswordStrengthUtil.java @@ -0,0 +1,179 @@ +package com.kurihada.auth.util; + +import java.util.regex.Pattern; + +/** + * 密码强度评估工具 + */ +public class PasswordStrengthUtil { + + /** + * 密码强度等级 + */ + public enum PasswordStrength { + VERY_WEAK(0, "非常弱"), + WEAK(1, "弱"), + MEDIUM(2, "中等"), + STRONG(3, "强"), + VERY_STRONG(4, "非常强"); + + private final int level; + private final String description; + + PasswordStrength(int level, String description) { + this.level = level; + this.description = description; + } + + public int getLevel() { + return level; + } + + public String getDescription() { + return description; + } + } + + /** + * 评估密码强度 + * + * @param password 密码 + * @return 密码强度等级 + */ + public static PasswordStrength assessPasswordStrength(String password) { + if (password == null || password.isEmpty()) { + return PasswordStrength.VERY_WEAK; + } + + int score = 0; + + // 1. 长度评分 + if (password.length() >= 8) score += 1; + if (password.length() >= 12) score += 1; + if (password.length() >= 16) score += 1; + + // 2. 字符类型评分 + if (Pattern.compile("[a-z]").matcher(password).find()) score += 1; // 小写字母 + if (Pattern.compile("[A-Z]").matcher(password).find()) score += 1; // 大写字母 + if (Pattern.compile("[0-9]").matcher(password).find()) score += 1; // 数字 + if (Pattern.compile("[!@#$%^&*()_+\\-=\\[\\]{}|;:,.<>?]").matcher(password).find()) score += 1; // 特殊字符 + + // 3. 字符多样性评分 + long uniqueChars = password.chars().distinct().count(); + if (uniqueChars >= password.length() * 0.6) score += 1; // 60%以上字符不重复 + + // 4. 扣分项 + if (hasSequentialChars(password, 3)) score -= 1; // 连续字符 + if (hasRepeatingChars(password, 3)) score -= 1; // 重复字符 + + // 根据分数返回强度等级 + if (score >= 8) return PasswordStrength.VERY_STRONG; + if (score >= 6) return PasswordStrength.STRONG; + if (score >= 4) return PasswordStrength.MEDIUM; + if (score >= 2) return PasswordStrength.WEAK; + return PasswordStrength.VERY_WEAK; + } + + /** + * 检查是否包含连续字符 + */ + private static boolean hasSequentialChars(String password, int length) { + if (password.length() < length) { + return false; + } + + for (int i = 0; i <= password.length() - length; i++) { + boolean isSequential = true; + char firstChar = password.charAt(i); + + for (int j = 1; j < length; j++) { + if (password.charAt(i + j) != firstChar + j) { + isSequential = false; + break; + } + } + if (isSequential) { + return true; + } + } + return false; + } + + /** + * 检查是否包含重复字符 + */ + private static boolean hasRepeatingChars(String password, int length) { + if (password.length() < length) { + return false; + } + + for (int i = 0; i <= password.length() - length; i++) { + char firstChar = password.charAt(i); + boolean isRepeating = true; + + for (int j = 1; j < length; j++) { + if (password.charAt(i + j) != firstChar) { + isRepeating = false; + break; + } + } + if (isRepeating) { + return true; + } + } + return false; + } + + /** + * 生成密码强度建议 + */ + public static String getPasswordSuggestion(String password) { + if (password == null || password.isEmpty()) { + return "请输入密码"; + } + + StringBuilder suggestions = new StringBuilder(); + + if (password.length() < 8) { + suggestions.append("• 密码长度至少8位\n"); + } + + if (!Pattern.compile("[a-z]").matcher(password).find()) { + suggestions.append("• 添加小写字母\n"); + } + + if (!Pattern.compile("[A-Z]").matcher(password).find()) { + suggestions.append("• 添加大写字母\n"); + } + + if (!Pattern.compile("[0-9]").matcher(password).find()) { + suggestions.append("• 添加数字\n"); + } + + if (!Pattern.compile("[!@#$%^&*()_+\\-=\\[\\]{}|;:,.<>?]").matcher(password).find()) { + suggestions.append("• 添加特殊字符\n"); + } + + if (hasSequentialChars(password, 3)) { + suggestions.append("• 避免使用连续字符(如123、abc)\n"); + } + + if (hasRepeatingChars(password, 3)) { + suggestions.append("• 避免使用重复字符(如aaa、111)\n"); + } + + if (suggestions.length() == 0) { + return "密码强度良好"; + } + + return "建议改进:\n" + suggestions.toString(); + } + + /** + * 计算密码强度百分比(0-100) + */ + public static int getPasswordStrengthPercentage(String password) { + PasswordStrength strength = assessPasswordStrength(password); + return strength.getLevel() * 25; // 0, 25, 50, 75, 100 + } +} diff --git a/src/main/java/com/kurihada/auth/util/TenantResolver.java b/src/main/java/com/kurihada/auth/util/TenantResolver.java new file mode 100644 index 0000000..af822d8 --- /dev/null +++ b/src/main/java/com/kurihada/auth/util/TenantResolver.java @@ -0,0 +1,182 @@ +package com.kurihada.auth.util; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.kurihada.auth.entity.Tenant; +import com.kurihada.auth.exception.BusinessException; +import com.kurihada.auth.mapper.TenantMapper; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +/** + * 租户解析器 + * 支持多种方式识别租户:请求参数、请求头、子域名 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class TenantResolver { + + private final TenantMapper tenantMapper; + + private static final String TENANT_HEADER = "X-Tenant-Code"; + private static final Long DEFAULT_TENANT_ID = 1L; + private static final String DEFAULT_TENANT_CODE = "default"; + + /** + * 解析租户ID + * 优先级:tenantId参数 > tenantCode参数 > 请求头 > 子域名 > 默认租户 + * + * @param request HttpServletRequest + * @param tenantId 请求参数中的租户ID + * @param tenantCode 请求参数中的租户代码 + * @return 租户ID + */ + public Long resolveTenantId(HttpServletRequest request, Long tenantId, String tenantCode) { + // 1. 优先使用请求参数中的 tenantId + if (tenantId != null) { + log.debug("从请求参数获取租户ID: {}", tenantId); + return validateTenantId(tenantId); + } + + // 2. 使用请求参数中的 tenantCode + if (StringUtils.hasText(tenantCode)) { + log.debug("从请求参数获取租户代码: {}", tenantCode); + return getTenantIdByCode(tenantCode); + } + + // 3. 从请求头获取租户代码 + String headerTenantCode = request.getHeader(TENANT_HEADER); + if (StringUtils.hasText(headerTenantCode)) { + log.debug("从请求头获取租户代码: {}", headerTenantCode); + return getTenantIdByCode(headerTenantCode); + } + + // 4. 从子域名获取租户代码 + String subdomainTenantCode = extractTenantFromSubdomain(request); + if (StringUtils.hasText(subdomainTenantCode)) { + log.debug("从子域名获取租户代码: {}", subdomainTenantCode); + return getTenantIdByCode(subdomainTenantCode); + } + + // 5. 使用默认租户 + log.debug("未找到租户标识,使用默认租户ID: {}", DEFAULT_TENANT_ID); + return DEFAULT_TENANT_ID; + } + + /** + * 根据租户代码获取租户ID + * + * @param tenantCode 租户代码 + * @return 租户ID + */ + public Long getTenantIdByCode(String tenantCode) { + if (!StringUtils.hasText(tenantCode)) { + return DEFAULT_TENANT_ID; + } + + Tenant tenant = tenantMapper.selectOne( + new LambdaQueryWrapper() + .eq(Tenant::getCode, tenantCode) + .eq(Tenant::getStatus, 1) + .eq(Tenant::getDeleted, 0) + ); + + if (tenant == null) { + log.warn("租户代码不存在或已禁用: {}", tenantCode); + throw new BusinessException("租户不存在或已被禁用"); + } + + // 检查租户是否过期 + if (tenant.getExpireTime() != null && + tenant.getExpireTime().isBefore(java.time.LocalDateTime.now())) { + log.warn("租户已过期: {}, 过期时间: {}", tenantCode, tenant.getExpireTime()); + throw new BusinessException("租户已过期,请联系管理员"); + } + + return tenant.getId(); + } + + /** + * 验证租户ID是否有效 + * + * @param tenantId 租户ID + * @return 租户ID + */ + public Long validateTenantId(Long tenantId) { + Tenant tenant = tenantMapper.selectById(tenantId); + + if (tenant == null) { + log.warn("租户ID不存在: {}", tenantId); + throw new BusinessException("租户不存在"); + } + + if (tenant.getStatus() == 0) { + log.warn("租户已被禁用: {}", tenantId); + throw new BusinessException("租户已被禁用"); + } + + if (tenant.getDeleted() == 1) { + log.warn("租户已被删除: {}", tenantId); + throw new BusinessException("租户不存在"); + } + + // 检查租户是否过期 + if (tenant.getExpireTime() != null && + tenant.getExpireTime().isBefore(java.time.LocalDateTime.now())) { + log.warn("租户已过期: {}, 过期时间: {}", tenantId, tenant.getExpireTime()); + throw new BusinessException("租户已过期,请联系管理员"); + } + + return tenantId; + } + + /** + * 从子域名中提取租户代码 + * 例如:tenant1.example.com -> tenant1 + * + * @param request HttpServletRequest + * @return 租户代码,如果无法提取则返回 null + */ + private String extractTenantFromSubdomain(HttpServletRequest request) { + String serverName = request.getServerName(); + + // localhost 或 IP 地址不提取 + if (serverName == null || serverName.equals("localhost") || + serverName.matches("^\\d+\\.\\d+\\.\\d+\\.\\d+$")) { + return null; + } + + // 分割域名 + String[] parts = serverName.split("\\."); + + // 至少需要 3 部分:subdomain.domain.tld + if (parts.length < 3) { + return null; + } + + // 第一部分作为租户代码(排除 www) + String subdomain = parts[0]; + if ("www".equalsIgnoreCase(subdomain)) { + return null; + } + + return subdomain; + } + + /** + * 获取默认租户ID + */ + public Long getDefaultTenantId() { + return DEFAULT_TENANT_ID; + } + + /** + * 获取默认租户代码 + */ + public String getDefaultTenantCode() { + return DEFAULT_TENANT_CODE; + } +} diff --git a/src/main/java/com/kurihada/auth/validation/PasswordValidator.java b/src/main/java/com/kurihada/auth/validation/PasswordValidator.java new file mode 100644 index 0000000..aa3b1bc --- /dev/null +++ b/src/main/java/com/kurihada/auth/validation/PasswordValidator.java @@ -0,0 +1,228 @@ +package com.kurihada.auth.validation; + +import com.kurihada.auth.annotation.ValidPassword; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import org.springframework.util.StringUtils; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 密码复杂度验证器 + */ +public class PasswordValidator implements ConstraintValidator { + + private int minLength; + private int maxLength; + private boolean requireLowercase; + private boolean requireUppercase; + private boolean requireDigit; + private boolean requireSpecialChar; + private String specialChars; + private boolean forbidCommonPasswords; + + /** + * 常见弱密码列表(部分) + */ + private static final Set COMMON_PASSWORDS = new HashSet<>(Arrays.asList( + "password", "password123", "123456", "12345678", "123456789", + "qwerty", "abc123", "password1", "admin", "admin123", + "letmein", "welcome", "monkey", "1234567890", "123123", + "111111", "666666", "888888", "password!", "Aa123456", + "123qwe", "qwe123", "admin888", "root", "admin888" + )); + + @Override + public void initialize(ValidPassword constraintAnnotation) { + this.minLength = constraintAnnotation.minLength(); + this.maxLength = constraintAnnotation.maxLength(); + this.requireLowercase = constraintAnnotation.requireLowercase(); + this.requireUppercase = constraintAnnotation.requireUppercase(); + this.requireDigit = constraintAnnotation.requireDigit(); + this.requireSpecialChar = constraintAnnotation.requireSpecialChar(); + this.specialChars = constraintAnnotation.specialChars(); + this.forbidCommonPasswords = constraintAnnotation.forbidCommonPasswords(); + } + + @Override + public boolean isValid(String password, ConstraintValidatorContext context) { + if (!StringUtils.hasText(password)) { + setCustomMessage(context, "密码不能为空"); + return false; + } + + // 1. 检查长度 + if (password.length() < minLength) { + setCustomMessage(context, String.format("密码长度不能少于%d位", minLength)); + return false; + } + + if (password.length() > maxLength) { + setCustomMessage(context, String.format("密码长度不能超过%d位", maxLength)); + return false; + } + + // 2. 检查是否包含小写字母 + if (requireLowercase && !containsLowercase(password)) { + setCustomMessage(context, "密码必须包含至少一个小写字母"); + return false; + } + + // 3. 检查是否包含大写字母 + if (requireUppercase && !containsUppercase(password)) { + setCustomMessage(context, "密码必须包含至少一个大写字母"); + return false; + } + + // 4. 检查是否包含数字 + if (requireDigit && !containsDigit(password)) { + setCustomMessage(context, "密码必须包含至少一个数字"); + return false; + } + + // 5. 检查是否包含特殊字符 + if (requireSpecialChar && !containsSpecialChar(password, specialChars)) { + setCustomMessage(context, String.format("密码必须包含至少一个特殊字符(%s)", specialChars)); + return false; + } + + // 6. 检查是否为常见弱密码 + if (forbidCommonPasswords && isCommonPassword(password)) { + setCustomMessage(context, "该密码过于简单,请使用更复杂的密码"); + return false; + } + + // 7. 检查连续字符(如:123456、abcdef) + if (hasSequentialChars(password, 4)) { + setCustomMessage(context, "密码不能包含4个或以上连续字符"); + return false; + } + + // 8. 检查重复字符(如:aaaaaa、111111) + if (hasRepeatingChars(password, 4)) { + setCustomMessage(context, "密码不能包含4个或以上重复字符"); + return false; + } + + return true; + } + + /** + * 设置自定义错误消息 + */ + private void setCustomMessage(ConstraintValidatorContext context, String message) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(message).addConstraintViolation(); + } + + /** + * 检查是否包含小写字母 + */ + private boolean containsLowercase(String password) { + return Pattern.compile("[a-z]").matcher(password).find(); + } + + /** + * 检查是否包含大写字母 + */ + private boolean containsUppercase(String password) { + return Pattern.compile("[A-Z]").matcher(password).find(); + } + + /** + * 检查是否包含数字 + */ + private boolean containsDigit(String password) { + return Pattern.compile("[0-9]").matcher(password).find(); + } + + /** + * 检查是否包含特殊字符 + */ + private boolean containsSpecialChar(String password, String specialChars) { + for (char c : specialChars.toCharArray()) { + if (password.indexOf(c) >= 0) { + return true; + } + } + return false; + } + + /** + * 检查是否为常见弱密码 + */ + private boolean isCommonPassword(String password) { + return COMMON_PASSWORDS.contains(password.toLowerCase()); + } + + /** + * 检查是否包含连续字符 + * 例如:123456、abcdef + */ + private boolean hasSequentialChars(String password, int length) { + if (password.length() < length) { + return false; + } + + for (int i = 0; i <= password.length() - length; i++) { + boolean isSequential = true; + char firstChar = password.charAt(i); + + // 检查递增序列 + for (int j = 1; j < length; j++) { + if (password.charAt(i + j) != firstChar + j) { + isSequential = false; + break; + } + } + if (isSequential) { + return true; + } + + // 检查递减序列 + isSequential = true; + for (int j = 1; j < length; j++) { + if (password.charAt(i + j) != firstChar - j) { + isSequential = false; + break; + } + } + if (isSequential) { + return true; + } + } + + return false; + } + + /** + * 检查是否包含重复字符 + * 例如:aaaaaa、111111 + */ + private boolean hasRepeatingChars(String password, int length) { + if (password.length() < length) { + return false; + } + + for (int i = 0; i <= password.length() - length; i++) { + char firstChar = password.charAt(i); + boolean isRepeating = true; + + for (int j = 1; j < length; j++) { + if (password.charAt(i + j) != firstChar) { + isRepeating = false; + break; + } + } + + if (isRepeating) { + return true; + } + } + + return false; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..50eaefd --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,190 @@ +spring: + application: + name: auth-system + + # 环境配置(默认开发环境) + profiles: + active: dev + + # 数据源配置 + datasource: + url: jdbc:mysql://152.32.168.79:3306/auth?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: auth_user + password: auth_pwd + driver-class-name: com.mysql.cj.jdbc.Driver + # HikariCP 连接池配置 + hikari: + # 连接池名称 + pool-name: AuthSystemHikariCP + # 最小空闲连接数 + minimum-idle: 5 + # 最大连接数 + maximum-pool-size: 20 + # 连接超时时间(毫秒) + connection-timeout: 30000 + # 空闲连接超时时间(毫秒) + idle-timeout: 600000 + # 连接最大存活时间(毫秒) + max-lifetime: 1800000 + # 连接测试查询 + connection-test-query: SELECT 1 + + # Jackson配置 + jackson: + time-zone: GMT+8 + date-format: yyyy-MM-dd HH:mm:ss + + # Redis配置 + data: + redis: + host: 152.32.168.79 + port: 6379 + password: 20001201tds + database: 0 + timeout: 3000ms + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 0 + max-wait: -1ms + + # 邮件配置 + mail: + host: smtp.qq.com + port: 587 + username: kurihada@qq.com + password: objliupbasxvbeig + default-encoding: UTF-8 + properties: + mail: + smtp: + auth: true + starttls: + enable: true + required: true + ssl: + enable: false + socketFactory: + port: 587 + class: javax.net.ssl.SSLSocketFactory + connectiontimeout: 5000 + timeout: 5000 + writetimeout: 5000 + +# 服务器配置 +server: + port: 8001 + +# JWT配置 +jwt: + secret: authSystemSecretKeyForJWTTokenGenerationAndValidation2024 + expiration: 3600000 # Access Token: 1小时(毫秒) + refresh-expiration: 604800000 # Refresh Token: 7天(毫秒) + header: Authorization + prefix: Bearer + +# 限流配置 +rate-limit: + # 登录接口限流 + login: + enabled: true + window: 60 # 时间窗口:60秒 + max-requests: 5 # 最大请求次数:5次 + message: "登录请求过于频繁,请稍后再试" + # 注册接口限流 + register: + enabled: true + window: 300 # 时间窗口:5分钟 + max-requests: 3 # 最大请求次数:3次 + message: "注册请求过于频繁,请稍后再试" + # 刷新Token限流 + refresh: + enabled: true + window: 60 # 时间窗口:60秒 + max-requests: 10 # 最大请求次数:10次 + message: "刷新Token请求过于频繁,请稍后再试" + +# 密码策略配置 +password: + # 最小长度 + min-length: 8 + # 最大长度 + max-length: 32 + # 是否要求包含小写字母 + require-lowercase: true + # 是否要求包含大写字母 + require-uppercase: true + # 是否要求包含数字 + require-digit: true + # 是否要求包含特殊字符 + require-special-char: true + # 允许的特殊字符 + special-chars: "!@#$%^&*()_+-=[]{}|;:,.<>?" + # 是否禁止常见弱密码 + forbid-common-passwords: true + # 是否禁止包含用户名 + forbid-username: true + # 密码重置token过期时间(秒)- 默认1小时 + reset-token-expiration: 3600 + +# 权限缓存配置 +permission-cache: + # 是否启用权限缓存 + enabled: true + # 用户权限缓存过期时间(秒)- 默认2小时 + user-permission-ttl: 7200 + # 角色权限缓存过期时间(秒)- 默认1小时 + role-permission-ttl: 3600 + # 用户信息缓存过期时间(秒)- 默认30分钟 + user-info-ttl: 1800 + +# 用户注册配置 +registration: + # 是否为新用户自动分配默认角色 + auto-assign-default-role: true + # 默认角色代码 + default-role-code: USER + # 是否要求邮箱验证(如果为true,未验证邮箱的用户无法登录) + require-email-verification: true + +# 邮箱验证配置 +email: + # 验证token过期时间(秒)- 默认24小时 + verification-token-expiration: 86400 + +# 账户锁定配置 +account-lock: + # 最大登录失败次数(默认5次) + max-failed-attempts: 5 + # 账户锁定时长(分钟,默认30分钟) + lock-duration-minutes: 30 + +# MyBatis Plus配置 +mybatis-plus: + configuration: + # 开启驼峰命名映射 + map-underscore-to-camel-case: true + # 日志实现 + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + global-config: + db-config: + # 主键类型:AUTO-数据库自增 + id-type: auto + # 逻辑删除字段名 + logic-delete-field: deleted + # 逻辑删除值 + logic-delete-value: 1 + # 逻辑未删除值 + logic-not-delete-value: 0 + # mapper xml文件位置 + mapper-locations: classpath*:/mapper/**/*.xml + # 实体类包路径 + type-aliases-package: com.kurihada.auth.entity + +# 日志配置 +logging: + level: + com.kurihada.auth: debug + org.springframework.security: debug + com.baomidou.mybatisplus: debug diff --git a/src/main/resources/db/migration/init_database.sql b/src/main/resources/db/migration/init_database.sql new file mode 100644 index 0000000..de0e712 --- /dev/null +++ b/src/main/resources/db/migration/init_database.sql @@ -0,0 +1,259 @@ +-- ======================================== +-- 认证系统数据库初始化脚本 +-- 版本: 1.0 +-- 说明: 包含所有表的创建和初始数据 +-- ======================================== + +-- ======================================== +-- 1. 租户表 +-- ======================================== +CREATE TABLE IF NOT EXISTS `tenants` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '租户ID', + `code` VARCHAR(50) NOT NULL COMMENT '租户代码(唯一标识)', + `name` VARCHAR(100) NOT NULL COMMENT '租户名称', + `contact_name` VARCHAR(50) COMMENT '联系人', + `contact_phone` VARCHAR(20) COMMENT '联系电话', + `contact_email` VARCHAR(100) COMMENT '联系邮箱', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态(0:禁用,1:启用)', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除(0:未删除,1:已删除)', + `expire_time` DATETIME COMMENT '过期时间', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_code` (`code`), + KEY `idx_status` (`status`), + KEY `idx_deleted` (`deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户表'; + +-- ======================================== +-- 2. 用户表(整合了账户锁定和邮箱验证功能) +-- ======================================== +CREATE TABLE IF NOT EXISTS `users` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '用户ID', + `username` VARCHAR(50) NOT NULL COMMENT '用户名', + `password` VARCHAR(255) NOT NULL COMMENT '密码(加密后)', + `tenant_id` BIGINT COMMENT '租户ID', + `real_name` VARCHAR(50) COMMENT '真实姓名', + `email` VARCHAR(100) COMMENT '邮箱', + `phone` VARCHAR(20) COMMENT '手机号', + `gender` TINYINT COMMENT '性别(0:女,1:男,2:未知)', + `avatar` VARCHAR(255) COMMENT '头像URL', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态(0:禁用,1:启用)', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除(0:未删除,1:已删除)', + `last_login_time` DATETIME COMMENT '最后登录时间', + -- 账户锁定相关字段 + `login_failed_count` INT DEFAULT 0 COMMENT '登录失败次数', + `account_locked_at` DATETIME NULL COMMENT '账户锁定时间', + `account_unlock_at` DATETIME NULL COMMENT '账户解锁时间', + -- 邮箱验证相关字段 + `email_verified` TINYINT(1) DEFAULT 0 COMMENT '邮箱是否已验证(0:未验证,1:已验证)', + `email_verified_at` DATETIME DEFAULT NULL COMMENT '邮箱验证时间', + -- 时间戳字段 + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_username` (`username`), + UNIQUE KEY `uk_email` (`email`), + KEY `idx_tenant_id` (`tenant_id`), + KEY `idx_status_deleted` (`status`, `deleted`), + KEY `idx_phone` (`phone`), + KEY `idx_create_time` (`create_time`), + KEY `idx_last_login_time` (`last_login_time`), + KEY `idx_account_locked_at` (`account_locked_at`), + KEY `idx_account_unlock_at` (`account_unlock_at`), + KEY `idx_email_verified` (`email_verified`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; + +-- ======================================== +-- 3. 角色表 +-- ======================================== +CREATE TABLE IF NOT EXISTS `roles` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '角色ID', + `name` VARCHAR(50) NOT NULL COMMENT '角色名称', + `code` VARCHAR(50) NOT NULL COMMENT '角色代码', + `tenant_id` BIGINT COMMENT '租户ID', + `description` VARCHAR(255) COMMENT '角色描述', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态(0:禁用,1:启用)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_code_tenant` (`code`, `tenant_id`), + KEY `idx_tenant_id` (`tenant_id`), + KEY `idx_status` (`status`), + KEY `idx_name` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表'; + +-- ======================================== +-- 4. 权限表 +-- ======================================== +CREATE TABLE IF NOT EXISTS `permissions` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '权限ID', + `name` VARCHAR(50) NOT NULL COMMENT '权限名称', + `code` VARCHAR(100) NOT NULL COMMENT '权限代码', + `tenant_id` BIGINT COMMENT '租户ID(null表示公共权限)', + `description` VARCHAR(255) COMMENT '权限描述', + `type` VARCHAR(20) COMMENT '权限类型(menu:菜单,button:按钮,api:接口)', + `resource` VARCHAR(255) COMMENT '资源路径(URL或方法)', + `parent_id` BIGINT COMMENT '父权限ID', + `sort` INT DEFAULT 0 COMMENT '排序', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态(0:禁用,1:启用)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_code_tenant` (`code`, `tenant_id`), + KEY `idx_tenant_id` (`tenant_id`), + KEY `idx_parent_id` (`parent_id`), + KEY `idx_type` (`type`), + KEY `idx_status` (`status`), + KEY `idx_sort` (`sort`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限表'; + +-- ======================================== +-- 5. 用户角色关联表 +-- ======================================== +CREATE TABLE IF NOT EXISTS `user_roles` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT 'ID', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `role_id` BIGINT NOT NULL COMMENT '角色ID', + `tenant_id` BIGINT COMMENT '租户ID', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_role_tenant` (`user_id`, `role_id`, `tenant_id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_role_id` (`role_id`), + KEY `idx_tenant_id` (`tenant_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表'; + +-- ======================================== +-- 6. 角色权限关联表 +-- ======================================== +CREATE TABLE IF NOT EXISTS `role_permissions` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT 'ID', + `role_id` BIGINT NOT NULL COMMENT '角色ID', + `permission_id` BIGINT NOT NULL COMMENT '权限ID', + `tenant_id` BIGINT COMMENT '租户ID', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_role_permission_tenant` (`role_id`, `permission_id`, `tenant_id`), + KEY `idx_role_id` (`role_id`), + KEY `idx_permission_id` (`permission_id`), + KEY `idx_tenant_id` (`tenant_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色权限关联表'; + +-- ======================================== +-- 7. Refresh Token 表 +-- ======================================== +CREATE TABLE IF NOT EXISTS `refresh_tokens` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT 'ID', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `token` VARCHAR(100) NOT NULL COMMENT 'Refresh Token', + `tenant_id` BIGINT COMMENT '租户ID', + `expire_time` DATETIME NOT NULL COMMENT '过期时间', + `used` TINYINT NOT NULL DEFAULT 0 COMMENT '是否已使用(0:未使用,1:已使用)', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除(0:未删除,1:已删除)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_token` (`token`), + KEY `idx_user_id` (`user_id`), + KEY `idx_tenant_id` (`tenant_id`), + KEY `idx_expire_time` (`expire_time`), + KEY `idx_used` (`used`), + KEY `idx_deleted` (`deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Refresh Token表'; + +-- ======================================== +-- 8. 审计日志表 +-- ======================================== +CREATE TABLE IF NOT EXISTS `audit_log` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `action_type` VARCHAR(50) NOT NULL COMMENT '操作类型(LOGIN, LOGOUT, CREATE_USER, UPDATE_USER, DELETE_USER, ASSIGN_ROLE, REMOVE_ROLE, etc.)', + `user_id` BIGINT COMMENT '操作用户ID', + `username` VARCHAR(50) COMMENT '操作用户名', + `tenant_id` BIGINT NOT NULL COMMENT '租户ID', + `resource_type` VARCHAR(50) COMMENT '资源类型(USER, ROLE, PERMISSION)', + `resource_id` VARCHAR(100) COMMENT '资源ID', + `details` TEXT COMMENT '操作详情', + `result` VARCHAR(20) NOT NULL COMMENT '操作结果(SUCCESS/FAILURE)', + `error_message` TEXT COMMENT '错误信息(如果失败)', + `ip_address` VARCHAR(50) COMMENT '请求IP地址', + `user_agent` VARCHAR(500) COMMENT 'User Agent', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + INDEX `idx_user_id` (`user_id`), + INDEX `idx_username` (`username`), + INDEX `idx_tenant_id` (`tenant_id`), + INDEX `idx_action_type` (`action_type`), + INDEX `idx_result` (`result`), + INDEX `idx_create_time` (`create_time`), + INDEX `idx_composite_user_time` (`user_id`, `create_time`), + INDEX `idx_composite_action_result` (`action_type`, `result`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审计日志表 - 记录系统中的所有重要操作'; + +-- ======================================== +-- 初始化数据 +-- ======================================== + +-- 插入租户数据 +INSERT INTO `tenants` (`code`, `name`, `contact_name`, `status`) +VALUES ('default', '默认租户', '系统管理员', 1); + +INSERT INTO `tenants` (`code`, `name`, `contact_name`, `contact_phone`, `contact_email`, `status`) +VALUES + ('company_a', 'A公司', '张三', '13800138000', 'zhangsan@companya.com', 1), + ('company_b', 'B公司', '李四', '13900139000', 'lisi@companyb.com', 1); + +-- 插入用户数据 +-- 密码: admin123 (BCrypt加密) +INSERT INTO `users` (`username`, `password`, `tenant_id`, `real_name`, `email`, `status`) +VALUES + ('admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt6Z5EH', 1, '系统管理员', 'admin@example.com', 1), + ('user', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt6Z5EH', 1, '普通用户', 'user@example.com', 1); + +-- 插入角色数据 +INSERT INTO `roles` (`name`, `code`, `tenant_id`, `description`, `status`) +VALUES + ('超级管理员', 'ADMIN', 1, '拥有所有权限', 1), + ('普通用户', 'USER', 1, '基础用户权限', 1); + +-- 插入权限数据(公共权限,tenant_id 为 NULL) +INSERT INTO `permissions` (`name`, `code`, `tenant_id`, `description`, `type`, `resource`, `parent_id`, `sort`, `status`) +VALUES + -- 系统管理 + ('系统管理', 'system:manage', NULL, '系统管理模块', 'menu', '/system', NULL, 1, 1), + + -- 用户管理 + ('用户管理', 'user:manage', NULL, '用户管理模块', 'menu', '/system/user', 1, 1, 1), + ('用户查看', 'user:read', NULL, '查看用户信息', 'api', 'GET:/api/users/**', 2, 1, 1), + ('用户新增', 'user:create', NULL, '新增用户', 'api', 'POST:/api/users', 2, 2, 1), + ('用户修改', 'user:update', NULL, '修改用户信息', 'api', 'PUT:/api/users/**', 2, 3, 1), + ('用户删除', 'user:delete', NULL, '删除用户', 'api', 'DELETE:/api/users/**', 2, 4, 1), + + -- 角色管理 + ('角色管理', 'role:manage', NULL, '角色管理模块', 'menu', '/system/role', 1, 2, 1), + ('角色查看', 'role:read', NULL, '查看角色信息', 'api', 'GET:/api/roles/**', 7, 1, 1), + ('角色新增', 'role:create', NULL, '新增角色', 'api', 'POST:/api/roles', 7, 2, 1), + ('角色修改', 'role:update', NULL, '修改角色信息', 'api', 'PUT:/api/roles/**', 7, 3, 1), + ('角色删除', 'role:delete', NULL, '删除角色', 'api', 'DELETE:/api/roles/**', 7, 4, 1), + + -- 权限管理 + ('权限管理', 'permission:manage', NULL, '权限管理模块', 'menu', '/system/permission', 1, 3, 1), + ('权限查看', 'permission:read', NULL, '查看权限信息', 'api', 'GET:/api/permissions/**', 12, 1, 1), + ('权限新增', 'permission:create', NULL, '新增权限', 'api', 'POST:/api/permissions', 12, 2, 1), + ('权限修改', 'permission:update', NULL, '修改权限信息', 'api', 'PUT:/api/permissions/**', 12, 3, 1), + ('权限删除', 'permission:delete', NULL, '删除权限', 'api', 'DELETE:/api/permissions/**', 12, 4, 1); + +-- 插入用户角色关联数据 +INSERT INTO `user_roles` (`user_id`, `role_id`, `tenant_id`) +VALUES + (1, 1, 1), -- admin 用户 -> 超级管理员角色 + (2, 2, 1); -- user 用户 -> 普通用户角色 + +-- 插入角色权限关联数据 +-- 为超级管理员角色分配所有权限 +INSERT INTO `role_permissions` (`role_id`, `permission_id`, `tenant_id`) +SELECT 1, id, 1 FROM `permissions` WHERE `status` = 1; + +-- 为普通用户角色分配查看权限 +INSERT INTO `role_permissions` (`role_id`, `permission_id`, `tenant_id`) +SELECT 2, id, 1 FROM `permissions` WHERE `code` IN ('user:read', 'role:read', 'permission:read'); \ No newline at end of file diff --git a/src/main/resources/mapper/UserMapper.xml b/src/main/resources/mapper/UserMapper.xml new file mode 100644 index 0000000..24c7f5f --- /dev/null +++ b/src/main/resources/mapper/UserMapper.xml @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/templates/email-verification.html b/src/main/resources/templates/email-verification.html new file mode 100644 index 0000000..1ddf4e6 --- /dev/null +++ b/src/main/resources/templates/email-verification.html @@ -0,0 +1,166 @@ + + + + + + 邮箱验证 + + + +
+
+

✉️ 验证您的邮箱

+
+ +
+
+ 您好,用户! +
+ +
+

感谢您注册我们的认证系统!

+

为了确保账户安全并激活您的账号,请点击下方按钮验证您的邮箱地址:

+
+ + + +
+ ⏰ 此链接将在 24 小时后过期 +
+ +
+

如果按钮无法点击,请复制以下链接到浏览器:

+ + 验证链接 + +
+ +
+

+ ⚠️ 安全提示: + 如果您没有注册我们的服务,请忽略此邮件。您的账户安全不会受到影响。 +

+
+
+ + +
+ + diff --git a/src/main/resources/templates/password-reset-email.html b/src/main/resources/templates/password-reset-email.html new file mode 100644 index 0000000..2fddbc1 --- /dev/null +++ b/src/main/resources/templates/password-reset-email.html @@ -0,0 +1,112 @@ + + + + + + 密码重置请求 + + + +
+ + +

密码重置请求

+ +
+

您好,用户

+ +

我们收到了您的密码重置请求。如果这不是您的操作,请忽略此邮件。

+ +

要重置您的密码,请点击下面的按钮:

+
+ + + +
+

或者复制以下链接到浏览器:

+

重置链接

+
+ +
+

⏰ 重要提示:

+
    +
  • 此链接将在 60 分钟后失效
  • +
  • 为了您的账号安全,请不要将此链接分享给他人
  • +
  • 如果您没有请求重置密码,请忽略此邮件
  • +
+
+ + +
+ +