From 5e86ebe6b7bb5e63e7eb1cb108be1e5804f4079c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Wed, 23 Sep 2026 11:59:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=AE=9E=E7=8E=B0=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E8=AE=A4=E8=AF=81=E6=A8=A1=E5=9D=97=E5=8F=8A=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ApiResult 统一响应封装类,规范前端接口返回结构 - 添加 AuthController,支持多种登录方式及用户信息接口 - 实现 AuthService,包含登录、登出、密码修改、绑定手机号、重置密码等功能 - 集成图形验证码校验和登录失败锁定机制,增强安全性 - 支持邮箱登录和手机号验证码登录,完善多样化认证策略 - 实现 JWT 令牌签发与吊销,确保会话安全管理 - 配置不同环境 application.yml 及配置,支持多租户和灵活缓存配置 - 增加 .gitignore 规则,屏蔽构建产物、IDE 文件和敏感配置 - 引入 AuthProperties 以配置登录与验证码策略参数 --- iam-api/.gitignore | 31 ++ iam-api/.mvn/wrapper/maven-wrapper.properties | 3 + iam-api/Dockerfile | 32 ++ iam-api/README.md | 256 +++++++++++ iam-api/config/iam-jwt.properties.example | 19 + iam-api/docker-compose.yml | 81 ++++ iam-api/docs/iam-api-dev-plan.md | 145 ++++++ iam-api/mvnw | 295 +++++++++++++ iam-api/mvnw.cmd | 189 ++++++++ iam-api/pom.xml | 232 ++++++++++ iam-api/run.sh | 61 +++ iam-api/scripts/gen-iam-key.sh | 27 ++ .../java/com/gxwebsoft/IamApiApplication.java | 37 ++ .../com/gxwebsoft/common/core/Constants.java | 54 +++ .../common/core/annotation/QueryField.java | 29 ++ .../common/core/annotation/QueryType.java | 27 ++ .../common/core/cache/CacheStore.java | 32 ++ .../common/core/cache/MemoryCacheStore.java | 76 ++++ .../common/core/cache/RedisCacheStore.java | 57 +++ .../common/core/config/CacheConfig.java | 38 ++ .../common/core/config/ConfigProperties.java | 67 +++ .../common/core/config/JacksonConfig.java | 40 ++ .../common/core/config/JwtProperties.java | 28 ++ .../core/config/MybatisMetaObjectHandler.java | 28 ++ .../common/core/config/MybatisPlusConfig.java | 57 +++ .../common/core/config/SecurityConfig.java | 119 +++++ .../common/core/config/WebMvcConfig.java | 31 ++ .../common/core/context/TenantAware.java | 12 + .../common/core/context/TenantContext.java | 70 +++ .../core/exception/BusinessException.java | 32 ++ .../exception/GlobalExceptionHandler.java | 50 +++ .../core/security/JwtAccessDeniedHandler.java | 33 ++ .../security/JwtAuthenticationEntryPoint.java | 33 ++ .../security/JwtAuthenticationFilter.java | 105 +++++ .../common/core/security/JwtSubject.java | 17 + .../common/core/security/JwtUtil.java | 113 +++++ .../core/security/LoginUserProvider.java | 25 ++ .../core/security/RevokedTokenStore.java | 36 ++ .../gxwebsoft/common/core/util/IpUtils.java | 42 ++ .../gxwebsoft/common/core/web/ApiResult.java | 101 +++++ .../common/core/web/BaseController.java | 66 +++ .../gxwebsoft/common/core/web/BaseParam.java | 58 +++ .../gxwebsoft/common/core/web/PageParam.java | 213 +++++++++ .../gxwebsoft/common/core/web/PageResult.java | 52 +++ .../common/core/web/ResponseWriter.java | 34 ++ .../common/core/web/TenantFilter.java | 64 +++ .../common/system/config/AuthProperties.java | 40 ++ .../common/system/config/MailProperties.java | 28 ++ .../common/system/config/SmsConfig.java | 26 ++ .../common/system/config/SmsProperties.java | 34 ++ .../system/controller/AuthController.java | 103 +++++ .../system/controller/CaptchaController.java | 56 +++ .../system/controller/MenuController.java | 78 ++++ .../system/controller/RoleController.java | 95 ++++ .../system/controller/TenantController.java | 85 ++++ .../system/controller/UserController.java | 100 +++++ .../common/system/entity/LoginRecord.java | 65 +++ .../gxwebsoft/common/system/entity/Menu.java | 92 ++++ .../gxwebsoft/common/system/entity/Role.java | 64 +++ .../common/system/entity/RoleMenu.java | 39 ++ .../common/system/entity/Tenant.java | 64 +++ .../gxwebsoft/common/system/entity/User.java | 79 ++++ .../common/system/entity/UserRole.java | 39 ++ .../system/mapper/LoginRecordMapper.java | 12 + .../common/system/mapper/MenuMapper.java | 45 ++ .../common/system/mapper/RoleMapper.java | 33 ++ .../common/system/mapper/RoleMenuMapper.java | 12 + .../common/system/mapper/TenantMapper.java | 12 + .../common/system/mapper/UserMapper.java | 12 + .../common/system/mapper/UserRoleMapper.java | 12 + .../system/param/BindPhoneCaptchaParam.java | 15 + .../common/system/param/BindPhoneParam.java | 20 + .../system/param/ChangePasswordParam.java | 21 + .../system/param/EmailCaptchaParam.java | 17 + .../common/system/param/EmailLoginParam.java | 24 + .../common/system/param/LoginParam.java | 26 ++ .../common/system/param/MenuPageParam.java | 30 ++ .../common/system/param/MenuSaveParam.java | 37 ++ .../common/system/param/RoleMenuParam.java | 15 + .../common/system/param/RolePageParam.java | 30 ++ .../common/system/param/RoleSaveParam.java | 28 ++ .../system/param/SelfResetPasswordParam.java | 29 ++ .../common/system/param/SmsCaptchaParam.java | 17 + .../common/system/param/SmsLoginParam.java | 22 + .../common/system/param/TenantOpenParam.java | 27 ++ .../common/system/param/TenantPageParam.java | 30 ++ .../common/system/param/TenantSaveParam.java | 29 ++ .../common/system/param/UserPageParam.java | 34 ++ .../system/param/UserResetPasswordParam.java | 15 + .../common/system/param/UserSaveParam.java | 35 ++ .../common/system/result/CaptchaResult.java | 14 + .../common/system/result/LoginResult.java | 16 + .../common/system/result/UserInfoResult.java | 23 + .../security/SystemLoginUserProvider.java | 65 +++ .../common/system/service/AuthService.java | 413 ++++++++++++++++++ .../common/system/service/CaptchaService.java | 80 ++++ .../system/service/EmailCaptchaService.java | 96 ++++ .../system/service/LoggingSmsSender.java | 33 ++ .../system/service/LoginRecordService.java | 53 +++ .../common/system/service/MenuService.java | 147 +++++++ .../common/system/service/RoleService.java | 132 ++++++ .../system/service/SmsCaptchaService.java | 109 +++++ .../common/system/service/SmsSender.java | 16 + .../common/system/service/TenantService.java | 146 +++++++ .../common/system/service/UserService.java | 261 +++++++++++ .../gxwebsoft/iam/config/IamJwtConfig.java | 124 ++++++ .../iam/config/IamJwtProperties.java | 40 ++ .../iam/controller/IamClientController.java | 86 ++++ .../iam/controller/IamOAuthController.java | 165 +++++++ .../com/gxwebsoft/iam/entity/IamAuthCode.java | 66 +++ .../com/gxwebsoft/iam/entity/IamClient.java | 78 ++++ .../iam/mapper/IamAuthCodeMapper.java | 12 + .../gxwebsoft/iam/mapper/IamClientMapper.java | 12 + .../iam/param/IamAuthorizeParam.java | 37 ++ .../iam/param/IamClientPageParam.java | 30 ++ .../iam/param/IamClientSaveParam.java | 34 ++ .../iam/param/OpenIntrospectParam.java | 15 + .../gxwebsoft/iam/param/OpenRevokeParam.java | 15 + .../gxwebsoft/iam/param/OpenTokenParam.java | 31 ++ .../iam/result/IamClientSecretResult.java | 12 + .../iam/result/IamIntrospectResult.java | 29 ++ .../gxwebsoft/iam/result/IamTokenResult.java | 18 + .../iam/service/IamAuthCodeService.java | 139 ++++++ .../iam/service/IamClientService.java | 198 +++++++++ .../iam/service/IamTokenService.java | 214 +++++++++ .../src/main/resources/application-demo.yml | 30 ++ .../src/main/resources/application-dev.yml | 37 ++ .../src/main/resources/application-prod.yml | 82 ++++ iam-api/src/main/resources/application.yml | 141 ++++++ .../db/migration/V1__init_iam_schema.sql | 165 +++++++ .../db/migration/V2__seed_base_data.sql | 77 ++++ iam-api/src/main/resources/logback-spring.xml | 26 ++ .../gxwebsoft/iam/IamApiIntegrationTest.java | 215 +++++++++ .../src/test/resources/application-test.yml | 42 ++ 134 files changed, 8747 insertions(+) create mode 100644 iam-api/.gitignore create mode 100644 iam-api/.mvn/wrapper/maven-wrapper.properties create mode 100644 iam-api/Dockerfile create mode 100644 iam-api/README.md create mode 100644 iam-api/config/iam-jwt.properties.example create mode 100644 iam-api/docker-compose.yml create mode 100644 iam-api/docs/iam-api-dev-plan.md create mode 100755 iam-api/mvnw create mode 100644 iam-api/mvnw.cmd create mode 100644 iam-api/pom.xml create mode 100755 iam-api/run.sh create mode 100755 iam-api/scripts/gen-iam-key.sh create mode 100644 iam-api/src/main/java/com/gxwebsoft/IamApiApplication.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/Constants.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/annotation/QueryField.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/annotation/QueryType.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/cache/CacheStore.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/cache/MemoryCacheStore.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/cache/RedisCacheStore.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/config/CacheConfig.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/config/ConfigProperties.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/config/JacksonConfig.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/config/JwtProperties.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/config/MybatisMetaObjectHandler.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/config/MybatisPlusConfig.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/config/SecurityConfig.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/config/WebMvcConfig.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/context/TenantAware.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/context/TenantContext.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/exception/BusinessException.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/exception/GlobalExceptionHandler.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtAccessDeniedHandler.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationEntryPoint.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationFilter.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtSubject.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtUtil.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/security/LoginUserProvider.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/security/RevokedTokenStore.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/util/IpUtils.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/web/ApiResult.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/web/BaseController.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/web/BaseParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/web/PageParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/web/PageResult.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/web/ResponseWriter.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/core/web/TenantFilter.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/config/AuthProperties.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/config/MailProperties.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/config/SmsConfig.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/config/SmsProperties.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/controller/AuthController.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/controller/CaptchaController.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/controller/MenuController.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/controller/RoleController.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/controller/TenantController.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/controller/UserController.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/entity/LoginRecord.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/entity/Menu.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/entity/Role.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/entity/RoleMenu.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/entity/Tenant.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/entity/User.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/entity/UserRole.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/mapper/LoginRecordMapper.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/mapper/MenuMapper.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/mapper/RoleMapper.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/mapper/RoleMenuMapper.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/mapper/TenantMapper.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/mapper/UserMapper.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/mapper/UserRoleMapper.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/param/BindPhoneCaptchaParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/param/BindPhoneParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/param/ChangePasswordParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/param/EmailCaptchaParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/param/EmailLoginParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/param/LoginParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/param/MenuPageParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/param/MenuSaveParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/param/RoleMenuParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/param/RolePageParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/param/RoleSaveParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/param/SelfResetPasswordParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/param/SmsCaptchaParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/param/SmsLoginParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/param/TenantOpenParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/param/TenantPageParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/param/TenantSaveParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/param/UserPageParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/param/UserResetPasswordParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/param/UserSaveParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/result/CaptchaResult.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/result/LoginResult.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/result/UserInfoResult.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/security/SystemLoginUserProvider.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/service/AuthService.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/service/CaptchaService.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/service/EmailCaptchaService.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/service/LoggingSmsSender.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/service/LoginRecordService.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/service/MenuService.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/service/RoleService.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/service/SmsCaptchaService.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/service/SmsSender.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/service/TenantService.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/common/system/service/UserService.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/iam/config/IamJwtConfig.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/iam/config/IamJwtProperties.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/iam/controller/IamClientController.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/iam/controller/IamOAuthController.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/iam/entity/IamAuthCode.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/iam/entity/IamClient.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/iam/mapper/IamAuthCodeMapper.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/iam/mapper/IamClientMapper.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/iam/param/IamAuthorizeParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/iam/param/IamClientPageParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/iam/param/IamClientSaveParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/iam/param/OpenIntrospectParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/iam/param/OpenRevokeParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/iam/param/OpenTokenParam.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/iam/result/IamClientSecretResult.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/iam/result/IamIntrospectResult.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/iam/result/IamTokenResult.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/iam/service/IamAuthCodeService.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/iam/service/IamClientService.java create mode 100644 iam-api/src/main/java/com/gxwebsoft/iam/service/IamTokenService.java create mode 100644 iam-api/src/main/resources/application-demo.yml create mode 100644 iam-api/src/main/resources/application-dev.yml create mode 100644 iam-api/src/main/resources/application-prod.yml create mode 100644 iam-api/src/main/resources/application.yml create mode 100644 iam-api/src/main/resources/db/migration/V1__init_iam_schema.sql create mode 100644 iam-api/src/main/resources/db/migration/V2__seed_base_data.sql create mode 100644 iam-api/src/main/resources/logback-spring.xml create mode 100644 iam-api/src/test/java/com/gxwebsoft/iam/IamApiIntegrationTest.java create mode 100644 iam-api/src/test/resources/application-test.yml diff --git a/iam-api/.gitignore b/iam-api/.gitignore new file mode 100644 index 0000000..7e84944 --- /dev/null +++ b/iam-api/.gitignore @@ -0,0 +1,31 @@ +# 构建产物 +target/ +**/target/ + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.settings/ +.classpath +.project + +# 系统 +.DS_Store +Thumbs.db + +# 日志与临时文件 +*.log +logs/ +*.tmp + +# 环境与密钥(禁止入库):config/ 只保留示例模板,真实密钥不入库 +/config/* +!/config/iam-jwt.properties.example +.env +.env.local +src/main/resources/application-local.yml +*.pem +*.p12 +*.jks diff --git a/iam-api/.mvn/wrapper/maven-wrapper.properties b/iam-api/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..216df05 --- /dev/null +++ b/iam-api/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip diff --git a/iam-api/Dockerfile b/iam-api/Dockerfile new file mode 100644 index 0000000..ecee7a5 --- /dev/null +++ b/iam-api/Dockerfile @@ -0,0 +1,32 @@ +# ---------- 构建阶段:JDK 25 + Maven ---------- +FROM maven:3.9-eclipse-temurin-25 AS build +WORKDIR /build + +# 先只复制 pom,利用镜像层缓存加速依赖下载 +COPY pom.xml . +RUN mvn -B -ntp dependency:go-offline + +COPY src ./src +RUN mvn -B -ntp clean package -DskipTests + +# ---------- 运行阶段:JRE 25 ---------- +FROM eclipse-temurin:25-jre-alpine + +WORKDIR /app + +RUN mkdir -p /app/logs /app/config && chmod 755 /app/logs /app/config + +COPY --from=build /build/target/iam-api-*.jar app.jar + +ENV TZ=Asia/Shanghai \ + SPRING_PROFILES_ACTIVE=prod \ + # netty 4.1 仍使用 sun.misc.Unsafe 内存访问,JDK 25 下显式放行以消除告警 + # -Duser.timezone 独立于操作系统时区库,避免 alpine 镜像里 TZ 不生效导致时间写入 UTC + JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:+UseG1GC --sun-misc-unsafe-memory-access=allow -Duser.timezone=Asia/Shanghai" + +EXPOSE 8900 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8900/actuator/health || exit 1 + +ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"] diff --git a/iam-api/README.md b/iam-api/README.md new file mode 100644 index 0000000..f570e41 --- /dev/null +++ b/iam-api/README.md @@ -0,0 +1,256 @@ +# iam-api · 统一身份中心 + +> 技术栈:Java 25 + Spring Boot 3.5 + MyBatis-Plus + Spring Security + Flyway + Redis +> 定位:**open-api 与 dp-api 共用的唯一账号源**,让数据中台可以脱离开放平台独立售卖。 + +## 这个服务解决什么问题 + +此前 `dp-web` 控制台的登录直接复用 `open-api` 的账号体系,`dp-api` 没有自己的身份。 +一旦 `dp-api` 要独立对外售卖 / 部署,账号体系就成了耦合点。iam-api 把「用户 / 租户 / +角色 / 权限 / 菜单 / 登录 / 令牌签发」抽成独立服务,`open-api` 与 `dp-api` 都只作为 +它的**资源服务**:通过 JWKS 验签,不共享用户表、不共享对称密钥。 + +``` + ┌──────────────────────────────┐ + │ iam-api │ + │ 账号/租户/角色/权限/菜单/令牌 │ + └───────────┬──────────────────┘ + RS256 令牌 + JWKS │ RS256 令牌 + JWKS + ┌─────────────────────┴─────────────────────┐ + ▼ ▼ + ┌─────────────┐ ┌─────────────┐ + │ open-api │ │ dp-api │ + └─────────────┘ └─────────────┘ +``` + +详细规划见 [`docs/iam-api-dev-plan.md`](docs/iam-api-dev-plan.md)。 + +## 快速开始 + +### 方式一:零依赖演示(推荐先跑这个) + +```bash +# H2 内存库 + 进程内缓存,不连 MySQL / Redis +./run.sh demo + +# 或 +./mvnw -Pdemo spring-boot:run -Dspring-boot.run.profiles=demo +``` + +启动后访问 `http://localhost:8900/swagger-ui.html`(演示环境已开接口文档)。 +演示 profile 默认仍开启图形验证码,联调时可临时关掉: + +```bash +./mvnw -Pdemo spring-boot:run -Dspring-boot.run.profiles=demo \ + -Dspring-boot.run.arguments=--config.auth.captcha-enabled=false +``` + +### 方式二:连接真实依赖 + +```bash +cp config/iam-jwt.properties.example config/iam-jwt.properties +bash scripts/gen-iam-key.sh >> config/iam-jwt.properties # 生成 RS256 密钥 + +export DB_HOST=127.0.0.1 DB_USERNAME=root DB_PASSWORD=yourpass DB_NAME=iam +export REDIS_HOST=127.0.0.1 INTERNAL_KEY=your-internal-key JWT_SECRET=your-32byte-secret + +./run.sh # dev profile +``` + +> 系统 `java` 若是 17,直接 `./mvnw` 会报 `release version 25 not supported`, +> 用 `./run.sh`(内部固定 JDK 25)或自行 `export JAVA_HOME=`。 + +初始账号:`admin / admin123`(租户 1)。首次登录后请立刻改密码。 + +## 目录结构 + +``` +src/main/java/com/gxwebsoft/ +├─ common/core/ 内核:返回结构、异常、分页、租户上下文、缓存抽象、安全过滤器 +│ ├─ cache/ CacheStore:RedisCacheStore / MemoryCacheStore +│ ├─ config/ SecurityConfig / MybatisPlusConfig / CacheConfig ... +│ ├─ security/ HS256 会话令牌(JwtUtil / JwtAuthenticationFilter / 吊销名单) +│ └─ web/ ApiResult / PageParam / BaseController / TenantFilter +├─ common/system/ 身份与授权:租户 / 用户 / 角色 / 菜单 / 登录审计 / 验证码 +│ ├─ controller/ AuthController、CaptchaController、User/Role/Menu/TenantController +│ ├─ entity/ mapper/ param/ sys_tenant / sys_user / sys_role / sys_menu / sys_login_record +│ └─ security/ SystemLoginUserProvider(登录主体 + 权限集合) +└─ iam/ 统一令牌与接入方 + ├─ config/ IamJwtConfig(RS256 + JWKS) + ├─ controller/ IamOAuthController(/api/v1/oauth/**)、IamClientController + └─ entity/ iam_client、iam_auth_code +``` + +## 接口一览 + +| 分组 | 接口 | 认证 | +|---|---|---| +| 认证 | `POST /api/login`、`POST /api/loginByEmail`、`POST /api/loginBySms` | 免登录 | +| 认证 | `POST /api/logout`、`GET /api/me`、`POST /api/changePassword`、`POST /api/bindPhone[/captcha]` | 会话令牌 | +| 认证 | `POST /api/resetPassword` | 免登录(凭验证码) | +| 验证码 | `GET /api/captcha`、`POST /api/sendSmsCaptcha`、`POST /api/sendEmailCaptcha` | 免登录 | +| 租户 | `GET/POST/PUT/DELETE /api/iam/tenant`、`POST /api/iam/tenant/open` | 权限点 `iam:tenant:*` | +| 用户 | `GET/POST/PUT/DELETE /api/iam/user`、`POST /api/iam/user/{id}/resetPassword` | 权限点 `iam:user:*` | +| 角色 | `GET/POST/PUT/DELETE /api/iam/role`、`PUT /api/iam/role/{id}/menus` | 权限点 `iam:role:*` | +| 菜单 | `GET /api/iam/menu/tree`、`POST/PUT/DELETE /api/iam/menu` | 权限点 `iam:menu:*` | +| 接入方 | `GET/POST/PUT/DELETE /api/iam/client`、`POST /api/iam/client/{id}/secret` | 权限点 `iam:client:*` | +| 令牌 | `POST /api/v1/oauth/authorize`、`POST /api/v1/oauth/token` | 终端用户 / client_id+secret | +| 令牌 | `GET /api/v1/oauth/jwks` | 免登录 | +| 令牌 | `POST /api/v1/oauth/introspect`、`POST /api/v1/oauth/revoke` | `X-Internal-Key` | + +统一响应:`{"code":0,"message":"操作成功","data":{...}}`,与既有控制台契约一致。 + +### 令牌怎么用(资源服务视角) + +1. 启动时拉取 `http://iam-host/api/v1/oauth/jwks`,缓存公钥。 +2. 校验 `Authorization: Bearer ` 的签名、`iss`、`exp`、`aud`,并确认 + `token_type == access`(refresh token 不能当 access token 用)。 +3. 从 claim 里取 `tenant_id` / `user_id` / `roles` / `scope` 做业务授权。 +4. 需要感知吊销时,再用 `X-Internal-Key` 调 introspect 复核。 + +access token 默认 2 小时,refresh token 默认 30 天;**刷新即轮换**,接续方必须 +用返回的新 refreshToken 覆盖本地,否则下次刷新会失败。 + +### 内置接入方 + +迁移脚本预置了两条,`client_id` 分别是 `open-api` 与 `dp-api`,**演示密钥为 +`admin123`**(与初始管理员密码同一个 BCrypt 哈希,便于开箱联调)。 +**上线前必须在「接入方管理」里重置为随机密钥并妥善保存**,明文只在创建 / 重置时返回一次。 + +## 配置项 + +| 配置 | 说明 | 默认 | +|---|---|---| +| `server.port` | 服务端口 | `8900` | +| `iam.cache.type` | `redis` / `memory` | `redis`(demo/test 为 `memory`) | +| `config.jwt.secret` | 控制台会话令牌密钥(HS256,≥32 字节) | 开发默认值,生产必填 | +| `config.iam-jwt.private-key` | 资源服务令牌私钥(RS256,PEM) | 缺省时临时生成并告警 | +| `config.iam-jwt.issuer` / `audience` | 令牌 `iss` / 默认 `aud` | `https://iam.websoft.top` / `websoft-platform` | +| `config.iam-jwt.access-expire` / `refresh-expire` | 令牌有效期(秒) | `7200` / `2592000` | +| `config.internal-key` | introspect / revoke 自证密钥 | `change-me`,生产必填 | +| `config.auth.captcha-enabled` | 登录是否强制图形验证码 | `true` | +| `config.auth.login-fail-threshold` / `login-lock-seconds` | 失败锁定策略 | `5` 次 / `600` 秒 | +| `config.auth.register-enabled` | 自助注册开关(当前无注册接口,账号由平台运营开通) | `false` | +| `config.sms.enabled` | 是否真正下发短信 | `false`(仅写日志) | +| `config.mail.enabled` / `spring.mail.*` | 邮件验证码通道 | 未配 SMTP 时仅写日志 | +| `spring.flyway.user` / `spring.flyway.password` | Flyway 迁移专用账号(`prod` 必填) | 无默认值,缺失即启动失败 | + +## 部署 + +```bash +docker compose up -d --build +``` + +必须提供的环境变量:`DB_PASSWORD`、`JWT_SECRET`、`INTERNAL_KEY`,以及(首次启动前) +挂载到 `/app/config/iam-jwt.properties` 的 RS256 密钥。 +`prod` 还需要 `FLYWAY_USER` / `FLYWAY_PASSWORD`(迁移专用账号,见下方常见问题)。 +详见 [`docker-compose.yml`](docker-compose.yml)。 + +无 1Panel / 单机部署:`./mvnw clean package` 产出 `target/iam-api-0.1.0.jar`, +`java -jar` 启动,用 `SPRING_PROFILES_ACTIVE=prod` 与上面的环境变量即可。 + +### 部署检查清单 + +| # | 检查项 | 说明 | +|---|---|---| +| 1 | RS256 密钥已固定 | 不配 `private-key` 会每次重启换密钥,已签发令牌全部失效 | +| 2 | `JWT_SECRET` / `INTERNAL_KEY` 已换成随机值 | 默认值只能用于本地 | +| 3 | 内置接入方密钥已重置 | `open-api` / `dp-api` 的演示密钥是 `admin123` | +| 4 | `admin` 初始密码已修改 | 演示密码 `admin123` | +| 5 | Redis 已接入并纳入健康检查 | 验证码、失败计数、令牌吊销都依赖它 | +| 6 | 接口文档按需关闭 | 生产默认 `API_DOCS_ENABLED=false` | +| 7 | 反向代理放行 `Authorization` 与 `X-Internal-Key` | 漏掉会导致「登录成功但接口 401」 | +| 8 | `FLYWAY_USER` / `FLYWAY_PASSWORD` 已配好 | 迁移账号需有库 DDL 权限 + `performance_schema.user_variables_by_thread` 的 SELECT,建号脚本见 `scripts/create-flyway-user.sql` | + +## 与另外两个服务的关系(迁移路径) + +| 阶段 | 动作 | +|---|---| +| 现在 | iam-api 可独立运行,接口与数据自洽 | +| 下一步 | open-api 改为 iam-api 的接入方:登录代理 / 跳转,`/api/me` 改读 iam-api | +| 下一步 | dp-api 接入 JWKS 验签,用 `tenant_id` 做数据隔离 | +| 之后 | 存量用户从 `db_open.sys_user` 迁移到 iam-api,迁移期以 iam-api 为准、禁止双向写 | + +## 验证 + +```bash +./mvnw test # H2 + 内存缓存,覆盖登录 / 权限 / 令牌 / JWKS / 自省 / 授权码 +./mvnw clean package # 生产包(不含 H2) +``` + +## 当前未完成(见开发计划 M5/M6) + +- SSO 会话表与跨域免登票据、MFA、登录设备管理。 +- open-api / dp-api 的接入改造与存量用户迁移脚本。 +- 真实的短信 / 邮件通道实现(当前预留 `SmsSender` 扩展点,默认只写日志)。 + +## 常见问题 + +### 启动报 `SELECT command denied to user 'xxx'@'...' for table 'user_variables_by_thread'` + +不是业务代码的查询,是 **Flyway 的 MySQL 支持在建立连接时固定执行的探测**: +它会读 `performance_schema.user_variables_by_thread`(MariaDB 则是 +`information_schema.user_variables`)来记录 `foreign_key_checks` / `sql_safe_updates`, +迁移结束后再用 `SET foreign_key_checks=?, sql_safe_updates=?` 还原。是否启用该探测 +**只按数据库版本判断(MySQL ≥5.7 就启用),不看账号权限**,所以业务账号权限被收敛后就会撞上。 + +#### 本项目的选择:方案 B —— Flyway 用独立账号 + +`application-prod.yml` 已经启用: + +```yaml +spring: + flyway: + user: ${FLYWAY_USER} + password: ${FLYWAY_PASSWORD} +``` + +理由:业务账号的权限是被有意收敛的(它不该有 DDL 权限),为了跑迁移去给它开 +`performance_schema` 反而放大了它的权限面。迁移用独立账号,权限各归各位。 + +落地三步: + +```bash +# 1) 用 root / 高权限账号建迁移账号(脚本里替换 4 个占位符后执行) +mysql -h -u root -p < scripts/create-flyway-user.sql + +# 2) 自检:应返回一行,而不是 SELECT command denied +mysql -h -u -p -e \ + "SELECT VARIABLE_VALUE FROM performance_schema.user_variables_by_thread WHERE VARIABLE_NAME='foreign_key_checks' LIMIT 1;" + +# 3) 部署时注入变量 +export FLYWAY_USER=flyway_iam FLYWAY_PASSWORD='<强密码>' +``` + +两个设计细节值得知道: + +- **不需要配 `spring.flyway.url`**。只给 `user`/`password` 时,Spring Boot 会从主数据源的 + URL 与驱动派生一个只换账号密码的迁移数据源(已实测派生出的 url 与主库完全一致), + 所以 JDBC URL 只有 `datasource` 一处,不用两边同步维护。 +- **两个变量故意不给默认值**。`spring.flyway.user` 为空时 Spring 会静默回退到业务账号, + 也就是再次撞上同一个权限错误——而且是换一副面孔报出来。让它缺变量就启动失败, + 比事后排查便宜得多。 + +#### 备选方案 + +**A. 给业务账号补权限**(如果以后想少一个账号,且托管库允许) + +```sql +-- 主机按报错里的来源地址写;账号若建在 % 上就写 '%' +GRANT SELECT ON performance_schema.user_variables_by_thread TO '业务账号'@'来源主机'; +FLUSH PRIVILEGES; +``` + +**C. 关掉 Flyway,手工执行迁移**(云数据库连 `performance_schema` 都不让授权时的兜底) + +```bash +export SPRING_FLYWAY_ENABLED=false + +# 用有权限的账号(通常是跳板机上的 root)执行 +DB_HOST=... DB_NAME=iam DB_USERNAME=root DB_PASSWORD=... \ + bash scripts/apply-migrations.sh +``` + +脚本按文件名顺序执行 `src/main/resources/db/migration/*.sql`,密码走 `MYSQL_PWD`、 +不会出现在 `ps` 里。它不写 `flyway_schema_history`;之后若要交回 Flyway, +应用的 `baseline-on-migrate=true` 会先打基线再跳过已应用的版本。 diff --git a/iam-api/config/iam-jwt.properties.example b/iam-api/config/iam-jwt.properties.example new file mode 100644 index 0000000..a9ec597 --- /dev/null +++ b/iam-api/config/iam-jwt.properties.example @@ -0,0 +1,19 @@ +# RS256 统一令牌签名密钥(PEM,单行即可,换行会被去掉)。 +# +# 使用方式: +# cp config/iam-jwt.properties.example config/iam-jwt.properties +# bash scripts/gen-iam-key.sh >> config/iam-jwt.properties +# +# 生产环境务必使用固定密钥:不配置时应用会在启动时临时生成, +# 重启后此前签发的令牌全部失效,且多实例之间互不认账。 +# config/ 已在 .gitignore 中,密钥不会入库。 + +# 签发方:资源服务校验 iss 时必须完全一致 +issuer=https://iam.websoft.top +# 默认 audience;接入方有自己的 audience 时以 iam_client.audience 为准 +audience=websoft-platform +# 密钥标识,JWKS 中返回,用于密钥轮换 +key-id=iam-1 +# PKCS#8 私钥(可选公钥,留空则从私钥推导) +# private-key=-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY----- +# public-key=-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY----- diff --git a/iam-api/docker-compose.yml b/iam-api/docker-compose.yml new file mode 100644 index 0000000..71d8d0a --- /dev/null +++ b/iam-api/docker-compose.yml @@ -0,0 +1,81 @@ +# 1Panel / docker compose 部署示例。 +# 数据库与 Redis 若已有实例,删掉对应 service 并改环境变量指向外部地址即可。 +services: + iam-api: + build: . + image: iam-api:0.1.0 + container_name: iam-api + restart: unless-stopped + ports: + - "${IAM_API_PORT:-8900}:8900" + environment: + SPRING_PROFILES_ACTIVE: prod + DB_HOST: mysql + DB_PORT: "3306" + DB_NAME: ${DB_NAME:-iam} + DB_USERNAME: ${DB_USERNAME:-iam} + DB_PASSWORD: ${DB_PASSWORD:?数据库密码必须设置} + # Flyway 迁移账号:需要 iam 库的 DDL 权限 + performance_schema.user_variables_by_thread 的 SELECT + # 不设置这两项会启动失败(application-prod.yml 里故意没有默认值) + FLYWAY_USER: ${FLYWAY_USER:?Flyway 迁移账号必须设置} + FLYWAY_PASSWORD: ${FLYWAY_PASSWORD:?Flyway 迁移账号密码必须设置} + REDIS_HOST: redis + REDIS_PORT: "6379" + REDIS_PASSWORD: ${REDIS_PASSWORD:-} + # 必填:生产没有默认值,缺失会启动失败 + JWT_SECRET: ${JWT_SECRET:?会话令牌密钥必须设置} + INTERNAL_KEY: ${INTERNAL_KEY:?内部自证密钥必须设置} + # 内网自证密钥轮换期可配多值(逗号分隔)由网关处理,此处仅单值 + HEALTH_REDIS_ENABLED: "true" + volumes: + # RS256 私钥:宿主机 ./config/iam-jwt.properties 挂进容器,禁止打进镜像 + - ./config:/app/config:ro + - ./logs:/app/logs + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + + mysql: + image: mysql:8.0 + container_name: iam-mysql + restart: unless-stopped + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MySQL root 密码必须设置} + MYSQL_DATABASE: ${DB_NAME:-iam} + MYSQL_USER: ${DB_USERNAME:-iam} + MYSQL_PASSWORD: ${DB_PASSWORD:?数据库密码必须设置} + TZ: Asia/Shanghai + volumes: + - iam-mysql-data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + container_name: iam-redis + restart: unless-stopped + command: > + sh -c 'if [ -n "$$REDIS_PASSWORD" ]; then + exec redis-server --appendonly yes --requirepass "$$REDIS_PASSWORD"; + else + exec redis-server --appendonly yes; + fi' + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:-} + volumes: + - iam-redis-data:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli ${REDIS_PASSWORD:+-a $REDIS_PASSWORD} ping | grep PONG"] + interval: 10s + timeout: 5s + retries: 10 + +volumes: + iam-mysql-data: + iam-redis-data: diff --git a/iam-api/docs/iam-api-dev-plan.md b/iam-api/docs/iam-api-dev-plan.md new file mode 100644 index 0000000..1580aa0 --- /dev/null +++ b/iam-api/docs/iam-api-dev-plan.md @@ -0,0 +1,145 @@ +# iam-api 开发计划 + +> 统一身份中心(Identity & Access Management)。目标:把「用户 / 租户 / 角色 / 权限 / +> 菜单 / 登录 / 令牌签发」从 `open-api` 里抽出来,成为 `open-api` 与 `dp-api` 共用的 +> 唯一账号源,使 `dp-api` 可以脱离 `open-api` 独立售卖与部署。 + +## 1. 定位与边界 + +### 1.1 iam-api 负责 + +| 能力 | 说明 | +|---|---| +| 身份(Identity) | 租户、用户、账号密码 / 邮箱 / 手机号、密码强度与找回、绑定手机 | +| 授权(Access) | 角色、权限标识、菜单(菜单即权限载体)、用户-角色-菜单关系 | +| 会话(Session) | 控制台登录态令牌(HS256)、登出吊销、登录失败锁定、登录审计 | +| 令牌(Token) | 面向资源服务的 RS256 access token、refresh token、JWKS、introspect、revoke | +| 客户端(Client) | `iam_client` 注册表:登记 open-api / dp-api 等消费方及其允许的 scope / aud | + +### 1.2 iam-api 不负责 + +- 开放平台的业务语义:应用管理、OAuth2 授权页、scope 字典、令牌签发统计(留在 `open-api`)。 +- 数据中台的业务数据:服务器、客户、订单、合同、发票、支付(留在 `dp-api`)。 +- 通用底座:云存储、系统设置、字典、附件、消息、审计(后续放 `base-api`)。 + +> 边界一句话:**iam-api 只知道“你是谁、你能干什么”,不知道“你干的是什么业务”。** + +### 1.3 与 open-api / dp-api 的关系 + +``` + ┌──────────────────────────────┐ + │ iam-api │ + │ 账号/租户/角色/权限/菜单/令牌 │ + └───────────┬──────────────────┘ + RS256 令牌 + JWKS │ RS256 令牌 + JWKS + ┌─────────────────────┴─────────────────────┐ + ▼ ▼ + ┌─────────────┐ ┌─────────────┐ + │ open-api │ │ dp-api │ + │ 开放平台 │ │ 数据中台 │ + └─────────────┘ └─────────────┘ +``` + +- 两个资源服务都只通过 JWKS 验签,不共享用户表、不共享对称密钥。 +- 同一个 `user_id` 可以在两个产品里拥有不同角色: + `iam_user_role` 带 `client_id`(为空表示控制台全局角色),授权按消费者隔离。 + +## 2. 里程碑 + +### M1 内核与骨架(本期) + +- [x] Maven 工程、Spring Boot 3.5 / Java 25、Flyway、MyBatis-Plus +- [x] 统一返回结构、全局异常、分页与查询参数封装 +- [x] 多租户上下文与 MyBatis-Plus 租户插件 +- [x] 缓存抽象 `CacheStore`(Redis / 内存双实现,演示与测试免 Redis) + +### M2 身份与授权(本期) + +- [x] `sys_tenant` / `sys_user` / `sys_role` / `sys_menu` / 关联表 / 登录审计 +- [x] 租户、用户、角色、菜单的查询与管理接口 +- [x] 用户带角色与权限集合,供控制台做按钮级权限 + +### M3 登录与安全(本期) + +- [x] 账号密码登录(图形验证码 + 失败锁定) +- [x] 邮箱登录、手机号验证码登录 +- [x] 登出(令牌吊销)、`/api/me`、修改密码、绑定/换绑手机号、自助找回密码 +- [x] HS256 会话令牌 + 认证过滤器 + 401/403 统一处理 + +### M4 统一令牌与客户端(本期) + +- [x] `iam_client` 客户端注册表与 scope 白名单 +- [x] RS256 令牌签发:`client_credentials` / `authorization_code(PKCE)` / `refresh_token` +- [x] `/api/v1/oauth/jwks` 公钥集合、`/api/v1/oauth/introspect`、`/api/v1/oauth/revoke` +- [x] 授权码存储与一次性核销(`iam_auth_code`) + +### M5 迁出与联调(下一期) + +- [ ] open-api 改为 iam-api 的客户端:登录跳转/代理、用户信息改读 iam-api +- [ ] dp-api 接入:JWKS 验签 + `tenant_id` / 角色映射 +- [ ] 存量用户迁移脚本(`db_open.sys_user` → `iam_user`)与双写过渡 +- [ ] 平台运营端(账号开通、跨租户管理)迁到 iam-api 或独立的 iam-admin + +### M6 单点登录与合规(远期) + +- [ ] SSO 会话表 `iam_sso_session` 与跨域免登票据 +- [ ] MFA(TOTP)、登录设备管理、异常登录提醒 +- [ ] 审计中心:把登录审计升级为操作审计 + +## 3. 数据模型 + +| 表 | 说明 | +|---|---| +| `sys_tenant` | 租户 | +| `sys_user` | 用户(BCrypt 密码,邮箱/手机号可空) | +| `sys_role` | 角色 | +| `sys_user_role` | 用户-角色 | +| `sys_menu` | 菜单 / 权限点 | +| `sys_role_menu` | 角色-菜单 | +| `sys_login_record` | 登录审计 | +| `iam_client` | 接入方(client_id / secret / 允许 scope / 令牌有效期) | +| `iam_auth_code` | OAuth2 授权码(一次性、短有效期) | + +所有业务表统一:`del_flag` 逻辑删除、`create_time` / `update_time` 应用层填充。 + +## 4. 接口一览(本期) + +| 分组 | 接口 | +|---|---| +| 认证 | `POST /api/login`、`POST /api/loginByEmail`、`POST /api/loginBySms`、`POST /api/logout`、`GET /api/me`、`POST /api/changePassword`、`POST /api/resetPassword`、`POST /api/bindPhone`、`POST /api/bindPhone/captcha` | +| 验证码 | `GET /api/captcha`、`POST /api/sendSmsCaptcha`、`POST /api/sendEmailCaptcha` | +| 租户 | `GET/POST/PUT/DELETE /api/iam/tenant`、`GET /api/iam/tenant/{id}` | +| 用户 | `GET /api/iam/user`、`POST /api/iam/user`、`PUT /api/iam/user/{id}`、`DELETE /api/iam/user/{id}`、`POST /api/iam/user/{id}/resetPassword` | +| 角色 | `GET /api/iam/role`、`POST /api/iam/role`、`PUT /api/iam/role/{id}`、`DELETE /api/iam/role/{id}`、`PUT /api/iam/role/{id}/menus` | +| 菜单 | `GET /api/iam/menu/tree`、`POST /api/iam/menu`、`PUT /api/iam/menu/{id}`、`DELETE /api/iam/menu/{id}` | +| 客户端 | `GET /api/iam/client`、`POST /api/iam/client`、`PUT /api/iam/client/{id}`、`DELETE /api/iam/client/{id}`、`POST /api/iam/client/{id}/secret` | +| 令牌 | `POST /api/v1/oauth/token`、`GET /api/v1/oauth/jwks`、`POST /api/v1/oauth/introspect`、`POST /api/v1/oauth/revoke` | + +## 5. 配置 + +| 配置项 | 说明 | 默认 | +|---|---|---| +| `server.port` | 服务端口 | `8900` | +| `iam.cache.type` | 缓存实现:`redis` / `memory` | `redis`(`demo`/`test` 为 `memory`) | +| `config.jwt.secret` | 控制台会话令牌密钥(HS256) | 开发默认值,生产必须覆盖 | +| `config.iam-jwt.private-key` | 资源服务令牌私钥(RS256,PEM) | 缺省时启动生成临时密钥并告警 | +| `config.internal-key` | introspect 调用方自证密钥 | `change-me` | + +## 6. 部署 + +- 端口 `8900`,与 `open-api`(8800) / `dp-api`(18800) 并存。 +- 生产用 Docker(`Dockerfile` + `docker-compose.yml`),MySQL + Redis 由编排提供。 +- 演示与测试走 `-Pdemo` / `test` profile,H2 + 内存缓存,无外部依赖。 +- Flyway 用**独立迁移账号**(`FLYWAY_USER` / `FLYWAY_PASSWORD`),与业务账号分离: + Flyway 建连接时要读 `performance_schema.user_variables_by_thread`,业务账号不该为此扩权。 + 建号脚本 `scripts/create-flyway-user.sql`;无法授权时用 `scripts/apply-migrations.sh` 手工迁移。 + +## 7. 风险与对策 + +| 风险 | 对策 | +|---|---| +| 双用户表导致身份漂移 | 迁移期以 iam-api 为准,open-api / dp-api 只读;禁止双向写 | +| 令牌吊销的传播延迟 | access token 短有效期(默认 2h)+ 资源服务就近校验 + introspect 兜底 | +| Redis 不可用导致全员无法登录 | `CacheStore` 降级策略与 `iam.cache.type` 显式切换,健康检查暴露状态 | +| 认证与业务耦合 | 资源服务只依赖 JWKS 公钥,不引入 iam-api 的代码依赖 | +| 迁移账号权限不足导致启动失败 | Flyway 独立账号 + 建号脚本;退路是关闭 Flyway 用 `scripts/apply-migrations.sh` | diff --git a/iam-api/mvnw b/iam-api/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/iam-api/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/iam-api/mvnw.cmd b/iam-api/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/iam-api/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/iam-api/pom.xml b/iam-api/pom.xml new file mode 100644 index 0000000..47c9188 --- /dev/null +++ b/iam-api/pom.xml @@ -0,0 +1,232 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.16 + + + + com.gxwebsoft + iam-api + 0.1.0 + jar + + iam-api + 统一身份中心:账号 / 租户 / 角色 / 权限 / 菜单 / 令牌签发 + + + 25 + 25 + UTF-8 + UTF-8 + + 3.5.17 + 1.2.28 + + 2.3.0 + 4.5.0 + 0.13.0 + 5.8.47 + 1.18.48 + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework.boot + spring-boot-starter-aop + + + org.springframework.boot + spring-boot-starter-actuator + + + + + com.baomidou + mybatis-plus-spring-boot3-starter + ${mybatis-plus.version} + + + + com.baomidou + mybatis-plus-jsqlparser + ${mybatis-plus.version} + + + com.alibaba + druid-spring-boot-3-starter + ${druid.version} + + + com.mysql + mysql-connector-j + runtime + + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-mysql + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-mail + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + com.github.xiaoymin + knife4j-openapi3-jakarta-spring-boot-starter + ${knife4j.version} + + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + + cn.hutool + hutool-all + ${hutool.version} + + + com.github.whvcse + easy-captcha + 1.6.2 + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + com.h2database + h2 + test + + + + + + + demo + + + com.h2database + h2 + runtime + + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + + + org.projectlombok + lombok + ${lombok.version} + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/iam-api/run.sh b/iam-api/run.sh new file mode 100755 index 0000000..4ded989 --- /dev/null +++ b/iam-api/run.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# +# 开发环境启动脚本。 +# +# 项目要求 Java 25(maven.compiler.release=25),系统默认 JDK 可能是 17, +# 直接跑 ./mvnw 会报 "release version 25 not supported",所以这里固定 JDK。 +# +# 用法: +# ./run.sh 使用 dev profile(连本地 MySQL / Redis) +# ./run.sh demo 使用 demo profile(H2 + 内存缓存,零外部依赖) +# ./run.sh dev --server.port=8081 +# JDK25_HOME=/path/to/jdk ./run.sh +# +set -euo pipefail + +JDK25_HOME="${JDK25_HOME:-$HOME/Library/Java/JavaVirtualMachines/temurin-25.jdk}" + +# macOS 的 .jdk 是 bundle 结构,真正的 JDK 根目录在 Contents/Home; +# 也兼容直接指向 JDK 根目录的情况。 +if [[ -x "$JDK25_HOME/Contents/Home/bin/java" ]]; then + JDK25_HOME="$JDK25_HOME/Contents/Home" +fi + +if [[ ! -x "$JDK25_HOME/bin/java" ]]; then + echo "未找到 JDK 25:$JDK25_HOME" >&2 + echo "可通过 JDK25_HOME=/path/to/jdk25 ./run.sh 指定其他位置" >&2 + exit 1 +fi + +export JAVA_HOME="$JDK25_HOME" +export PATH="$JAVA_HOME/bin:$PATH" + +cd "$(dirname "$0")" + +# JDK 23 起 sun.misc.Unsafe 的内存访问方法被标记为「最终弃用」, +# Netty(来自 Lettuce/Redis)内部仍在使用,启动时会打印 WARNING。 +APP_JVM_ARGS="${APP_JVM_ARGS:---sun-misc-unsafe-memory-access=allow}" + +PROFILE="${1:-dev}" +if [[ "$PROFILE" == "demo" ]]; then + # demo 需要 H2,而 H2 只在 -Pdemo 时是 runtime 依赖 + MVN_PROFILE=(-Pdemo) + shift || true +else + MVN_PROFILE=() + shift || true +fi + +echo "JDK: $("$JAVA_HOME/bin/java" -version 2>&1 | head -1)" +echo "Profile: $PROFILE" + +if [[ $# -gt 0 ]]; then + exec ./mvnw -q "${MVN_PROFILE[@]}" spring-boot:run \ + -Dspring-boot.run.profiles="$PROFILE" \ + -Dspring-boot.run.jvmArguments="$APP_JVM_ARGS" \ + -Dspring-boot.run.arguments="$*" +fi + +exec ./mvnw -q "${MVN_PROFILE[@]}" spring-boot:run \ + -Dspring-boot.run.profiles="$PROFILE" \ + -Dspring-boot.run.jvmArguments="$APP_JVM_ARGS" diff --git a/iam-api/scripts/gen-iam-key.sh b/iam-api/scripts/gen-iam-key.sh new file mode 100755 index 0000000..b79b576 --- /dev/null +++ b/iam-api/scripts/gen-iam-key.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# +# 生成 RS256 密钥对,输出可直接追加到 config/iam-jwt.properties 的内容。 +# +# bash scripts/gen-iam-key.sh >> config/iam-jwt.properties +# +# 需要 openssl 与 JDK 25 的 keytool 不冲突:这里统一用 openssl, +# 生成的是 PKCS#8 私钥 + X.509 公钥,正好是 IamJwtConfig 期望的格式。 +# +set -euo pipefail + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$WORK_DIR/private.pem" 2>/dev/null +openssl rsa -in "$WORK_DIR/private.pem" -pubout -out "$WORK_DIR/public.pem" 2>/dev/null + +# properties 文件里换行需要转义,这里直接压成单行交给 IamJwtConfig 解析 +PRIVATE_KEY="$(awk 'BEGIN{ORS=""} {print}' "$WORK_DIR/private.pem")" +PUBLIC_KEY="$(awk 'BEGIN{ORS=""} {print}' "$WORK_DIR/public.pem")" + +cat <iam-api 是 open-api 与 dp-api 共用的唯一账号源:负责用户、租户、角色、权限、菜单、 + * 登录会话,以及面向资源服务的 RS256 令牌签发与 JWKS 发布。

+ * + * @author WebSoft + */ +@SpringBootApplication +@MapperScan("com.gxwebsoft.**.mapper") +@EnableConfigurationProperties({ + ConfigProperties.class, + JwtProperties.class, + AuthProperties.class, + SmsProperties.class, + MailProperties.class, + IamJwtProperties.class +}) +public class IamApiApplication { + + public static void main(String[] args) { + SpringApplication.run(IamApiApplication.class, args); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/Constants.java b/iam-api/src/main/java/com/gxwebsoft/common/core/Constants.java new file mode 100644 index 0000000..c81c61a --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/Constants.java @@ -0,0 +1,54 @@ +package com.gxwebsoft.common.core; + +/** + * 框架级常量。 + * + * @author WebSoft + */ +public final class Constants { + + /** 成功状态码 */ + public static final int RESULT_OK_CODE = 0; + + /** 失败状态码 */ + public static final int RESULT_ERROR_CODE = 1; + + public static final String RESULT_OK_MSG = "操作成功"; + + public static final String RESULT_ERROR_MSG = "操作失败"; + + /** 无权限 */ + public static final int UNAUTHORIZED_CODE = 403; + + public static final String UNAUTHORIZED_MSG = "没有访问权限"; + + /** 未认证 */ + public static final int UNAUTHENTICATED_CODE = 401; + + public static final String UNAUTHENTICATED_MSG = "请先登录"; + + public static final int TOKEN_EXPIRED_CODE = 401; + + public static final String TOKEN_EXPIRED_MSG = "登录已过期"; + + public static final int BAD_CREDENTIALS_CODE = 401; + + public static final String BAD_CREDENTIALS_MSG = "请退出重新登录"; + + public static final String ORDER_DESC_VALUE = "desc"; + + /** Token 传递位置:请求头 */ + public static final String TOKEN_HEADER_NAME = "Authorization"; + + /** Token 认证类型 */ + public static final String TOKEN_TYPE = "Bearer"; + + /** 多租户:请求头中的租户 ID */ + public static final String TENANT_HEADER_NAME = "tenantId"; + + /** 内部服务自证密钥的请求头 */ + public static final String INTERNAL_KEY_HEADER = "X-Internal-Key"; + + private Constants() { + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/annotation/QueryField.java b/iam-api/src/main/java/com/gxwebsoft/common/core/annotation/QueryField.java new file mode 100644 index 0000000..ed147b7 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/annotation/QueryField.java @@ -0,0 +1,29 @@ +package com.gxwebsoft.common.core.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 声明查询条件字段:字段名 → 数据库列 + 比较方式。 + * + *

{@code keywords=true} 的字段参与通用关键字模糊搜索(多个字段之间是 OR)。

+ * + * @author WebSoft + */ +@Documented +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface QueryField { + + /** 数据库列名;为空时按驼峰转下划线取字段名 */ + String value() default ""; + + /** 比较方式 */ + QueryType type() default QueryType.EQ; + + /** 是否参与通用 keywords 模糊搜索 */ + boolean keywords() default false; +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/annotation/QueryType.java b/iam-api/src/main/java/com/gxwebsoft/common/core/annotation/QueryType.java new file mode 100644 index 0000000..5315829 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/annotation/QueryType.java @@ -0,0 +1,27 @@ +package com.gxwebsoft.common.core.annotation; + +/** + * 查询条件类型。 + * + * @author WebSoft + */ +public enum QueryType { + + /** 等于 */ + EQ, + + /** 不等于 */ + NE, + + /** 大于等于 */ + GE, + + /** 小于等于 */ + LE, + + /** 模糊匹配 */ + LIKE, + + /** 包含于(逗号分隔的字符串入参) */ + IN +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/cache/CacheStore.java b/iam-api/src/main/java/com/gxwebsoft/common/core/cache/CacheStore.java new file mode 100644 index 0000000..e96ca03 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/cache/CacheStore.java @@ -0,0 +1,32 @@ +package com.gxwebsoft.common.core.cache; + +/** + * 键值缓存抽象。 + * + *

验证码、失败计数、令牌吊销名单都落在缓存里。抽象成接口是为了让「演示 / 测试环境」 + * 不依赖 Redis 也能跑起来:实现见 {@link MemoryCacheStore} 与 {@link RedisCacheStore}。

+ * + * @author WebSoft + */ +public interface CacheStore { + + /** 写入并设置过期时间(秒) */ + void set(String key, String value, long ttlSeconds); + + /** 读取,不存在或已过期返回 null */ + String get(String key); + + /** 删除 */ + void delete(String key); + + /** 自增并返回自增后的值;key 不存在时从 0 开始,且首次写入设置过期时间 */ + long increment(String key, long ttlSeconds); + + /** key 不存在时写入并返回 true,否则返回 false(用于发码频控) */ + boolean setIfAbsent(String key, String value, long ttlSeconds); + + /** 是否存在 */ + default boolean exists(String key) { + return get(key) != null; + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/cache/MemoryCacheStore.java b/iam-api/src/main/java/com/gxwebsoft/common/core/cache/MemoryCacheStore.java new file mode 100644 index 0000000..a73c3b3 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/cache/MemoryCacheStore.java @@ -0,0 +1,76 @@ +package com.gxwebsoft.common.core.cache; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 进程内缓存实现。 + * + *

用途:`demo` profile 与单元测试。多实例部署下验证码与失败计数不共享, + * 生产必须使用 {@link RedisCacheStore}。

+ * + * @author WebSoft + */ +public class MemoryCacheStore implements CacheStore { + + /** 单个键的过期时间戳,已过期的键在读取时惰性清理 */ + private record Entry(String value, long expireAtMillis) { + + boolean expired(long now) { + return expireAtMillis > 0 && now >= expireAtMillis; + } + } + + private final Map store = new ConcurrentHashMap<>(); + + @Override + public void set(String key, String value, long ttlSeconds) { + store.put(key, new Entry(value, expireAt(ttlSeconds))); + } + + @Override + public String get(String key) { + Entry entry = store.get(key); + if (entry == null) { + return null; + } + if (entry.expired(System.currentTimeMillis())) { + store.remove(key); + return null; + } + return entry.value(); + } + + @Override + public void delete(String key) { + store.remove(key); + } + + @Override + public long increment(String key, long ttlSeconds) { + AtomicLong counter = new AtomicLong(); + store.compute(key, (k, existing) -> { + long now = System.currentTimeMillis(); + long base = existing == null || existing.expired(now) ? 0L : Long.parseLong(existing.value()); + long next = base + 1; + counter.set(next); + long expireAt = existing == null || existing.expired(now) ? expireAt(ttlSeconds) : existing.expireAtMillis(); + return new Entry(String.valueOf(next), expireAt); + }); + return counter.get(); + } + + @Override + public boolean setIfAbsent(String key, String value, long ttlSeconds) { + long now = System.currentTimeMillis(); + Entry created = new Entry(value, expireAt(ttlSeconds)); + Entry previous = store.compute(key, (k, existing) -> + existing == null || existing.expired(now) ? created : existing); + return previous == created; + } + + private static long expireAt(long ttlSeconds) { + return ttlSeconds > 0 ? System.currentTimeMillis() + ttlSeconds * 1000L : 0L; + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/cache/RedisCacheStore.java b/iam-api/src/main/java/com/gxwebsoft/common/core/cache/RedisCacheStore.java new file mode 100644 index 0000000..f8024e2 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/cache/RedisCacheStore.java @@ -0,0 +1,57 @@ +package com.gxwebsoft.common.core.cache; + +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.time.Duration; + +/** + * Redis 缓存实现(生产默认)。 + * + * @author WebSoft + */ +public class RedisCacheStore implements CacheStore { + + private final StringRedisTemplate redisTemplate; + + public RedisCacheStore(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + @Override + public void set(String key, String value, long ttlSeconds) { + if (ttlSeconds > 0) { + redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(ttlSeconds)); + } else { + redisTemplate.opsForValue().set(key, value); + } + } + + @Override + public String get(String key) { + return redisTemplate.opsForValue().get(key); + } + + @Override + public void delete(String key) { + redisTemplate.delete(key); + } + + @Override + public long increment(String key, long ttlSeconds) { + Long value = redisTemplate.opsForValue().increment(key); + long result = value == null ? 0L : value; + // 只在第一次自增时设置过期时间,避免每次自增都把窗口往后推 + if (result == 1L && ttlSeconds > 0) { + redisTemplate.expire(key, Duration.ofSeconds(ttlSeconds)); + } + return result; + } + + @Override + public boolean setIfAbsent(String key, String value, long ttlSeconds) { + Boolean ok = ttlSeconds > 0 + ? redisTemplate.opsForValue().setIfAbsent(key, value, Duration.ofSeconds(ttlSeconds)) + : redisTemplate.opsForValue().setIfAbsent(key, value); + return Boolean.TRUE.equals(ok); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/config/CacheConfig.java b/iam-api/src/main/java/com/gxwebsoft/common/core/config/CacheConfig.java new file mode 100644 index 0000000..c73f166 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/config/CacheConfig.java @@ -0,0 +1,38 @@ +package com.gxwebsoft.common.core.config; + +import com.gxwebsoft.common.core.cache.CacheStore; +import com.gxwebsoft.common.core.cache.MemoryCacheStore; +import com.gxwebsoft.common.core.cache.RedisCacheStore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.StringRedisTemplate; + +/** + * 缓存实现装配。 + * + *

{@code iam.cache.type=redis}(默认)走 Redis;{@code memory} 走进程内 Map, + * 用于演示与测试。多实例部署必须用 redis,否则验证码与失败计数不共享。

+ * + * @author WebSoft + */ +@Configuration +public class CacheConfig { + + private static final Logger log = LoggerFactory.getLogger(CacheConfig.class); + + @Bean + @ConditionalOnProperty(name = "iam.cache.type", havingValue = "memory", matchIfMissing = false) + public CacheStore memoryCacheStore() { + log.warn("iam.cache.type=memory:验证码与登录失败计数仅存于本进程,禁止用于多实例生产部署"); + return new MemoryCacheStore(); + } + + @Bean + @ConditionalOnProperty(name = "iam.cache.type", havingValue = "redis", matchIfMissing = true) + public CacheStore redisCacheStore(StringRedisTemplate stringRedisTemplate) { + return new RedisCacheStore(stringRedisTemplate); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/config/ConfigProperties.java b/iam-api/src/main/java/com/gxwebsoft/common/core/config/ConfigProperties.java new file mode 100644 index 0000000..9681e13 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/config/ConfigProperties.java @@ -0,0 +1,67 @@ +package com.gxwebsoft.common.core.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +/** + * 框架通用配置(前缀 {@code config})。 + * + * @author WebSoft + */ +@Getter +@Setter +@ConfigurationProperties(prefix = "config") +public class ConfigProperties { + + private String swaggerTitle = "iam-api 接口文档"; + + private String swaggerDescription = "统一身份中心:账号 / 租户 / 角色 / 权限 / 菜单 / 令牌"; + + private String swaggerVersion = "0.1.0"; + + private List swaggerBasePackages = List.of("com.gxwebsoft"); + + /** 对外服务地址,用于拼接回调地址 */ + private String serverUrl; + + /** 内部服务调用密钥:introspect / revoke 的调用方用 X-Internal-Key 自证 */ + private String internalKey; + + /** + * 允许跨域访问的来源白名单(支持通配,例如 {@code https://*.websoft.top})。 + * + *

禁止配置成 {@code *}:接口开启了 allowCredentials,任意站点都能带凭证调用本服务。

+ */ + private List corsAllowedOriginPatterns = List.of( + "https://websoft.top", + "https://*.websoft.top", + "http://localhost:[*]", + "http://127.0.0.1:[*]" + ); + + /** 免鉴权路径白名单 */ + private List permitAllPaths = List.of( + "/", + "/api/login", + "/api/loginByEmail", + "/api/loginBySms", + "/api/captcha", + "/api/sendSmsCaptcha", + "/api/sendEmailCaptcha", + "/api/resetPassword", + // 申请授权码:终端用户用账号密码换码,接口本身免登录(内部自证用户身份) + "/api/v1/oauth/authorize", + // 令牌自省:调用方是资源服务,用 X-Internal-Key 头自证 + "/api/v1/oauth/introspect", + "/api/v1/oauth/revoke", + "/actuator/health", + "/v3/api-docs/**", + "/doc.html", + "/swagger-ui/**", + "/swagger-ui.html", + "/webjars/**" + ); +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/config/JacksonConfig.java b/iam-api/src/main/java/com/gxwebsoft/common/core/config/JacksonConfig.java new file mode 100644 index 0000000..af8bd56 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/config/JacksonConfig.java @@ -0,0 +1,40 @@ +package com.gxwebsoft.common.core.config; + +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * Jackson 配置:统一日期时间格式。 + * + * @author WebSoft + */ +@Configuration +public class JacksonConfig { + + private static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; + + /** + * 只注册「定制器」,ObjectMapper 本身交给 Spring Boot 自动配置。 + * + *

不要在这里自己 new 一个 ObjectMapper Bean:那会覆盖自动配置,并且和 Boot 自带的 + * {@code standardJacksonObjectMapperBuilderCustomizer} 一起被注入时产生歧义。

+ */ + @Bean + public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DATE_TIME_PATTERN); + return builder -> { + JavaTimeModule module = new JavaTimeModule(); + module.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(formatter)); + module.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(formatter)); + builder.modules(module); + builder.simpleDateFormat(DATE_TIME_PATTERN); + }; + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/config/JwtProperties.java b/iam-api/src/main/java/com/gxwebsoft/common/core/config/JwtProperties.java new file mode 100644 index 0000000..32d9503 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/config/JwtProperties.java @@ -0,0 +1,28 @@ +package com.gxwebsoft.common.core.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 控制台会话令牌配置(前缀 {@code config.jwt})。 + * + *

会话令牌只服务于控制台自身(HS256),与面向资源服务的 RS256 令牌是两套东西, + * 后者见 {@code com.gxwebsoft.iam.config.IamJwtProperties}。

+ * + * @author WebSoft + */ +@Getter +@Setter +@ConfigurationProperties(prefix = "config.jwt") +public class JwtProperties { + + /** 签名密钥:Base64 或不少于 32 字节的明文,生产环境必须用环境变量注入 */ + private String secret; + + /** Token 有效期(秒) */ + private long expire = 86400; + + /** 剩余有效期低于该值(秒)时自动续签 */ + private long refreshThreshold = 1800; +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/config/MybatisMetaObjectHandler.java b/iam-api/src/main/java/com/gxwebsoft/common/core/config/MybatisMetaObjectHandler.java new file mode 100644 index 0000000..e11878d --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/config/MybatisMetaObjectHandler.java @@ -0,0 +1,28 @@ +package com.gxwebsoft.common.core.config; + +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import org.apache.ibatis.reflection.MetaObject; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * 公共字段自动填充:创建时间 / 更新时间。 + * + * @author WebSoft + */ +@Component +public class MybatisMetaObjectHandler implements MetaObjectHandler { + + @Override + public void insertFill(MetaObject metaObject) { + LocalDateTime now = LocalDateTime.now(); + strictInsertFill(metaObject, "createTime", LocalDateTime.class, now); + strictInsertFill(metaObject, "updateTime", LocalDateTime.class, now); + } + + @Override + public void updateFill(MetaObject metaObject) { + strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/config/MybatisPlusConfig.java b/iam-api/src/main/java/com/gxwebsoft/common/core/config/MybatisPlusConfig.java new file mode 100644 index 0000000..13bc801 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/config/MybatisPlusConfig.java @@ -0,0 +1,57 @@ +package com.gxwebsoft.common.core.config; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import com.gxwebsoft.common.core.context.TenantContext; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.expression.NullValue; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Set; + +/** + * MyBatis-Plus 配置:多租户 + 分页。 + * + *

租户 ID 由 {@link TenantContext} 在请求进入时解析完成,这里只负责把它拼进 SQL。

+ * + * @author WebSoft + */ +@Configuration +public class MybatisPlusConfig { + + /** 不做租户隔离的公共表:平台级元数据,或本身已带租户条件由业务显式指定 */ + private static final Set IGNORE_TABLES = Set.of( + "sys_tenant", + "sys_menu", + "sys_user_role", + "sys_role_menu", + "sys_login_record", + "iam_client", + "iam_auth_code"); + + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() { + + @Override + public Expression getTenantId() { + Long tenantId = TenantContext.getTenantId(); + return tenantId == null ? new NullValue() : new LongValue(tenantId); + } + + @Override + public boolean ignoreTable(String tableName) { + return TenantContext.isIgnoreTenant() + || IGNORE_TABLES.contains(tableName.toLowerCase()); + } + })); + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); + return interceptor; + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/config/SecurityConfig.java b/iam-api/src/main/java/com/gxwebsoft/common/core/config/SecurityConfig.java new file mode 100644 index 0000000..fd6a167 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/config/SecurityConfig.java @@ -0,0 +1,119 @@ +package com.gxwebsoft.common.core.config; + +import com.gxwebsoft.common.core.security.JwtAccessDeniedHandler; +import com.gxwebsoft.common.core.security.JwtAuthenticationEntryPoint; +import com.gxwebsoft.common.core.security.JwtAuthenticationFilter; +import com.gxwebsoft.common.core.security.JwtUtil; +import com.gxwebsoft.common.core.security.LoginUserProvider; +import com.gxwebsoft.common.core.security.RevokedTokenStore; +import com.gxwebsoft.common.core.web.ResponseWriter; +import com.gxwebsoft.common.core.web.TenantFilter; +import jakarta.servlet.DispatcherType; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; +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.http.SessionCreationPolicy; +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; +import org.springframework.security.web.header.writers.StaticHeadersWriter; + +/** + * Spring Security 配置。 + * + * @author WebSoft + */ +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + private final ConfigProperties configProperties; + + public SecurityConfig(ConfigProperties configProperties) { + this.configProperties = configProperties; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, + JwtAuthenticationFilter jwtAuthenticationFilter, + TenantFilter tenantFilter, + JwtAuthenticationEntryPoint entryPoint, + JwtAccessDeniedHandler accessDeniedHandler) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .cors(Customizer.withDefaults()) + .logout(logout -> logout.disable()) + .headers(headers -> headers + // 拒绝被第三方站点 iframe 嵌套(点击劫持),同源内嵌(接口文档页)保留 + .frameOptions(frame -> frame.sameOrigin()) + // TLS 在反向代理终结,这里统一补 HSTS,避免降级 / SSL 剥离 + .addHeaderWriter(new StaticHeadersWriter("Strict-Transport-Security", + "max-age=31536000; includeSubDomains")) + .addHeaderWriter(new StaticHeadersWriter("Referrer-Policy", "no-referrer"))) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(registry -> registry + // 放行内部 ERROR 转发,否则过滤器链里的异常会被包装成 401,掩盖真实错误 + .dispatcherTypeMatchers(DispatcherType.ERROR).permitAll() + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers("/api/v1/oauth/token", "/api/v1/oauth/jwks").permitAll() + .requestMatchers(configProperties.getPermitAllPaths().toArray(String[]::new)).permitAll() + .anyRequest().authenticated()) + .exceptionHandling(handling -> handling + .authenticationEntryPoint(entryPoint) + .accessDeniedHandler(accessDeniedHandler)) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(tenantFilter, JwtAuthenticationFilter.class); + return http.build(); + } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter(JwtUtil jwtUtil, LoginUserProvider loginUserProvider, + ResponseWriter responseWriter, + RevokedTokenStore revokedTokenStore) { + return new JwtAuthenticationFilter(jwtUtil, loginUserProvider, responseWriter, revokedTokenStore); + } + + @Bean + public TenantFilter tenantFilter() { + return new TenantFilter(); + } + + /** 过滤器只挂在 Security 链上,这里关闭 Servlet 容器的重复注册 */ + @Bean + public FilterRegistrationBean jwtFilterRegistration(JwtAuthenticationFilter filter) { + FilterRegistrationBean registration = new FilterRegistrationBean<>(filter); + registration.setEnabled(false); + return registration; + } + + @Bean + public FilterRegistrationBean tenantFilterRegistration(TenantFilter filter) { + FilterRegistrationBean registration = new FilterRegistrationBean<>(filter); + registration.setEnabled(false); + return registration; + } + + /** + * 默认的登录用户加载器:直接把 token 载体作为主体,不查库。 + * + *

system 模块提供自己的 {@link LoginUserProvider} 实现后,本 Bean 自动失效。

+ */ + @Bean + @ConditionalOnMissingBean(LoginUserProvider.class) + public LoginUserProvider defaultLoginUserProvider() { + return subject -> subject; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/config/WebMvcConfig.java b/iam-api/src/main/java/com/gxwebsoft/common/core/config/WebMvcConfig.java new file mode 100644 index 0000000..83e3b9b --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/config/WebMvcConfig.java @@ -0,0 +1,31 @@ +package com.gxwebsoft.common.core.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web MVC 配置:跨域。 + * + * @author WebSoft + */ +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final ConfigProperties configProperties; + + public WebMvcConfig(ConfigProperties configProperties) { + this.configProperties = configProperties; + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns(configProperties.getCorsAllowedOriginPatterns().toArray(String[]::new)) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + // 令牌放在 Authorization 头里由前端显式携带;开启凭证只为兼容以 Cookie 绑定的图形验证码 + .allowCredentials(true) + .maxAge(3600); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/context/TenantAware.java b/iam-api/src/main/java/com/gxwebsoft/common/core/context/TenantAware.java new file mode 100644 index 0000000..98bfc43 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/context/TenantAware.java @@ -0,0 +1,12 @@ +package com.gxwebsoft.common.core.context; + +/** + * 租户感知:登录主体实现该接口后,租户过滤器可以直接从主体上取到租户 ID。 + * + * @author WebSoft + */ +public interface TenantAware { + + /** 所属租户 ID */ + Long getTenantId(); +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/context/TenantContext.java b/iam-api/src/main/java/com/gxwebsoft/common/core/context/TenantContext.java new file mode 100644 index 0000000..d9496c6 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/context/TenantContext.java @@ -0,0 +1,70 @@ +package com.gxwebsoft.common.core.context; + +import java.util.function.Supplier; + +/** + * 租户上下文。 + * + *

由 {@code TenantFilter} 在请求进入时写入,供多租户插件与业务代码读取。 + * 使用 {@link ThreadLocal},在虚拟线程下同样成立(每个虚拟线程各自持有副本)。

+ * + * @author WebSoft + */ +public final class TenantContext { + + private static final ThreadLocal TENANT_ID = new ThreadLocal<>(); + private static final ThreadLocal IGNORE_TENANT = new ThreadLocal<>(); + + private TenantContext() { + } + + /** 当前租户 ID,未识别到时返回 null */ + public static Long getTenantId() { + return TENANT_ID.get(); + } + + public static void setTenantId(Long tenantId) { + TENANT_ID.set(tenantId); + } + + /** 是否忽略租户隔离 */ + public static boolean isIgnoreTenant() { + return Boolean.TRUE.equals(IGNORE_TENANT.get()); + } + + public static void setIgnoreTenant(boolean ignore) { + IGNORE_TENANT.set(ignore); + } + + /** 清理上下文,必须在请求结束时调用,避免线程复用造成串租户 */ + public static void clear() { + TENANT_ID.remove(); + IGNORE_TENANT.remove(); + } + + /** 在忽略租户隔离的上下文中执行并返回结果 */ + public static T callIgnoreTenant(Supplier supplier) { + boolean origin = isIgnoreTenant(); + try { + setIgnoreTenant(true); + return supplier.get(); + } finally { + setIgnoreTenant(origin); + } + } + + /** + * 在指定租户上下文中执行并返回结果。 + * + *

用于登录等「租户尚未进入上下文、但需要按租户查数据」的场景。

+ */ + public static T callWithTenant(Long tenantId, Supplier supplier) { + Long origin = getTenantId(); + try { + setTenantId(tenantId); + return supplier.get(); + } finally { + setTenantId(origin); + } + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/exception/BusinessException.java b/iam-api/src/main/java/com/gxwebsoft/common/core/exception/BusinessException.java new file mode 100644 index 0000000..b38db84 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/exception/BusinessException.java @@ -0,0 +1,32 @@ +package com.gxwebsoft.common.core.exception; + +import com.gxwebsoft.common.core.Constants; + +import java.io.Serial; + +/** + * 业务异常:由全局异常处理器翻译为 {@code ApiResult} 失败响应。 + * + * @author WebSoft + */ +public class BusinessException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + /** 业务状态码,默认 1(失败) */ + private final int code; + + public BusinessException(String message) { + this(Constants.RESULT_ERROR_CODE, message); + } + + public BusinessException(int code, String message) { + super(message); + this.code = code; + } + + public int getCode() { + return code; + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/exception/GlobalExceptionHandler.java b/iam-api/src/main/java/com/gxwebsoft/common/core/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..f79d3a4 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/exception/GlobalExceptionHandler.java @@ -0,0 +1,50 @@ +package com.gxwebsoft.common.core.exception; + +import com.gxwebsoft.common.core.Constants; +import com.gxwebsoft.common.core.web.ApiResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * 全局异常处理。 + * + *

控制器抛出的业务异常统一翻译为 HTTP 200 + {@code code != 0},与既有前端契约一致: + * 前端 axios 拦截器只在 HTTP 401 时清理登录态,业务错误按 {@code message} 提示。

+ * + * @author WebSoft + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(BusinessException.class) + public ApiResult handleBusiness(BusinessException e) { + return ApiResult.fail(e.getCode(), e.getMessage()); + } + + /** 参数校验失败:取第一条字段错误,避免把整个校验列表丢给用户 */ + @ExceptionHandler({MethodArgumentNotValidException.class, BindException.class}) + public ApiResult handleValidation(Exception e) { + FieldError fieldError = e instanceof MethodArgumentNotValidException ex + ? ex.getBindingResult().getFieldError() + : ((BindException) e).getBindingResult().getFieldError(); + String message = fieldError == null ? "请求参数不合法" : fieldError.getDefaultMessage(); + return ApiResult.fail(message); + } + + /** 未预期的异常:日志留全量堆栈,响应只给出通用提示,避免泄露内部实现 */ + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ApiResult handleUnexpected(Exception e) { + log.error("未处理的异常", e); + return ApiResult.fail(Constants.RESULT_ERROR_CODE, "服务异常,请稍后重试"); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtAccessDeniedHandler.java b/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..d447731 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtAccessDeniedHandler.java @@ -0,0 +1,33 @@ +package com.gxwebsoft.common.core.security; + +import com.gxwebsoft.common.core.Constants; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.ResponseWriter; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * 无权限处理:统一返回 {@code code=403}。 + * + * @author WebSoft + */ +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ResponseWriter responseWriter; + + public JwtAccessDeniedHandler(ResponseWriter responseWriter) { + this.responseWriter = responseWriter; + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + responseWriter.write(response, ApiResult.fail(Constants.UNAUTHORIZED_CODE, Constants.UNAUTHORIZED_MSG)); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationEntryPoint.java b/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..5b4dc78 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationEntryPoint.java @@ -0,0 +1,33 @@ +package com.gxwebsoft.common.core.security; + +import com.gxwebsoft.common.core.Constants; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.ResponseWriter; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * 未认证处理:统一返回 {@code code=401}。 + * + * @author WebSoft + */ +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ResponseWriter responseWriter; + + public JwtAuthenticationEntryPoint(ResponseWriter responseWriter) { + this.responseWriter = responseWriter; + } + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + responseWriter.write(response, ApiResult.fail(Constants.UNAUTHENTICATED_CODE, Constants.UNAUTHENTICATED_MSG)); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationFilter.java b/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..d181184 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationFilter.java @@ -0,0 +1,105 @@ +package com.gxwebsoft.common.core.security; + +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.common.core.Constants; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.ResponseWriter; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collection; + +/** + * 携带会话 token 的请求认证过滤器。 + * + *

不做 servlet 容器的自动注册,只在 Spring Security 过滤器链中生效, + * 避免过滤器被重复执行两次。

+ * + * @author WebSoft + */ +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class); + + private final JwtUtil jwtUtil; + private final LoginUserProvider loginUserProvider; + private final ResponseWriter responseWriter; + private final RevokedTokenStore revokedTokenStore; + + public JwtAuthenticationFilter(JwtUtil jwtUtil, LoginUserProvider loginUserProvider, + ResponseWriter responseWriter, RevokedTokenStore revokedTokenStore) { + this.jwtUtil = jwtUtil; + this.loginUserProvider = loginUserProvider; + this.responseWriter = responseWriter; + this.revokedTokenStore = revokedTokenStore; + } + + /** + * 令牌端点(/api/v1/oauth/**)用 client_id + client_secret 或 X-Internal-Key 自证身份, + * 不认会话令牌。 + */ + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return request.getRequestURI().startsWith("/api/v1/"); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + String token = JwtUtil.getAccessToken(request); + if (StrUtil.isBlank(token)) { + chain.doFilter(request, response); + return; + } + + try { + Claims claims = jwtUtil.parseToken(token); + // 登出 / 强制下线:令牌即使签名有效且未过期,也拒绝继续使用 + if (revokedTokenStore.isRevoked(claims.getId())) { + log.debug("token 已被吊销: jti={}", claims.getId()); + responseWriter.write(response, + ApiResult.fail(Constants.BAD_CREDENTIALS_CODE, Constants.BAD_CREDENTIALS_MSG)); + return; + } + JwtSubject subject = jwtUtil.toSubject(claims); + Object principal = loginUserProvider.loadPrincipal(subject); + if (principal == null) { + throw new IllegalStateException("用户不存在或已被禁用"); + } + Collection authorities = loginUserProvider.loadAuthorities(principal); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(principal, null, authorities); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + + // 临近过期自动续签,避免用户使用中被登出 + if (jwtUtil.needRefresh(claims)) { + response.setHeader(Constants.TOKEN_HEADER_NAME, jwtUtil.buildToken(subject)); + } + } catch (ExpiredJwtException e) { + log.debug("token 已过期: {}", e.getMessage()); + responseWriter.write(response, + ApiResult.fail(Constants.TOKEN_EXPIRED_CODE, Constants.TOKEN_EXPIRED_MSG)); + return; + } catch (Exception e) { + log.debug("token 校验失败: {}", e.getMessage()); + responseWriter.write(response, + ApiResult.fail(Constants.BAD_CREDENTIALS_CODE, Constants.BAD_CREDENTIALS_MSG)); + return; + } + + chain.doFilter(request, response); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtSubject.java b/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtSubject.java new file mode 100644 index 0000000..b797297 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtSubject.java @@ -0,0 +1,17 @@ +package com.gxwebsoft.common.core.security; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 会话令牌载体:只放鉴权必需的最小信息。 + * + * @param username 账号 + * @param tenantId 租户 ID + * @author WebSoft + */ +public record JwtSubject(String username, Long tenantId) implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtUtil.java b/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtUtil.java new file mode 100644 index 0000000..ab8be9c --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtUtil.java @@ -0,0 +1,113 @@ +package com.gxwebsoft.common.core.security; + +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.common.core.Constants; +import com.gxwebsoft.common.core.config.JwtProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; + +/** + * 控制台会话令牌工具(HS256)。 + * + * @author WebSoft + */ +@Component +public class JwtUtil { + + private static final String CLAIM_TENANT_ID = "tenantId"; + + private final JwtProperties properties; + private final SecretKey key; + + public JwtUtil(JwtProperties properties) { + this.properties = properties; + this.key = buildKey(properties.getSecret()); + } + + /** + * 从请求头提取 token,自动去掉 Bearer 前缀。 + * + *

只认 {@code Authorization} 请求头:URL 里的令牌会落进反向代理 access log、 + * Referer、浏览器历史与中间缓存,属于明确的劝退用法。

+ */ + public static String getAccessToken(HttpServletRequest request) { + String token = request.getHeader(Constants.TOKEN_HEADER_NAME); + if (StrUtil.isBlank(token)) { + return null; + } + if (token.startsWith(Constants.TOKEN_TYPE)) { + token = StrUtil.removePrefix(token, Constants.TOKEN_TYPE).trim(); + } + return token; + } + + /** 签发 token:带 {@code jti} 供登出时吊销 */ + public String buildToken(JwtSubject subject) { + Instant now = Instant.now(); + return Jwts.builder() + .id(IdUtil.fastSimpleUUID()) + .subject(subject.username()) + .claim(CLAIM_TENANT_ID, subject.tenantId()) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plusSeconds(properties.getExpire()))) + .signWith(key) + .compact(); + } + + /** 解析并校验 token,失败时抛出 jjwt 的运行时异常 */ + public Claims parseToken(String token) { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + public JwtSubject toSubject(Claims claims) { + return new JwtSubject(claims.getSubject(), claims.get(CLAIM_TENANT_ID, Long.class)); + } + + /** token 剩余有效秒数 */ + public long remainingSeconds(Claims claims) { + return (claims.getExpiration().getTime() - System.currentTimeMillis()) / 1000; + } + + /** 是否需要续签 */ + public boolean needRefresh(Claims claims) { + return remainingSeconds(claims) < properties.getRefreshThreshold(); + } + + /** 令牌有效期(秒),登录响应里回给前端 */ + public long getExpireSeconds() { + return properties.getExpire(); + } + + /** 构造签名密钥:支持 Base64 与明文两种写法,明文要求不少于 32 字节 */ + private static SecretKey buildKey(String secret) { + // 占位符未被解析时会以 "${JWT_SECRET}" 这样的字面量传入,这里统一按「未配置」处理 + if (StrUtil.isBlank(secret) || secret.contains("${")) { + throw new IllegalStateException( + "未配置 JWT 密钥:请设置环境变量 JWT_SECRET(HS256 要求 Base64 或不少于 32 字节的明文)"); + } + byte[] bytes; + try { + bytes = Decoders.BASE64.decode(secret); + } catch (Exception e) { + bytes = secret.getBytes(StandardCharsets.UTF_8); + } + if (bytes.length < 32) { + throw new IllegalStateException("JWT 密钥长度不足:HS256 要求至少 32 字节"); + } + return Keys.hmacShaKeyFor(bytes); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/security/LoginUserProvider.java b/iam-api/src/main/java/com/gxwebsoft/common/core/security/LoginUserProvider.java new file mode 100644 index 0000000..78c97e3 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/security/LoginUserProvider.java @@ -0,0 +1,25 @@ +package com.gxwebsoft.common.core.security; + +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; +import java.util.List; + +/** + * 登录主体与权限的加载扩展点。 + * + *

框架层不认识业务实体,认证过滤器通过本接口拿到「主体对象」和「权限集合」; + * system 模块提供基于 {@code sys_user} / {@code sys_menu} 的实现。

+ * + * @author WebSoft + */ +public interface LoginUserProvider { + + /** 按令牌载体加载登录主体;返回 null 表示用户不存在或已停用 */ + Object loadPrincipal(JwtSubject subject); + + /** 加载权限集合,默认无权限 */ + default Collection loadAuthorities(Object principal) { + return List.of(); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/security/RevokedTokenStore.java b/iam-api/src/main/java/com/gxwebsoft/common/core/security/RevokedTokenStore.java new file mode 100644 index 0000000..5328d65 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/security/RevokedTokenStore.java @@ -0,0 +1,36 @@ +package com.gxwebsoft.common.core.security; + +import com.gxwebsoft.common.core.cache.CacheStore; +import org.springframework.stereotype.Component; + +/** + * 令牌吊销名单。 + * + *

JWT 本身无状态,只让客户端丢弃令牌的话,令牌在有效期内仍然可用。 + * 登出 / 强制下线时按 {@code jti} 写入吊销名单,有效期与令牌剩余寿命对齐。

+ * + * @author WebSoft + */ +@Component +public class RevokedTokenStore { + + private static final String KEY_PREFIX = "iam:revoked:"; + + private final CacheStore cacheStore; + + public RevokedTokenStore(CacheStore cacheStore) { + this.cacheStore = cacheStore; + } + + /** 吊销令牌,ttlSeconds 为令牌剩余有效期 */ + public void revoke(String jti, long ttlSeconds) { + if (jti == null || ttlSeconds <= 0) { + return; + } + cacheStore.set(KEY_PREFIX + jti, "1", ttlSeconds); + } + + public boolean isRevoked(String jti) { + return jti != null && cacheStore.exists(KEY_PREFIX + jti); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/util/IpUtils.java b/iam-api/src/main/java/com/gxwebsoft/common/core/util/IpUtils.java new file mode 100644 index 0000000..c16b738 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/util/IpUtils.java @@ -0,0 +1,42 @@ +package com.gxwebsoft.common.core.util; + +import cn.hutool.core.util.StrUtil; +import jakarta.servlet.http.HttpServletRequest; + +/** + * 客户端 IP 解析。 + * + * @author WebSoft + */ +public final class IpUtils { + + private static final String UNKNOWN = "unknown"; + + private IpUtils() { + } + + /** 取真实客户端 IP:优先反向代理头,其次 RemoteAddr */ + public static String clientIp(HttpServletRequest request) { + if (request == null) { + return null; + } + for (String header : new String[]{"X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP"}) { + String value = request.getHeader(header); + if (StrUtil.isNotBlank(value) && !UNKNOWN.equalsIgnoreCase(value)) { + // X-Forwarded-For 可能是「client, proxy1, proxy2」,第一个才是客户端 + int comma = value.indexOf(','); + String ip = comma > 0 ? value.substring(0, comma) : value; + return truncate(ip.trim()); + } + } + return truncate(request.getRemoteAddr()); + } + + /** 列宽 64,超长直接截断,避免写库失败 */ + private static String truncate(String ip) { + if (ip == null) { + return null; + } + return ip.length() <= 64 ? ip : ip.substring(0, 64); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/web/ApiResult.java b/iam-api/src/main/java/com/gxwebsoft/common/core/web/ApiResult.java new file mode 100644 index 0000000..915f7b8 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/web/ApiResult.java @@ -0,0 +1,101 @@ +package com.gxwebsoft.common.core.web; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.gxwebsoft.common.core.Constants; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 统一返回结果。 + * + *

保持 {@code code / message / data} 契约:前端 axios 拦截器按 {@code code === 0} 判断成功。

+ * + * @param 数据类型 + * @author WebSoft + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "统一返回结果") +public class ApiResult implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "状态码") + private Integer code; + + @Schema(description = "状态信息") + private String message; + + @Schema(description = "返回数据") + private T data; + + public ApiResult() { + } + + public ApiResult(Integer code, String message) { + this(code, message, null); + } + + public ApiResult(Integer code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + } + + public static ApiResult ok() { + return new ApiResult<>(Constants.RESULT_OK_CODE, Constants.RESULT_OK_MSG); + } + + public static ApiResult ok(T data) { + return new ApiResult<>(Constants.RESULT_OK_CODE, Constants.RESULT_OK_MSG, data); + } + + public static ApiResult ok(String message, T data) { + return new ApiResult<>(Constants.RESULT_OK_CODE, message, data); + } + + public static ApiResult ok(String message) { + return new ApiResult<>(Constants.RESULT_OK_CODE, message, null); + } + + public static ApiResult fail() { + return new ApiResult<>(Constants.RESULT_ERROR_CODE, Constants.RESULT_ERROR_MSG); + } + + public static ApiResult fail(String message) { + return new ApiResult<>(Constants.RESULT_ERROR_CODE, message); + } + + public static ApiResult fail(Integer code, String message) { + return new ApiResult<>(code, message); + } + + public Integer getCode() { + return code; + } + + public ApiResult setCode(Integer code) { + this.code = code; + return this; + } + + public String getMessage() { + return message; + } + + public ApiResult setMessage(String message) { + this.message = message; + return this; + } + + public T getData() { + return data; + } + + public ApiResult setData(T data) { + this.data = data; + return this; + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/web/BaseController.java b/iam-api/src/main/java/com/gxwebsoft/common/core/web/BaseController.java new file mode 100644 index 0000000..d817543 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/web/BaseController.java @@ -0,0 +1,66 @@ +package com.gxwebsoft.common.core.web; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.gxwebsoft.common.core.Constants; +import com.gxwebsoft.common.core.context.TenantContext; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.propertyeditors.StringTrimmerEditor; +import org.springframework.web.bind.WebDataBinder; +import org.springframework.web.bind.annotation.InitBinder; + +import java.util.List; + +/** + * Controller 基类:统一成功/失败返回、请求参数处理。 + * + * @author WebSoft + */ +public abstract class BaseController { + + @Resource + protected HttpServletRequest request; + + public ApiResult success() { + return new ApiResult<>(Constants.RESULT_OK_CODE, Constants.RESULT_OK_MSG); + } + + public ApiResult success(String message) { + return new ApiResult<>(Constants.RESULT_OK_CODE, message); + } + + public ApiResult success(T data) { + return new ApiResult<>(Constants.RESULT_OK_CODE, Constants.RESULT_OK_MSG, data); + } + + public ApiResult success(String message, T data) { + return new ApiResult<>(Constants.RESULT_OK_CODE, message, data); + } + + public ApiResult> success(List list, Long count) { + return success(new PageResult<>(list, count)); + } + + public ApiResult> success(IPage page) { + return success(page.getRecords(), page.getTotal()); + } + + public ApiResult fail(String message) { + return new ApiResult<>(Constants.RESULT_ERROR_CODE, message); + } + + /** 请求参数的空字符串转为 null,避免把 "" 当成有效值写库 */ + @InitBinder + public void initBinder(WebDataBinder binder) { + binder.registerCustomEditor(String.class, new StringTrimmerEditor(true)); + } + + /** 当前请求的租户 ID */ + public Long getTenantId() { + return TenantContext.getTenantId(); + } + + public String getAuthorization() { + return request.getHeader(Constants.TOKEN_HEADER_NAME); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/web/BaseParam.java b/iam-api/src/main/java/com/gxwebsoft/common/core/web/BaseParam.java new file mode 100644 index 0000000..15c0325 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/web/BaseParam.java @@ -0,0 +1,58 @@ +package com.gxwebsoft.common.core.web; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.gxwebsoft.common.core.annotation.QueryField; +import com.gxwebsoft.common.core.annotation.QueryType; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 查询参数基类:分页、排序、时间范围等公共字段。 + * + * @author WebSoft + */ +@Getter +@Setter +public class BaseParam implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @TableField(exist = false) + @Schema(description = "分页查询页码") + private Long page; + + @TableField(exist = false) + @Schema(description = "分页查询每页数量") + private Long limit; + + @TableField(exist = false) + @Schema(description = "排序字段或 SQL,如 `id asc, name desc`") + private String sort; + + @TableField(exist = false) + @Schema(description = "sort 是字段名时的排序方式:asc 升序、desc 降序") + private String order; + + @QueryField(value = "create_time", type = QueryType.GE) + @TableField(exist = false) + @Schema(description = "创建时间起始值") + private String createTimeStart; + + @QueryField(value = "create_time", type = QueryType.LE) + @TableField(exist = false) + @Schema(description = "创建时间结束值") + private String createTimeEnd; + + @TableField(exist = false) + @Schema(description = "租户 ID") + private Long tenantId; + + @TableField(exist = false) + @Schema(description = "通用模糊搜索关键字") + private String keywords; +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/web/PageParam.java b/iam-api/src/main/java/com/gxwebsoft/common/core/web/PageParam.java new file mode 100644 index 0000000..00a8919 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/web/PageParam.java @@ -0,0 +1,213 @@ +package com.gxwebsoft.common.core.web; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.OrderItem; +import com.baomidou.mybatisplus.core.metadata.TableFieldInfo; +import com.baomidou.mybatisplus.core.metadata.TableInfo; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.gxwebsoft.common.core.Constants; +import com.gxwebsoft.common.core.annotation.QueryField; +import com.gxwebsoft.common.core.annotation.QueryType; +import com.gxwebsoft.common.core.exception.BusinessException; + +import java.io.Serial; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 分页 + 排序 + 条件搜索参数封装。 + * + *

查询条件由 {@link BaseParam} 子类字段上的 {@link QueryField} 注解声明, + * {@link #getWrapper()} 负责翻译成 MyBatis-Plus 的条件构造器。

+ * + * @param 实体类型 + * @param 查询参数类型 + * @author WebSoft + */ +public class PageParam extends Page { + + @Serial + private static final long serialVersionUID = 1L; + + /** 合法标识符:字母 / 数字 / 下划线 */ + private static final Pattern IDENTIFIER = Pattern.compile("^[A-Za-z_][A-Za-z0-9_]*$"); + + /** 禁止参与排序的敏感列:按敏感列排序可用于盲注旁路 */ + private static final Set NON_SORTABLE_COLUMNS = Set.of("password", "client_secret", "secret"); + + /** 最大每页条数,避免一次拉全表 */ + private static final long MAX_SIZE = 200L; + + private final Class entityClass; + + private final U where; + + public PageParam(Class entityClass, U where) { + super(); + this.entityClass = entityClass; + this.where = where; + if (where != null) { + if (where.getPage() != null) { + setCurrent(Math.max(1L, where.getPage())); + } + if (where.getLimit() != null) { + setSize(Math.min(MAX_SIZE, Math.max(1L, where.getLimit()))); + } + if (StrUtil.isNotBlank(where.getSort())) { + setOrders(parseUserSort(where)); + } + } + } + + /** 默认排序:调用方未指定 sort 时生效,兜底保证分页结果稳定 */ + public PageParam setDefaultOrder(String orderSql) { + if (orders() == null || orders().isEmpty()) { + List items = new ArrayList<>(); + for (String item : orderSql.split(",")) { + String trimmed = item.trim(); + if (trimmed.isEmpty()) { + continue; + } + String[] parts = trimmed.split("\\s+"); + boolean asc = parts.length == 1 || !Constants.ORDER_DESC_VALUE.equals(parts[parts.length - 1]); + items.add(asc ? OrderItem.asc(parts[0]) : OrderItem.desc(parts[0])); + } + setOrders(items); + } + return this; + } + + /** 构造查询条件 */ + public QueryWrapper getWrapper() { + QueryWrapper wrapper = new QueryWrapper<>(); + if (where == null) { + return wrapper; + } + List keywordColumns = new ArrayList<>(); + for (Field field : allFields(where.getClass())) { + QueryField annotation = field.getAnnotation(QueryField.class); + if (annotation == null) { + continue; + } + String column = StrUtil.isBlank(annotation.value()) + ? StrUtil.toUnderlineCase(field.getName()) + : annotation.value(); + Object value = readField(field, where); + if (annotation.keywords()) { + keywordColumns.add(column); + } + if (value == null || (value instanceof String str && StrUtil.isBlank(str))) { + continue; + } + applyCondition(wrapper, column, annotation.type(), value); + } + // 关键字搜索:多个字段之间 OR,整体再与其它条件 AND + if (StrUtil.isNotBlank(where.getKeywords()) && !keywordColumns.isEmpty()) { + String keyword = where.getKeywords().trim(); + wrapper.and(nested -> { + for (int i = 0; i < keywordColumns.size(); i++) { + if (i > 0) { + nested.or(); + } + nested.like(keywordColumns.get(i), keyword); + } + }); + } + return wrapper; + } + + private void applyCondition(QueryWrapper wrapper, String column, QueryType type, Object value) { + switch (type) { + case EQ -> wrapper.eq(column, value); + case NE -> wrapper.ne(column, value); + case GE -> wrapper.ge(column, value); + case LE -> wrapper.le(column, value); + case LIKE -> wrapper.like(column, value); + case IN -> { + List values = Arrays.stream(String.valueOf(value).split(",")) + .map(String::trim).filter(StrUtil::isNotBlank).toList(); + if (!values.isEmpty()) { + wrapper.in(column, values); + } + } + } + } + + /** + * 解析用户传入的 {@code sort}。 + * + *

ORDER BY 的列名无法参数化,只能靠白名单拦:列名必须命中实体真实字段, + * 且不属于敏感列,从而同时挡住表达式注入与「按敏感列排序」的盲注旁路。

+ */ + private List parseUserSort(U where) { + List orders = new ArrayList<>(); + for (String item : where.getSort().split(",")) { + String trimmed = item.trim(); + if (trimmed.isEmpty()) { + continue; + } + String[] parts = trimmed.split("\\s+"); + String column = requireSortableColumn(parts[0]); + boolean asc = parts.length > 1 + ? !Constants.ORDER_DESC_VALUE.equals(parts[parts.length - 1]) + : !Constants.ORDER_DESC_VALUE.equals(where.getOrder()); + orders.add(asc ? OrderItem.asc(column) : OrderItem.desc(column)); + } + return orders; + } + + private String requireSortableColumn(String raw) { + String column = StrUtil.toUnderlineCase(raw); + if (!IDENTIFIER.matcher(column).matches() || NON_SORTABLE_COLUMNS.contains(column.toLowerCase())) { + throw new BusinessException("不支持的排序字段:" + raw); + } + Set columns = entityColumns(); + if (!columns.isEmpty() && !columns.contains(column)) { + throw new BusinessException("不支持的排序字段:" + raw); + } + return column; + } + + /** 实体真实列名;取不到元数据时返回空集合,退化为「合法标识符」校验 */ + private Set entityColumns() { + if (entityClass == null) { + return Set.of(); + } + TableInfo info = TableInfoHelper.getTableInfo(entityClass); + if (info == null) { + return Set.of(); + } + Set columns = new HashSet<>(); + if (StrUtil.isNotBlank(info.getKeyColumn())) { + columns.add(info.getKeyColumn()); + } + for (TableFieldInfo field : info.getFieldList()) { + columns.add(field.getColumn()); + } + return columns; + } + + private static List allFields(Class type) { + List fields = new ArrayList<>(); + for (Class current = type; current != null && current != Object.class; current = current.getSuperclass()) { + fields.addAll(Arrays.asList(current.getDeclaredFields())); + } + return fields; + } + + private static Object readField(Field field, Object target) { + try { + field.setAccessible(true); + return field.get(target); + } catch (IllegalAccessException e) { + throw new IllegalStateException("读取查询条件失败:" + field.getName(), e); + } + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/web/PageResult.java b/iam-api/src/main/java/com/gxwebsoft/common/core/web/PageResult.java new file mode 100644 index 0000000..9536e29 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/web/PageResult.java @@ -0,0 +1,52 @@ +package com.gxwebsoft.common.core.web; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 分页返回结构。 + * + * @param 记录类型 + * @author WebSoft + */ +@Schema(description = "分页返回结构") +public class PageResult implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "当前页数据") + private List list; + + @Schema(description = "总数量") + private Long total; + + public PageResult() { + } + + public PageResult(List list, Long total) { + this.list = list; + this.total = total; + } + + public List getList() { + return list; + } + + public PageResult setList(List list) { + this.list = list; + return this; + } + + public Long getTotal() { + return total; + } + + public PageResult setTotal(Long total) { + this.total = total; + return this; + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/web/ResponseWriter.java b/iam-api/src/main/java/com/gxwebsoft/common/core/web/ResponseWriter.java new file mode 100644 index 0000000..1ba2cac --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/web/ResponseWriter.java @@ -0,0 +1,34 @@ +package com.gxwebsoft.common.core.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** + * 过滤器链中的统一响应输出。 + * + *

过滤器在进入 Spring MVC 之前就可能拒绝请求,此时没有 {@code @RestControllerAdvice}, + * 只能自己写响应体。

+ * + * @author WebSoft + */ +@Component +public class ResponseWriter { + + private final ObjectMapper objectMapper; + + public ResponseWriter(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public void write(HttpServletResponse response, ApiResult body) throws IOException { + response.setStatus(HttpServletResponse.SC_OK); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write(objectMapper.writeValueAsString(body)); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/web/TenantFilter.java b/iam-api/src/main/java/com/gxwebsoft/common/core/web/TenantFilter.java new file mode 100644 index 0000000..a55e5fb --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/core/web/TenantFilter.java @@ -0,0 +1,64 @@ +package com.gxwebsoft.common.core.web; + +import com.gxwebsoft.common.core.Constants; +import com.gxwebsoft.common.core.context.TenantAware; +import com.gxwebsoft.common.core.context.TenantContext; +import com.gxwebsoft.common.core.security.JwtSubject; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * 租户解析过滤器。 + * + *

解析顺序:登录主体 → 请求头 {@code tenantId}。解析结果写入 {@link TenantContext}, + * 请求结束时清理,防止线程复用串租户。

+ * + * @author WebSoft + */ +public class TenantFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + try { + TenantContext.setTenantId(resolveTenantId(request)); + chain.doFilter(request, response); + } finally { + TenantContext.clear(); + } + } + + private Long resolveTenantId(HttpServletRequest request) { + // 已认证请求:以令牌中的租户为准,禁止用请求头切换租户,否则会越权读取其他租户数据 + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null) { + Object principal = authentication.getPrincipal(); + if (principal instanceof JwtSubject subject) { + return subject.tenantId(); + } + if (principal instanceof TenantAware tenantAware) { + return tenantAware.getTenantId(); + } + } + // 未认证请求:请求头指定(登录、取验证码) + return parse(request.getHeader(Constants.TENANT_HEADER_NAME)); + } + + private Long parse(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return Long.valueOf(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/config/AuthProperties.java b/iam-api/src/main/java/com/gxwebsoft/common/system/config/AuthProperties.java new file mode 100644 index 0000000..fa13aaa --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/config/AuthProperties.java @@ -0,0 +1,40 @@ +package com.gxwebsoft.common.system.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 登录与验证码策略配置(前缀 {@code config.auth})。 + * + * @author WebSoft + */ +@Getter +@Setter +@ConfigurationProperties(prefix = "config.auth") +public class AuthProperties { + + /** 登录是否强制图形验证码;本地联调可临时设为 false */ + private boolean captchaEnabled = true; + + /** 图形验证码有效期(秒) */ + private long captchaExpire = 300; + + /** 登录失败锁定阈值:连续失败达到该值锁定账号 */ + private int loginFailThreshold = 5; + + /** 锁定持续时间(秒) */ + private long loginLockSeconds = 600; + + /** 是否开放邮箱验证码 */ + private boolean emailCaptchaEnabled = true; + + /** 是否开放开发者自助注册;默认关闭,账号由平台管理员开通 */ + private boolean registerEnabled = false; + + /** 发码接口的 IP 维度每小时上限 */ + private int codeSendIpHourlyLimit = 30; + + /** 发码接口的 IP 维度最小间隔(秒) */ + private int codeSendIpIntervalSeconds = 5; +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/config/MailProperties.java b/iam-api/src/main/java/com/gxwebsoft/common/system/config/MailProperties.java new file mode 100644 index 0000000..3d765a7 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/config/MailProperties.java @@ -0,0 +1,28 @@ +package com.gxwebsoft.common.system.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 邮件验证码配置(前缀 {@code config.mail})。 + * + * @author WebSoft + */ +@Getter +@Setter +@ConfigurationProperties(prefix = "config.mail") +public class MailProperties { + + /** + * 是否真正发信。false 或未配置 {@code spring.mail.host} 时验证码只写日志, + * 便于本地联调;生产必须开着并配好 SMTP。 + */ + private boolean enabled = true; + + /** 发件人地址,为空时取 {@code spring.mail.username} */ + private String from; + + /** 邮件主题 */ + private String subject = "云枢开放平台验证码"; +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/config/SmsConfig.java b/iam-api/src/main/java/com/gxwebsoft/common/system/config/SmsConfig.java new file mode 100644 index 0000000..1347615 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/config/SmsConfig.java @@ -0,0 +1,26 @@ +package com.gxwebsoft.common.system.config; + +import com.gxwebsoft.common.system.service.LoggingSmsSender; +import com.gxwebsoft.common.system.service.SmsSender; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 短信通道装配。 + * + *

{@code @ConditionalOnMissingBean} 必须写在 {@code @Bean} 方法上: + * 直接标在被扫描的 {@code @Component} 上时,条件判断会看到自己这条定义, + * 结果把自己排除掉,最终「一个 SmsSender 都没有」。

+ * + * @author WebSoft + */ +@Configuration +public class SmsConfig { + + @Bean + @ConditionalOnMissingBean(SmsSender.class) + public SmsSender loggingSmsSender(SmsProperties smsProperties) { + return new LoggingSmsSender(smsProperties); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/config/SmsProperties.java b/iam-api/src/main/java/com/gxwebsoft/common/system/config/SmsProperties.java new file mode 100644 index 0000000..e2f96b1 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/config/SmsProperties.java @@ -0,0 +1,34 @@ +package com.gxwebsoft.common.system.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 短信验证码配置(前缀 {@code config.sms})。 + * + * @author WebSoft + */ +@Getter +@Setter +@ConfigurationProperties(prefix = "config.sms") +public class SmsProperties { + + /** 是否真正下发短信;false 时验证码只写日志,便于本地联调 */ + private boolean enabled = false; + + /** 同一手机号的最小发送间隔(秒) */ + private long intervalSeconds = 60; + + /** 同一手机号每日上限 */ + private int dailyLimit = 10; + + /** 验证码有效期(秒) */ + private long codeExpire = 300; + + /** 短信签名 */ + private String signName; + + /** 短信模板 */ + private String templateCode; +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/controller/AuthController.java b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/AuthController.java new file mode 100644 index 0000000..6a5eccc --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/AuthController.java @@ -0,0 +1,103 @@ +package com.gxwebsoft.common.system.controller; + +import com.gxwebsoft.common.core.util.IpUtils; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.system.param.BindPhoneCaptchaParam; +import com.gxwebsoft.common.system.param.BindPhoneParam; +import com.gxwebsoft.common.system.param.ChangePasswordParam; +import com.gxwebsoft.common.system.param.EmailLoginParam; +import com.gxwebsoft.common.system.param.LoginParam; +import com.gxwebsoft.common.system.param.SelfResetPasswordParam; +import com.gxwebsoft.common.system.param.SmsLoginParam; +import com.gxwebsoft.common.system.result.LoginResult; +import com.gxwebsoft.common.system.result.UserInfoResult; +import com.gxwebsoft.common.system.service.AuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 认证接口。 + * + * @author WebSoft + */ +@Tag(name = "认证") +@RestController +@RequestMapping("/api") +public class AuthController extends BaseController { + + private final AuthService authService; + + public AuthController(AuthService authService) { + this.authService = authService; + } + + @Operation(summary = "账号密码登录") + @PostMapping("/login") + public ApiResult login(@Valid @RequestBody LoginParam param, HttpServletRequest request) { + return ApiResult.ok(authService.login(param, request)); + } + + @Operation(summary = "邮箱登录") + @PostMapping("/loginByEmail") + public ApiResult loginByEmail(@Valid @RequestBody EmailLoginParam param, + HttpServletRequest request) { + return ApiResult.ok(authService.loginByEmail(param, request)); + } + + @Operation(summary = "手机号验证码登录") + @PostMapping("/loginBySms") + public ApiResult loginBySms(@Valid @RequestBody SmsLoginParam param, + HttpServletRequest request) { + return ApiResult.ok(authService.loginBySms(param, request)); + } + + @Operation(summary = "登出") + @PostMapping("/logout") + public ApiResult logout(HttpServletRequest request) { + authService.logout(request); + return ApiResult.ok(); + } + + @Operation(summary = "当前登录用户信息") + @GetMapping("/me") + public ApiResult me() { + return ApiResult.ok(authService.currentUser()); + } + + @Operation(summary = "修改密码(需校验原密码,成功后当前会话失效)") + @PostMapping("/changePassword") + public ApiResult changePassword(@Valid @RequestBody ChangePasswordParam param, + HttpServletRequest request) { + authService.changePassword(param, request); + return ApiResult.ok("密码修改成功,请使用新密码登录"); + } + + @Operation(summary = "自助找回密码") + @PostMapping("/resetPassword") + public ApiResult resetPassword(@Valid @RequestBody SelfResetPasswordParam param, + HttpServletRequest request) { + return ApiResult.ok(authService.resetPasswordBySelf(param, request)); + } + + @Operation(summary = "绑定手机号:发送短信验证码") + @PostMapping("/bindPhone/captcha") + public ApiResult bindPhoneCaptcha(@Valid @RequestBody BindPhoneCaptchaParam param, + HttpServletRequest request) { + return ApiResult.ok(authService.sendBindPhoneCaptcha(param, IpUtils.clientIp(request))); + } + + @Operation(summary = "绑定 / 换绑手机号(换绑需校验原密码)") + @PostMapping("/bindPhone") + public ApiResult bindPhone(@Valid @RequestBody BindPhoneParam param, HttpServletRequest request) { + authService.bindPhone(param, request); + return ApiResult.ok("手机号绑定成功"); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/controller/CaptchaController.java b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/CaptchaController.java new file mode 100644 index 0000000..f37978a --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/CaptchaController.java @@ -0,0 +1,56 @@ +package com.gxwebsoft.common.system.controller; + +import com.gxwebsoft.common.core.util.IpUtils; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.system.param.EmailCaptchaParam; +import com.gxwebsoft.common.system.param.SmsCaptchaParam; +import com.gxwebsoft.common.system.result.CaptchaResult; +import com.gxwebsoft.common.system.service.AuthService; +import com.gxwebsoft.common.system.service.CaptchaService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 验证码接口。 + * + * @author WebSoft + */ +@Tag(name = "验证码") +@RestController +@RequestMapping("/api") +public class CaptchaController extends BaseController { + + private final CaptchaService captchaService; + private final AuthService authService; + + public CaptchaController(CaptchaService captchaService, AuthService authService) { + this.captchaService = captchaService; + this.authService = authService; + } + + @Operation(summary = "获取图形验证码") + @GetMapping("/captcha") + public ApiResult captcha() { + return ApiResult.ok(captchaService.generate()); + } + + @Operation(summary = "发送短信验证码") + @PostMapping("/sendSmsCaptcha") + public ApiResult sendSmsCaptcha(@Valid @RequestBody SmsCaptchaParam param, HttpServletRequest request) { + return ApiResult.ok(authService.sendSmsCaptcha(param.phone(), param.scene(), IpUtils.clientIp(request))); + } + + @Operation(summary = "发送邮箱验证码") + @PostMapping("/sendEmailCaptcha") + public ApiResult sendEmailCaptcha(@Valid @RequestBody EmailCaptchaParam param, HttpServletRequest request) { + return ApiResult.ok(authService.sendEmailCaptcha(param.email(), IpUtils.clientIp(request))); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/controller/MenuController.java b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/MenuController.java new file mode 100644 index 0000000..6e35b9a --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/MenuController.java @@ -0,0 +1,78 @@ +package com.gxwebsoft.common.system.controller; + +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.system.entity.Menu; +import com.gxwebsoft.common.system.param.MenuPageParam; +import com.gxwebsoft.common.system.param.MenuSaveParam; +import com.gxwebsoft.common.system.service.MenuService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 菜单 / 权限管理接口(平台级元数据)。 + * + * @author WebSoft + */ +@Tag(name = "菜单管理") +@RestController +@RequestMapping("/api/iam/menu") +public class MenuController extends BaseController { + + private final MenuService menuService; + + public MenuController(MenuService menuService) { + this.menuService = menuService; + } + + @Operation(summary = "菜单树(全量启用)") + @GetMapping("/tree") + @PreAuthorize("hasAuthority('iam:menu:list')") + public ApiResult> tree(MenuPageParam param) { + return success(menuService.tree(param)); + } + + @Operation(summary = "菜单扁平列表(启用)") + @GetMapping("/list") + @PreAuthorize("hasAuthority('iam:menu:list')") + public ApiResult> list(MenuPageParam param) { + return success(menuService.listEnabled(param)); + } + + @Operation(summary = "新建菜单") + @PostMapping + @PreAuthorize("hasAuthority('iam:menu:add')") + public ApiResult create(@Valid @RequestBody MenuSaveParam param) { + return success("创建成功", menuService.save(param)); + } + + @Operation(summary = "修改菜单") + @PutMapping("/{menuId}") + @PreAuthorize("hasAuthority('iam:menu:edit')") + public ApiResult update(@PathVariable Long menuId, @Valid @RequestBody MenuSaveParam param) { + MenuSaveParam target = new MenuSaveParam(menuId, param.parentId(), param.menuName(), param.menuType(), + param.path(), param.component(), param.permission(), param.icon(), param.sort(), + param.visible(), param.status()); + return success("修改成功", menuService.save(target)); + } + + @Operation(summary = "删除菜单") + @DeleteMapping("/{menuId}") + @PreAuthorize("hasAuthority('iam:menu:remove')") + public ApiResult remove(@PathVariable Long menuId) { + menuService.remove(menuId); + return success("删除成功"); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/controller/RoleController.java b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/RoleController.java new file mode 100644 index 0000000..8b09ef7 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/RoleController.java @@ -0,0 +1,95 @@ +package com.gxwebsoft.common.system.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.system.entity.Role; +import com.gxwebsoft.common.system.param.RoleMenuParam; +import com.gxwebsoft.common.system.param.RolePageParam; +import com.gxwebsoft.common.system.param.RoleSaveParam; +import com.gxwebsoft.common.system.service.RoleService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 角色管理接口(租户内)。 + * + * @author WebSoft + */ +@Tag(name = "角色管理") +@RestController +@RequestMapping("/api/iam/role") +public class RoleController extends BaseController { + + private final RoleService roleService; + + public RoleController(RoleService roleService) { + this.roleService = roleService; + } + + @Operation(summary = "角色分页") + @GetMapping + @PreAuthorize("hasAuthority('iam:role:list')") + public ApiResult page(RolePageParam param) { + Page page = roleService.page(param); + return success(page); + } + + @Operation(summary = "启用角色列表(下拉选择)") + @GetMapping("/options") + @PreAuthorize("hasAuthority('iam:role:list')") + public ApiResult> options() { + return success(roleService.listEnabled()); + } + + @Operation(summary = "新建角色") + @PostMapping + @PreAuthorize("hasAuthority('iam:role:add')") + public ApiResult create(@Valid @RequestBody RoleSaveParam param) { + return success("创建成功", roleService.save(param)); + } + + @Operation(summary = "修改角色") + @PutMapping("/{roleId}") + @PreAuthorize("hasAuthority('iam:role:edit')") + public ApiResult update(@PathVariable Long roleId, @Valid @RequestBody RoleSaveParam param) { + RoleSaveParam target = new RoleSaveParam(roleId, param.tenantId(), param.roleName(), param.roleCode(), + param.sort(), param.remark(), param.status()); + return success("修改成功", roleService.save(target)); + } + + @Operation(summary = "删除角色") + @DeleteMapping("/{roleId}") + @PreAuthorize("hasAuthority('iam:role:remove')") + public ApiResult remove(@PathVariable Long roleId) { + roleService.remove(roleId); + return success("删除成功"); + } + + @Operation(summary = "角色已授权菜单ID") + @GetMapping("/{roleId}/menus") + @PreAuthorize("hasAuthority('iam:role:list')") + public ApiResult> menus(@PathVariable Long roleId) { + return success(roleService.menuIdsOfRole(roleId)); + } + + @Operation(summary = "角色菜单授权(覆盖式)") + @PutMapping("/{roleId}/menus") + @PreAuthorize("hasAuthority('iam:role:edit')") + public ApiResult assignMenus(@PathVariable Long roleId, @RequestBody RoleMenuParam param) { + roleService.assignMenus(roleId, param.menuIds()); + return success("授权成功"); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/controller/TenantController.java b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/TenantController.java new file mode 100644 index 0000000..cfa55dd --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/TenantController.java @@ -0,0 +1,85 @@ +package com.gxwebsoft.common.system.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.system.entity.Tenant; +import com.gxwebsoft.common.system.param.TenantOpenParam; +import com.gxwebsoft.common.system.param.TenantPageParam; +import com.gxwebsoft.common.system.param.TenantSaveParam; +import com.gxwebsoft.common.system.service.TenantService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 租户管理接口(平台级,仅平台运营角色可用)。 + * + * @author WebSoft + */ +@Tag(name = "租户管理") +@RestController +@RequestMapping("/api/iam/tenant") +public class TenantController extends BaseController { + + private final TenantService tenantService; + + public TenantController(TenantService tenantService) { + this.tenantService = tenantService; + } + + @Operation(summary = "租户分页") + @GetMapping + @PreAuthorize("hasAuthority('iam:tenant:list')") + public ApiResult page(TenantPageParam param) { + Page page = tenantService.page(param); + return success(page); + } + + @Operation(summary = "租户详情") + @GetMapping("/{tenantId}") + @PreAuthorize("hasAuthority('iam:tenant:list')") + public ApiResult detail(@PathVariable Long tenantId) { + return success(tenantService.getRequired(tenantId)); + } + + @Operation(summary = "开通租户账号(创建租户 + 管理员)") + @PostMapping("/open") + @PreAuthorize("hasAuthority('iam:tenant:add')") + public ApiResult open(@Valid @RequestBody TenantOpenParam param) { + return success("开通成功", tenantService.open(param)); + } + + @Operation(summary = "新建租户") + @PostMapping + @PreAuthorize("hasAuthority('iam:tenant:add')") + public ApiResult create(@Valid @RequestBody TenantSaveParam param) { + return success("创建成功", tenantService.save(param)); + } + + @Operation(summary = "修改租户") + @PutMapping("/{tenantId}") + @PreAuthorize("hasAuthority('iam:tenant:edit')") + public ApiResult update(@PathVariable Long tenantId, @Valid @RequestBody TenantSaveParam param) { + TenantSaveParam target = new TenantSaveParam(tenantId, param.tenantName(), param.tenantCode(), + param.contactName(), param.contactPhone(), param.status(), param.expireTime()); + return success("修改成功", tenantService.save(target)); + } + + @Operation(summary = "删除租户") + @DeleteMapping("/{tenantId}") + @PreAuthorize("hasAuthority('iam:tenant:remove')") + public ApiResult remove(@PathVariable Long tenantId) { + tenantService.remove(tenantId); + return success("删除成功"); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/controller/UserController.java b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/UserController.java new file mode 100644 index 0000000..85cbc02 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/UserController.java @@ -0,0 +1,100 @@ +package com.gxwebsoft.common.system.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.system.entity.Role; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.common.system.param.UserPageParam; +import com.gxwebsoft.common.system.param.UserResetPasswordParam; +import com.gxwebsoft.common.system.param.UserSaveParam; +import com.gxwebsoft.common.system.service.RoleService; +import com.gxwebsoft.common.system.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 用户管理接口(租户内)。 + * + * @author WebSoft + */ +@Tag(name = "用户管理") +@RestController +@RequestMapping("/api/iam/user") +public class UserController extends BaseController { + + private final UserService userService; + private final RoleService roleService; + + public UserController(UserService userService, RoleService roleService) { + this.userService = userService; + this.roleService = roleService; + } + + @Operation(summary = "用户分页") + @GetMapping + @PreAuthorize("hasAuthority('iam:user:list')") + public ApiResult page(UserPageParam param) { + Page page = userService.page(param); + return success(page); + } + + @Operation(summary = "用户详情") + @GetMapping("/{userId}") + @PreAuthorize("hasAuthority('iam:user:list')") + public ApiResult detail(@PathVariable Long userId) { + return success(userService.getById(userId)); + } + + @Operation(summary = "用户已分配角色") + @GetMapping("/{userId}/roles") + @PreAuthorize("hasAuthority('iam:user:list')") + public ApiResult> roles(@PathVariable Long userId) { + return success(roleService.listByUserId(userId)); + } + + @Operation(summary = "新建用户") + @PostMapping + @PreAuthorize("hasAuthority('iam:user:add')") + public ApiResult create(@Valid @RequestBody UserSaveParam param) { + return success("创建成功", userService.save(param)); + } + + @Operation(summary = "修改用户") + @PutMapping("/{userId}") + @PreAuthorize("hasAuthority('iam:user:edit')") + public ApiResult update(@PathVariable Long userId, @Valid @RequestBody UserSaveParam param) { + UserSaveParam target = new UserSaveParam(userId, param.tenantId(), param.username(), param.password(), + param.nickname(), param.phone(), param.email(), param.avatar(), param.status(), param.roleIds()); + return success("修改成功", userService.save(target)); + } + + @Operation(summary = "删除用户") + @DeleteMapping("/{userId}") + @PreAuthorize("hasAuthority('iam:user:remove')") + public ApiResult remove(@PathVariable Long userId) { + userService.remove(userId); + return success("删除成功"); + } + + @Operation(summary = "重置用户密码") + @PostMapping("/{userId}/resetPassword") + @PreAuthorize("hasAuthority('iam:user:resetPwd')") + public ApiResult resetPassword(@PathVariable Long userId, + @Valid @RequestBody UserResetPasswordParam param) { + userService.resetPassword(userId, param.newPassword()); + return success("密码已重置"); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/entity/LoginRecord.java b/iam-api/src/main/java/com/gxwebsoft/common/system/entity/LoginRecord.java new file mode 100644 index 0000000..2cf0033 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/entity/LoginRecord.java @@ -0,0 +1,65 @@ +package com.gxwebsoft.common.system.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 登录审计流水。 + * + * @author WebSoft + */ +@Getter +@Setter +@TableName("sys_login_record") +@Schema(description = "登录记录") +public class LoginRecord implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + public static final int TYPE_LOGIN = 1; + + public static final int TYPE_LOGOUT = 2; + + public static final int TYPE_REFRESH = 3; + + public static final int TYPE_BIND_PHONE = 4; + + public static final int STATUS_FAILURE = 0; + + public static final int STATUS_SUCCESS = 1; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + private Long tenantId; + + private Long userId; + + private String username; + + @Schema(description = "类型:1登录 2登出 3续签 4绑定手机号") + private Integer type; + + @Schema(description = "结果:0失败 1成功") + private Integer status; + + private String ip; + + private String userAgent; + + private String message; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/entity/Menu.java b/iam-api/src/main/java/com/gxwebsoft/common/system/entity/Menu.java new file mode 100644 index 0000000..32f2a30 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/entity/Menu.java @@ -0,0 +1,92 @@ +package com.gxwebsoft.common.system.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 菜单 / 权限。 + * + * @author WebSoft + */ +@Getter +@Setter +@TableName("sys_menu") +@Schema(description = "菜单") +public class Menu implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** 菜单类型:目录 */ + public static final int TYPE_DIR = 1; + + /** 菜单类型:菜单 */ + public static final int TYPE_MENU = 2; + + /** 菜单类型:按钮 */ + public static final int TYPE_BUTTON = 3; + + @TableId(value = "menu_id", type = IdType.AUTO) + @Schema(description = "菜单ID") + private Long menuId; + + @Schema(description = "父菜单ID,0 为根") + private Long parentId; + + @Schema(description = "菜单名称") + private String menuName; + + @Schema(description = "类型:1目录 2菜单 3按钮") + private Integer menuType; + + @Schema(description = "路由地址") + private String path; + + @Schema(description = "前端组件路径") + private String component; + + @Schema(description = "权限标识") + private String permission; + + @Schema(description = "图标") + private String icon; + + @Schema(description = "排序") + private Integer sort; + + @Schema(description = "是否显示:0隐藏 1显示") + private Integer visible; + + @Schema(description = "状态:0停用 1启用") + private Integer status; + + @TableLogic + @Schema(description = "逻辑删除", hidden = true) + private Integer delFlag; + + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "更新时间") + private LocalDateTime updateTime; + + /** 子菜单,构建树时使用 */ + @TableField(exist = false) + @Schema(description = "子菜单") + private List children = new ArrayList<>(); +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/entity/Role.java b/iam-api/src/main/java/com/gxwebsoft/common/system/entity/Role.java new file mode 100644 index 0000000..f26e17d --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/entity/Role.java @@ -0,0 +1,64 @@ +package com.gxwebsoft.common.system.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 角色。 + * + * @author WebSoft + */ +@Getter +@Setter +@TableName("sys_role") +@Schema(description = "角色") +public class Role implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @TableId(value = "role_id", type = IdType.AUTO) + @Schema(description = "角色ID") + private Long roleId; + + @Schema(description = "所属租户") + private Long tenantId; + + @Schema(description = "角色名称") + private String roleName; + + @Schema(description = "角色标识") + private String roleCode; + + @Schema(description = "排序") + private Integer sort; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "状态:0停用 1启用") + private Integer status; + + @TableLogic + @Schema(description = "逻辑删除", hidden = true) + private Integer delFlag; + + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "更新时间") + private LocalDateTime updateTime; +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/entity/RoleMenu.java b/iam-api/src/main/java/com/gxwebsoft/common/system/entity/RoleMenu.java new file mode 100644 index 0000000..b3e896c --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/entity/RoleMenu.java @@ -0,0 +1,39 @@ +package com.gxwebsoft.common.system.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 角色菜单关联。 + * + * @author WebSoft + */ +@Getter +@Setter +@TableName("sys_role_menu") +public class RoleMenu implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + private Long roleId; + + private Long menuId; + + public RoleMenu() { + } + + public RoleMenu(Long roleId, Long menuId) { + this.roleId = roleId; + this.menuId = menuId; + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/entity/Tenant.java b/iam-api/src/main/java/com/gxwebsoft/common/system/entity/Tenant.java new file mode 100644 index 0000000..981cc33 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/entity/Tenant.java @@ -0,0 +1,64 @@ +package com.gxwebsoft.common.system.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 租户。 + * + * @author WebSoft + */ +@Getter +@Setter +@TableName("sys_tenant") +@Schema(description = "租户") +public class Tenant implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @TableId(value = "tenant_id", type = IdType.AUTO) + @Schema(description = "租户ID") + private Long tenantId; + + @Schema(description = "租户名称") + private String tenantName; + + @Schema(description = "业务系统租户标识,用于与业务库对齐") + private String tenantCode; + + @Schema(description = "联系人") + private String contactName; + + @Schema(description = "联系电话") + private String contactPhone; + + @Schema(description = "状态:0停用 1启用") + private Integer status; + + @Schema(description = "到期时间") + private LocalDateTime expireTime; + + @TableLogic + @Schema(description = "逻辑删除:0正常 1已删除", hidden = true) + private Integer delFlag; + + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "更新时间") + private LocalDateTime updateTime; +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/entity/User.java b/iam-api/src/main/java/com/gxwebsoft/common/system/entity/User.java new file mode 100644 index 0000000..ec0823d --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/entity/User.java @@ -0,0 +1,79 @@ +package com.gxwebsoft.common.system.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.gxwebsoft.common.core.context.TenantAware; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 用户。 + * + *

作为 Spring Security 的登录主体,认证过滤器会把它放进 SecurityContext。

+ * + * @author WebSoft + */ +@Getter +@Setter +@TableName("sys_user") +@Schema(description = "用户") +public class User implements Serializable, TenantAware { + + @Serial + private static final long serialVersionUID = 1L; + + @TableId(value = "user_id", type = IdType.AUTO) + @Schema(description = "用户ID") + private Long userId; + + @Schema(description = "所属租户") + private Long tenantId; + + @Schema(description = "登录账号") + private String username; + + /** 密码只进不出,任何序列化场景都不输出 */ + @JsonIgnore + @Schema(description = "密码", hidden = true) + private String password; + + @Schema(description = "昵称") + private String nickname; + + @Schema(description = "手机号") + private String phone; + + @Schema(description = "邮箱") + private String email; + + @Schema(description = "头像") + private String avatar; + + @Schema(description = "状态:0停用 1启用") + private Integer status; + + @Schema(description = "最后登录时间") + private LocalDateTime lastLoginTime; + + @TableLogic + @Schema(description = "逻辑删除:0正常 1已删除", hidden = true) + private Integer delFlag; + + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "更新时间") + private LocalDateTime updateTime; +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/entity/UserRole.java b/iam-api/src/main/java/com/gxwebsoft/common/system/entity/UserRole.java new file mode 100644 index 0000000..ac2b975 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/entity/UserRole.java @@ -0,0 +1,39 @@ +package com.gxwebsoft.common.system.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 用户角色关联。 + * + * @author WebSoft + */ +@Getter +@Setter +@TableName("sys_user_role") +public class UserRole implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + private Long userId; + + private Long roleId; + + public UserRole() { + } + + public UserRole(Long userId, Long roleId) { + this.userId = userId; + this.roleId = roleId; + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/mapper/LoginRecordMapper.java b/iam-api/src/main/java/com/gxwebsoft/common/system/mapper/LoginRecordMapper.java new file mode 100644 index 0000000..7bb6451 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/mapper/LoginRecordMapper.java @@ -0,0 +1,12 @@ +package com.gxwebsoft.common.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.common.system.entity.LoginRecord; + +/** + * 登录记录 Mapper。 + * + * @author WebSoft + */ +public interface LoginRecordMapper extends BaseMapper { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/mapper/MenuMapper.java b/iam-api/src/main/java/com/gxwebsoft/common/system/mapper/MenuMapper.java new file mode 100644 index 0000000..796448f --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/mapper/MenuMapper.java @@ -0,0 +1,45 @@ +package com.gxwebsoft.common.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.common.system.entity.Menu; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 菜单 / 权限 Mapper。 + * + * @author WebSoft + */ +public interface MenuMapper extends BaseMapper { + + /** 用户拥有的权限标识集合(去重) */ + @Select(""" + SELECT DISTINCT m.permission FROM sys_menu m + JOIN sys_role_menu rm ON rm.menu_id = m.menu_id + JOIN sys_user_role ur ON ur.role_id = rm.role_id + WHERE ur.user_id = #{userId} + AND m.del_flag = 0 AND m.status = 1 + AND m.permission IS NOT NULL AND m.permission <> '' + """) + List selectPermissionsByUserId(@Param("userId") Long userId); + + /** 用户可见的菜单(目录 / 菜单 / 按钮),按排序返回 */ + @Select(""" + SELECT DISTINCT m.* FROM sys_menu m + JOIN sys_role_menu rm ON rm.menu_id = m.menu_id + JOIN sys_user_role ur ON ur.role_id = rm.role_id + WHERE ur.user_id = #{userId} AND m.del_flag = 0 AND m.status = 1 + ORDER BY m.sort, m.menu_id + """) + List selectByUserId(@Param("userId") Long userId); + + /** 角色已勾选的菜单 ID */ + @Select(""" + SELECT rm.menu_id FROM sys_role_menu rm + JOIN sys_menu m ON m.menu_id = rm.menu_id + WHERE rm.role_id = #{roleId} AND m.del_flag = 0 + """) + List selectMenuIdsByRoleId(@Param("roleId") Long roleId); +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/mapper/RoleMapper.java b/iam-api/src/main/java/com/gxwebsoft/common/system/mapper/RoleMapper.java new file mode 100644 index 0000000..d56cd4d --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/mapper/RoleMapper.java @@ -0,0 +1,33 @@ +package com.gxwebsoft.common.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.common.system.entity.Role; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 角色 Mapper。 + * + * @author WebSoft + */ +public interface RoleMapper extends BaseMapper { + + /** 用户已分配的角色 */ + @Select(""" + SELECT r.* FROM sys_role r + JOIN sys_user_role ur ON ur.role_id = r.role_id + WHERE ur.user_id = #{userId} AND r.del_flag = 0 AND r.status = 1 + ORDER BY r.sort, r.role_id + """) + List selectByUserId(@Param("userId") Long userId); + + /** 用户的角色标识集合 */ + @Select(""" + SELECT r.role_code FROM sys_role r + JOIN sys_user_role ur ON ur.role_id = r.role_id + WHERE ur.user_id = #{userId} AND r.del_flag = 0 AND r.status = 1 + """) + List selectRoleCodesByUserId(@Param("userId") Long userId); +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/mapper/RoleMenuMapper.java b/iam-api/src/main/java/com/gxwebsoft/common/system/mapper/RoleMenuMapper.java new file mode 100644 index 0000000..f259c7f --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/mapper/RoleMenuMapper.java @@ -0,0 +1,12 @@ +package com.gxwebsoft.common.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.common.system.entity.RoleMenu; + +/** + * 角色菜单关联 Mapper。 + * + * @author WebSoft + */ +public interface RoleMenuMapper extends BaseMapper { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/mapper/TenantMapper.java b/iam-api/src/main/java/com/gxwebsoft/common/system/mapper/TenantMapper.java new file mode 100644 index 0000000..4f7f857 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/mapper/TenantMapper.java @@ -0,0 +1,12 @@ +package com.gxwebsoft.common.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.common.system.entity.Tenant; + +/** + * 租户 Mapper。 + * + * @author WebSoft + */ +public interface TenantMapper extends BaseMapper { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/mapper/UserMapper.java b/iam-api/src/main/java/com/gxwebsoft/common/system/mapper/UserMapper.java new file mode 100644 index 0000000..3e7df3f --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/mapper/UserMapper.java @@ -0,0 +1,12 @@ +package com.gxwebsoft.common.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.common.system.entity.User; + +/** + * 用户 Mapper。 + * + * @author WebSoft + */ +public interface UserMapper extends BaseMapper { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/mapper/UserRoleMapper.java b/iam-api/src/main/java/com/gxwebsoft/common/system/mapper/UserRoleMapper.java new file mode 100644 index 0000000..16321fe --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/mapper/UserRoleMapper.java @@ -0,0 +1,12 @@ +package com.gxwebsoft.common.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.common.system.entity.UserRole; + +/** + * 用户角色关联 Mapper。 + * + * @author WebSoft + */ +public interface UserRoleMapper extends BaseMapper { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/param/BindPhoneCaptchaParam.java b/iam-api/src/main/java/com/gxwebsoft/common/system/param/BindPhoneCaptchaParam.java new file mode 100644 index 0000000..9a8e494 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/param/BindPhoneCaptchaParam.java @@ -0,0 +1,15 @@ +package com.gxwebsoft.common.system.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +/** + * 绑定手机号:发送短信验证码参数。 + * + * @author WebSoft + */ +@Schema(description = "绑定手机号验证码") +public record BindPhoneCaptchaParam( + @NotBlank(message = "手机号不能为空") + @Schema(description = "待绑定手机号") String phone) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/param/BindPhoneParam.java b/iam-api/src/main/java/com/gxwebsoft/common/system/param/BindPhoneParam.java new file mode 100644 index 0000000..70d0a85 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/param/BindPhoneParam.java @@ -0,0 +1,20 @@ +package com.gxwebsoft.common.system.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +/** + * 绑定 / 换绑手机号参数。 + * + * @author WebSoft + */ +@Schema(description = "绑定手机号") +public record BindPhoneParam( + @NotBlank(message = "手机号不能为空") + @Schema(description = "新手机号") String phone, + + @NotBlank(message = "验证码不能为空") + @Schema(description = "短信验证码") String code, + + @Schema(description = "原密码,仅换绑(账号已绑定手机号)时必填") String password) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/param/ChangePasswordParam.java b/iam-api/src/main/java/com/gxwebsoft/common/system/param/ChangePasswordParam.java new file mode 100644 index 0000000..55dbe12 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/param/ChangePasswordParam.java @@ -0,0 +1,21 @@ +package com.gxwebsoft.common.system.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +/** + * 修改密码参数(已登录,需校验原密码)。 + * + * @author WebSoft + */ +@Schema(description = "修改密码") +public record ChangePasswordParam( + @NotBlank(message = "原密码不能为空") + @Schema(description = "原密码") String oldPassword, + + @NotBlank(message = "新密码不能为空") + @Schema(description = "新密码,至少 8 位且包含字母和数字") String newPassword, + + @NotBlank(message = "确认密码不能为空") + @Schema(description = "确认新密码") String confirmPassword) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/param/EmailCaptchaParam.java b/iam-api/src/main/java/com/gxwebsoft/common/system/param/EmailCaptchaParam.java new file mode 100644 index 0000000..a48fc7f --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/param/EmailCaptchaParam.java @@ -0,0 +1,17 @@ +package com.gxwebsoft.common.system.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +/** + * 发送邮箱验证码参数。 + * + * @author WebSoft + */ +@Schema(description = "发送邮箱验证码") +public record EmailCaptchaParam( + @NotBlank(message = "邮箱不能为空") + @Schema(description = "邮箱") String email, + + @Schema(description = "场景:login(默认)/ register") String scene) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/param/EmailLoginParam.java b/iam-api/src/main/java/com/gxwebsoft/common/system/param/EmailLoginParam.java new file mode 100644 index 0000000..7b965b2 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/param/EmailLoginParam.java @@ -0,0 +1,24 @@ +package com.gxwebsoft.common.system.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +/** + * 邮箱登录参数。 + * + * @author WebSoft + */ +@Schema(description = "邮箱登录") +public record EmailLoginParam( + @Schema(description = "租户ID,可空;为空时按邮箱自动定位所属租户") Long tenantId, + + @NotBlank(message = "邮箱不能为空") + @Schema(description = "邮箱") String email, + + @NotBlank(message = "密码不能为空") + @Schema(description = "密码") String password, + + @Schema(description = "图形验证码标识") String captchaId, + + @Schema(description = "图形验证码") String captchaCode) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/param/LoginParam.java b/iam-api/src/main/java/com/gxwebsoft/common/system/param/LoginParam.java new file mode 100644 index 0000000..ec73add --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/param/LoginParam.java @@ -0,0 +1,26 @@ +package com.gxwebsoft.common.system.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * 账号密码登录参数。 + * + * @author WebSoft + */ +@Schema(description = "账号密码登录") +public record LoginParam( + @NotNull(message = "租户ID不能为空") + @Schema(description = "租户ID") Long tenantId, + + @NotBlank(message = "账号不能为空") + @Schema(description = "登录账号") String username, + + @NotBlank(message = "密码不能为空") + @Schema(description = "密码") String password, + + @Schema(description = "图形验证码标识") String captchaId, + + @Schema(description = "图形验证码") String captchaCode) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/param/MenuPageParam.java b/iam-api/src/main/java/com/gxwebsoft/common/system/param/MenuPageParam.java new file mode 100644 index 0000000..d1e4d66 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/param/MenuPageParam.java @@ -0,0 +1,30 @@ +package com.gxwebsoft.common.system.param; + +import com.gxwebsoft.common.core.annotation.QueryField; +import com.gxwebsoft.common.core.annotation.QueryType; +import com.gxwebsoft.common.core.web.BaseParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +/** + * 菜单查询参数。 + * + * @author WebSoft + */ +@Getter +@Setter +public class MenuPageParam extends BaseParam { + + @QueryField(type = QueryType.LIKE, keywords = true) + @Schema(description = "菜单名称") + private String menuName; + + @QueryField + @Schema(description = "类型:1目录 2菜单 3按钮") + private Integer menuType; + + @QueryField + @Schema(description = "状态:0停用 1启用") + private Integer status; +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/param/MenuSaveParam.java b/iam-api/src/main/java/com/gxwebsoft/common/system/param/MenuSaveParam.java new file mode 100644 index 0000000..3bdbbbf --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/param/MenuSaveParam.java @@ -0,0 +1,37 @@ +package com.gxwebsoft.common.system.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * 菜单保存参数。 + * + * @author WebSoft + */ +@Schema(description = "菜单保存") +public record MenuSaveParam( + @Schema(description = "菜单ID,新建时为空") Long menuId, + + @Schema(description = "父菜单ID,0 为根") Long parentId, + + @NotBlank(message = "菜单名称不能为空") + @Schema(description = "菜单名称") String menuName, + + @NotNull(message = "菜单类型不能为空") + @Schema(description = "类型:1目录 2菜单 3按钮") Integer menuType, + + @Schema(description = "路由地址") String path, + + @Schema(description = "前端组件路径") String component, + + @Schema(description = "权限标识") String permission, + + @Schema(description = "图标") String icon, + + @Schema(description = "排序") Integer sort, + + @Schema(description = "是否显示:0隐藏 1显示") Integer visible, + + @Schema(description = "状态:0停用 1启用") Integer status) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/param/RoleMenuParam.java b/iam-api/src/main/java/com/gxwebsoft/common/system/param/RoleMenuParam.java new file mode 100644 index 0000000..b5d849a --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/param/RoleMenuParam.java @@ -0,0 +1,15 @@ +package com.gxwebsoft.common.system.param; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** + * 角色菜单授权参数。 + * + * @author WebSoft + */ +@Schema(description = "角色菜单授权") +public record RoleMenuParam( + @Schema(description = "菜单ID集合,空集合表示取消全部授权") List menuIds) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/param/RolePageParam.java b/iam-api/src/main/java/com/gxwebsoft/common/system/param/RolePageParam.java new file mode 100644 index 0000000..54ad8c6 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/param/RolePageParam.java @@ -0,0 +1,30 @@ +package com.gxwebsoft.common.system.param; + +import com.gxwebsoft.common.core.annotation.QueryField; +import com.gxwebsoft.common.core.annotation.QueryType; +import com.gxwebsoft.common.core.web.BaseParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +/** + * 角色查询参数。 + * + * @author WebSoft + */ +@Getter +@Setter +public class RolePageParam extends BaseParam { + + @QueryField(type = QueryType.LIKE, keywords = true) + @Schema(description = "角色名称") + private String roleName; + + @QueryField(type = QueryType.LIKE, keywords = true) + @Schema(description = "角色标识") + private String roleCode; + + @QueryField + @Schema(description = "状态:0停用 1启用") + private Integer status; +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/param/RoleSaveParam.java b/iam-api/src/main/java/com/gxwebsoft/common/system/param/RoleSaveParam.java new file mode 100644 index 0000000..bd49a85 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/param/RoleSaveParam.java @@ -0,0 +1,28 @@ +package com.gxwebsoft.common.system.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +/** + * 角色保存参数。 + * + * @author WebSoft + */ +@Schema(description = "角色保存") +public record RoleSaveParam( + @Schema(description = "角色ID,新建时为空") Long roleId, + + @Schema(description = "所属租户,为空时取当前租户") Long tenantId, + + @NotBlank(message = "角色名称不能为空") + @Schema(description = "角色名称") String roleName, + + @NotBlank(message = "角色标识不能为空") + @Schema(description = "角色标识") String roleCode, + + @Schema(description = "排序") Integer sort, + + @Schema(description = "备注") String remark, + + @Schema(description = "状态:0停用 1启用") Integer status) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/param/SelfResetPasswordParam.java b/iam-api/src/main/java/com/gxwebsoft/common/system/param/SelfResetPasswordParam.java new file mode 100644 index 0000000..88f3745 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/param/SelfResetPasswordParam.java @@ -0,0 +1,29 @@ +package com.gxwebsoft.common.system.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +/** + * 自助找回密码参数。 + * + * @author WebSoft + */ +@Schema(description = "自助找回密码") +public record SelfResetPasswordParam( + @Schema(description = "租户ID;邮箱找回可空,手机找回必填") Long tenantId, + + @NotBlank(message = "找回方式不能为空") + @Schema(description = "找回方式:sms / email") String type, + + @NotBlank(message = "账号不能为空") + @Schema(description = "手机号或邮箱") String account, + + @NotBlank(message = "验证码不能为空") + @Schema(description = "验证码") String code, + + @NotBlank(message = "新密码不能为空") + @Schema(description = "新密码") String newPassword, + + @NotBlank(message = "确认密码不能为空") + @Schema(description = "确认新密码") String confirmPassword) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/param/SmsCaptchaParam.java b/iam-api/src/main/java/com/gxwebsoft/common/system/param/SmsCaptchaParam.java new file mode 100644 index 0000000..1d5e9b5 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/param/SmsCaptchaParam.java @@ -0,0 +1,17 @@ +package com.gxwebsoft.common.system.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +/** + * 发送短信验证码参数。 + * + * @author WebSoft + */ +@Schema(description = "发送短信验证码") +public record SmsCaptchaParam( + @NotBlank(message = "手机号不能为空") + @Schema(description = "手机号") String phone, + + @Schema(description = "场景:login(默认)/ register") String scene) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/param/SmsLoginParam.java b/iam-api/src/main/java/com/gxwebsoft/common/system/param/SmsLoginParam.java new file mode 100644 index 0000000..0f6f038 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/param/SmsLoginParam.java @@ -0,0 +1,22 @@ +package com.gxwebsoft.common.system.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * 手机号验证码登录参数。 + * + * @author WebSoft + */ +@Schema(description = "手机号验证码登录") +public record SmsLoginParam( + @NotNull(message = "租户ID不能为空") + @Schema(description = "租户ID") Long tenantId, + + @NotBlank(message = "手机号不能为空") + @Schema(description = "手机号") String phone, + + @NotBlank(message = "验证码不能为空") + @Schema(description = "短信验证码") String code) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/param/TenantOpenParam.java b/iam-api/src/main/java/com/gxwebsoft/common/system/param/TenantOpenParam.java new file mode 100644 index 0000000..958afd5 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/param/TenantOpenParam.java @@ -0,0 +1,27 @@ +package com.gxwebsoft.common.system.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +/** + * 开通租户账号参数:一次请求创建租户 + 管理员账号,供平台运营使用。 + * + * @author WebSoft + */ +@Schema(description = "开通租户账号") +public record TenantOpenParam( + @NotBlank(message = "租户名称不能为空") + @Schema(description = "租户名称") String tenantName, + + @Schema(description = "业务系统租户标识") String tenantCode, + + @Schema(description = "联系人") String contactName, + + @Schema(description = "联系电话") String contactPhone, + + @NotBlank(message = "管理员账号不能为空") + @Schema(description = "管理员登录账号") String adminUsername, + + @NotBlank(message = "管理员密码不能为空") + @Schema(description = "管理员密码") String adminPassword) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/param/TenantPageParam.java b/iam-api/src/main/java/com/gxwebsoft/common/system/param/TenantPageParam.java new file mode 100644 index 0000000..4bdcdc0 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/param/TenantPageParam.java @@ -0,0 +1,30 @@ +package com.gxwebsoft.common.system.param; + +import com.gxwebsoft.common.core.annotation.QueryField; +import com.gxwebsoft.common.core.annotation.QueryType; +import com.gxwebsoft.common.core.web.BaseParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +/** + * 租户查询参数。 + * + * @author WebSoft + */ +@Getter +@Setter +public class TenantPageParam extends BaseParam { + + @QueryField(type = QueryType.LIKE, keywords = true) + @Schema(description = "租户名称") + private String tenantName; + + @QueryField(type = QueryType.LIKE) + @Schema(description = "租户标识") + private String tenantCode; + + @QueryField + @Schema(description = "状态:0停用 1启用") + private Integer status; +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/param/TenantSaveParam.java b/iam-api/src/main/java/com/gxwebsoft/common/system/param/TenantSaveParam.java new file mode 100644 index 0000000..cd3df94 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/param/TenantSaveParam.java @@ -0,0 +1,29 @@ +package com.gxwebsoft.common.system.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +import java.time.LocalDateTime; + +/** + * 租户保存参数。 + * + * @author WebSoft + */ +@Schema(description = "租户保存") +public record TenantSaveParam( + @Schema(description = "租户ID,新建时为空") Long tenantId, + + @NotBlank(message = "租户名称不能为空") + @Schema(description = "租户名称") String tenantName, + + @Schema(description = "业务系统租户标识") String tenantCode, + + @Schema(description = "联系人") String contactName, + + @Schema(description = "联系电话") String contactPhone, + + @Schema(description = "状态:0停用 1启用") Integer status, + + @Schema(description = "到期时间,空表示永久") LocalDateTime expireTime) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/param/UserPageParam.java b/iam-api/src/main/java/com/gxwebsoft/common/system/param/UserPageParam.java new file mode 100644 index 0000000..e6e1f61 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/param/UserPageParam.java @@ -0,0 +1,34 @@ +package com.gxwebsoft.common.system.param; + +import com.gxwebsoft.common.core.annotation.QueryField; +import com.gxwebsoft.common.core.annotation.QueryType; +import com.gxwebsoft.common.core.web.BaseParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +/** + * 用户查询参数。 + * + * @author WebSoft + */ +@Getter +@Setter +public class UserPageParam extends BaseParam { + + @QueryField(type = QueryType.LIKE, keywords = true) + @Schema(description = "账号") + private String username; + + @QueryField(type = QueryType.LIKE, keywords = true) + @Schema(description = "昵称") + private String nickname; + + @QueryField(type = QueryType.LIKE) + @Schema(description = "手机号") + private String phone; + + @QueryField + @Schema(description = "状态:0停用 1启用") + private Integer status; +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/param/UserResetPasswordParam.java b/iam-api/src/main/java/com/gxwebsoft/common/system/param/UserResetPasswordParam.java new file mode 100644 index 0000000..d539462 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/param/UserResetPasswordParam.java @@ -0,0 +1,15 @@ +package com.gxwebsoft.common.system.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +/** + * 管理员重置用户密码参数。 + * + * @author WebSoft + */ +@Schema(description = "重置用户密码") +public record UserResetPasswordParam( + @NotBlank(message = "新密码不能为空") + @Schema(description = "新密码,至少 8 位且包含字母和数字") String newPassword) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/param/UserSaveParam.java b/iam-api/src/main/java/com/gxwebsoft/common/system/param/UserSaveParam.java new file mode 100644 index 0000000..df8168a --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/param/UserSaveParam.java @@ -0,0 +1,35 @@ +package com.gxwebsoft.common.system.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +import java.util.List; + +/** + * 用户保存参数(新建 / 修改)。 + * + * @author WebSoft + */ +@Schema(description = "用户保存") +public record UserSaveParam( + @Schema(description = "用户ID,新建时为空") Long userId, + + @Schema(description = "所属租户,为空时取当前租户") Long tenantId, + + @NotBlank(message = "账号不能为空") + @Schema(description = "登录账号") String username, + + @Schema(description = "密码,新建时必填;修改时留空表示不改密码") String password, + + @Schema(description = "昵称") String nickname, + + @Schema(description = "手机号") String phone, + + @Schema(description = "邮箱") String email, + + @Schema(description = "头像") String avatar, + + @Schema(description = "状态:0停用 1启用") Integer status, + + @Schema(description = "角色ID集合") List roleIds) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/result/CaptchaResult.java b/iam-api/src/main/java/com/gxwebsoft/common/system/result/CaptchaResult.java new file mode 100644 index 0000000..f163faa --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/result/CaptchaResult.java @@ -0,0 +1,14 @@ +package com.gxwebsoft.common.system.result; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 图形验证码响应:只返回标识与图片,答案不下发。 + * + * @param captchaId 验证码标识 + * @param image Base64 图片 + * @author WebSoft + */ +@Schema(description = "图形验证码") +public record CaptchaResult(String captchaId, String image) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/result/LoginResult.java b/iam-api/src/main/java/com/gxwebsoft/common/system/result/LoginResult.java new file mode 100644 index 0000000..c582341 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/result/LoginResult.java @@ -0,0 +1,16 @@ +package com.gxwebsoft.common.system.result; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 登录响应。 + * + * @param token 访问令牌 + * @param tokenType 令牌类型,固定 Bearer + * @param expiresIn 有效期(秒) + * @param user 用户信息 + * @author WebSoft + */ +@Schema(description = "登录响应") +public record LoginResult(String token, String tokenType, Long expiresIn, UserInfoResult user) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/result/UserInfoResult.java b/iam-api/src/main/java/com/gxwebsoft/common/system/result/UserInfoResult.java new file mode 100644 index 0000000..56452a8 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/result/UserInfoResult.java @@ -0,0 +1,23 @@ +package com.gxwebsoft.common.system.result; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** + * 当前登录用户信息(含角色与权限标识)。 + * + * @author WebSoft + */ +@Schema(description = "用户信息") +public record UserInfoResult( + @Schema(description = "用户ID") Long userId, + @Schema(description = "租户ID") Long tenantId, + @Schema(description = "账号") String username, + @Schema(description = "昵称") String nickname, + @Schema(description = "头像") String avatar, + @Schema(description = "手机号") String phone, + @Schema(description = "邮箱") String email, + @Schema(description = "角色标识") List roles, + @Schema(description = "权限标识") List permissions) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/security/SystemLoginUserProvider.java b/iam-api/src/main/java/com/gxwebsoft/common/system/security/SystemLoginUserProvider.java new file mode 100644 index 0000000..ec05af8 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/security/SystemLoginUserProvider.java @@ -0,0 +1,65 @@ +package com.gxwebsoft.common.system.security; + +import com.gxwebsoft.common.core.context.TenantContext; +import com.gxwebsoft.common.core.security.JwtSubject; +import com.gxwebsoft.common.core.security.LoginUserProvider; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.common.system.service.MenuService; +import com.gxwebsoft.common.system.service.UserService; +import org.springframework.context.annotation.Primary; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.List; + +/** + * 基于 {@code sys_user} / {@code sys_menu} 的登录主体加载。 + * + *

认证过滤器在租户过滤器之前执行,此时 {@link TenantContext} 还没有租户, + * 因此这里显式用令牌里的租户包一层,避免多租户插件把查询条件注入成 + * {@code tenant_id = null} 而永远查不到用户。

+ * + * @author WebSoft + */ +@Component +@Primary +public class SystemLoginUserProvider implements LoginUserProvider { + + private static final int STATUS_ENABLED = 1; + + private final UserService userService; + private final MenuService menuService; + + public SystemLoginUserProvider(UserService userService, MenuService menuService) { + this.userService = userService; + this.menuService = menuService; + } + + @Override + public Object loadPrincipal(JwtSubject subject) { + if (subject.tenantId() == null) { + return null; + } + User user = TenantContext.callWithTenant(subject.tenantId(), + () -> userService.getByUsername(subject.tenantId(), subject.username())); + if (user == null || user.getStatus() == null || user.getStatus() != STATUS_ENABLED) { + return null; + } + return user; + } + + @Override + public Collection loadAuthorities(Object principal) { + if (!(principal instanceof User user)) { + return List.of(); + } + List permissions = TenantContext.callWithTenant(user.getTenantId(), + () -> menuService.permissionsOfUser(user.getUserId())); + return permissions.stream().filter(p -> p != null && !p.isBlank()) + .distinct() + .map(SimpleGrantedAuthority::new) + .toList(); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/service/AuthService.java b/iam-api/src/main/java/com/gxwebsoft/common/system/service/AuthService.java new file mode 100644 index 0000000..8c2151d --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/service/AuthService.java @@ -0,0 +1,413 @@ +package com.gxwebsoft.common.system.service; + +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.common.core.Constants; +import com.gxwebsoft.common.core.cache.CacheStore; +import com.gxwebsoft.common.core.context.TenantContext; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.core.security.JwtSubject; +import com.gxwebsoft.common.core.security.JwtUtil; +import com.gxwebsoft.common.core.security.RevokedTokenStore; +import com.gxwebsoft.common.system.config.AuthProperties; +import com.gxwebsoft.common.system.entity.LoginRecord; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.common.system.param.BindPhoneCaptchaParam; +import com.gxwebsoft.common.system.param.BindPhoneParam; +import com.gxwebsoft.common.system.param.ChangePasswordParam; +import com.gxwebsoft.common.system.param.EmailLoginParam; +import com.gxwebsoft.common.system.param.LoginParam; +import com.gxwebsoft.common.system.param.SelfResetPasswordParam; +import com.gxwebsoft.common.system.param.SmsLoginParam; +import com.gxwebsoft.common.system.result.LoginResult; +import com.gxwebsoft.common.system.result.UserInfoResult; +import io.jsonwebtoken.Claims; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 认证服务:登录 / 登出 / 当前用户 / 改密 / 绑手机 / 找回密码。 + * + * @author WebSoft + */ +@Service +public class AuthService { + + private static final Logger log = LoggerFactory.getLogger(AuthService.class); + + private static final int STATUS_ENABLED = 1; + + /** 场景:登录(默认),只对已注册账号发码 */ + private static final String SCENE_LOGIN = "login"; + + private static final String TYPE_EMAIL = "email"; + private static final String TYPE_SMS = "sms"; + + /** 统一的发码提示:账号是否存在不对外区分,避免手机号 / 邮箱枚举 */ + private static final String CAPTCHA_SENT_MESSAGE = "验证码已发送,请注意查收(5 分钟内有效)"; + + private final UserService userService; + private final TenantService tenantService; + private final RoleService roleService; + private final MenuService menuService; + private final LoginRecordService loginRecordService; + private final CaptchaService captchaService; + private final SmsCaptchaService smsCaptchaService; + private final EmailCaptchaService emailCaptchaService; + private final AuthProperties authProperties; + private final JwtUtil jwtUtil; + private final PasswordEncoder passwordEncoder; + private final CacheStore cacheStore; + private final RevokedTokenStore revokedTokenStore; + + public AuthService(UserService userService, TenantService tenantService, RoleService roleService, + MenuService menuService, LoginRecordService loginRecordService, + CaptchaService captchaService, SmsCaptchaService smsCaptchaService, + EmailCaptchaService emailCaptchaService, AuthProperties authProperties, + JwtUtil jwtUtil, PasswordEncoder passwordEncoder, CacheStore cacheStore, + RevokedTokenStore revokedTokenStore) { + this.userService = userService; + this.tenantService = tenantService; + this.roleService = roleService; + this.menuService = menuService; + this.loginRecordService = loginRecordService; + this.captchaService = captchaService; + this.smsCaptchaService = smsCaptchaService; + this.emailCaptchaService = emailCaptchaService; + this.authProperties = authProperties; + this.jwtUtil = jwtUtil; + this.passwordEncoder = passwordEncoder; + this.cacheStore = cacheStore; + this.revokedTokenStore = revokedTokenStore; + } + + /** + * 账号密码登录:校验图形验证码 → 锁定状态 → 校验账号密码 → 校验状态 → 签发 token。 + * + *

登录请求还没有 token,租户来自请求体,因此整个流程都放在该租户的上下文中执行, + * 保证多租户插件对 {@code sys_user} 的查询注入正确的 tenant_id。

+ */ + public LoginResult login(LoginParam param, HttpServletRequest request) { + captchaService.verify(param.captchaId(), param.captchaCode()); + User user = checkPasswordAndLock(param.tenantId(), param.username(), param.password(), request); + return TenantContext.callWithTenant(param.tenantId(), () -> issueLogin(user, request)); + } + + /** + * 邮箱登录:与账号密码登录共用图形验证码与失败锁定,账号按「租户 + 邮箱」定位。 + * + *

未指定租户时按邮箱反查所属租户,登录页因此无需填写租户 ID。

+ */ + public LoginResult loginByEmail(EmailLoginParam param, HttpServletRequest request) { + captchaService.verify(param.captchaId(), param.captchaCode()); + Long tenantId = param.tenantId() != null + ? param.tenantId() + : userService.resolveTenantIdByEmail(param.email()); + if (tenantId == null) { + throw new BusinessException("邮箱或密码错误"); + } + checkLoginLocked(tenantId, param.email()); + LoginResult result = TenantContext.callWithTenant(tenantId, () -> { + tenantService.getRequired(tenantId); + User user = userService.getByEmail(tenantId, param.email()); + if (user == null || StrUtil.isBlank(param.password()) + || !passwordEncoder.matches(param.password(), user.getPassword())) { + loginRecordService.record(tenantId, null, param.email(), + LoginRecord.TYPE_LOGIN, false, "邮箱或密码错误", request); + increaseLoginFail(tenantId, param.email()); + throw new BusinessException("邮箱或密码错误"); + } + checkUserEnabled(user, request); + return issueLogin(user, request); + }); + clearLoginFail(tenantId, param.email()); + return result; + } + + /** + * 手机号验证码登录:短信验证码本身就是身份凭证,不再额外要求图形验证码。 + */ + public LoginResult loginBySms(SmsLoginParam param, HttpServletRequest request) { + return TenantContext.callWithTenant(param.tenantId(), () -> { + tenantService.getRequired(param.tenantId()); + User user = userService.getByPhone(param.tenantId(), param.phone()); + // 发码接口只对已注册手机号真正发码,因此这里不存在可利用的枚举面 + if (user == null) { + throw new BusinessException("该手机号未注册"); + } + // 先核销验证码,再做状态判断,避免同一验证码被反复试探 + smsCaptchaService.verify(param.phone(), param.code()); + checkUserEnabled(user, request); + return issueLogin(user, request); + }); + } + + /** + * 登出:把当前令牌写入吊销名单。 + * + *

JWT 本身无状态,只让客户端丢弃令牌的话,令牌在有效期内仍然可用 + * (被复制走也照样能登录),因此这里必须显式吊销。

+ */ + public void logout(HttpServletRequest request) { + revoke(request); + User user = currentLoginUser(); + if (user != null) { + loginRecordService.record(user.getTenantId(), user.getUserId(), user.getUsername(), + LoginRecord.TYPE_LOGOUT, true, null, request); + } + SecurityContextHolder.clearContext(); + } + + /** 当前登录用户信息(含角色与权限标识) */ + public UserInfoResult currentUser() { + return buildUserInfo(requireCurrentUser()); + } + + /** + * 修改密码(已登录,需校验原密码)。 + * + *

密码变更后当前会话立即失效:即使旧令牌未过期,也应强制用新密码重新登录。

+ */ + public void changePassword(ChangePasswordParam param, HttpServletRequest request) { + User user = requireCurrentUser(); + if (!param.newPassword().equals(param.confirmPassword())) { + throw new BusinessException("两次输入的新密码不一致"); + } + userService.changePassword(user.getUserId(), param.oldPassword(), param.newPassword()); + revoke(request); + SecurityContextHolder.clearContext(); + } + + /** 绑定手机号:发送短信验证码(需登录) */ + public String sendBindPhoneCaptcha(BindPhoneCaptchaParam param, String clientIp) { + requireCurrentUser(); + checkSendIpLimit(clientIp); + smsCaptchaService.send(param.phone(), SCENE_LOGIN); + return CAPTCHA_SENT_MESSAGE; + } + + /** 绑定 / 换绑手机号;账号已绑定手机号时要求同时提供原密码 */ + public void bindPhone(BindPhoneParam param, HttpServletRequest request) { + User user = requireCurrentUser(); + if (StrUtil.isNotBlank(user.getPhone()) + && (StrUtil.isBlank(param.password()) + || !passwordEncoder.matches(param.password(), user.getPassword()))) { + throw new BusinessException("换绑手机号需要校验原密码"); + } + smsCaptchaService.verify(param.phone(), param.code()); + userService.bindPhone(user.getUserId(), param.phone()); + loginRecordService.record(user.getTenantId(), user.getUserId(), user.getUsername(), + LoginRecord.TYPE_BIND_PHONE, true, "绑定手机号 " + param.phone(), request); + } + + /** 自助找回密码:按 type 校验对应验证码后重置 */ + public String resetPasswordBySelf(SelfResetPasswordParam param, HttpServletRequest request) { + if (!param.newPassword().equals(param.confirmPassword())) { + throw new BusinessException("两次输入的新密码不一致"); + } + boolean byEmail = TYPE_EMAIL.equalsIgnoreCase(param.type()); + if (!byEmail && !TYPE_SMS.equalsIgnoreCase(param.type())) { + throw new BusinessException("不支持的找回方式"); + } + Long tenantId = param.tenantId(); + if (byEmail) { + emailCaptchaService.verify(param.account(), param.code()); + if (tenantId == null) { + tenantId = userService.resolveTenantIdByEmail(param.account()); + } + } else { + if (tenantId == null) { + throw new BusinessException("手机号找回需要指定租户"); + } + smsCaptchaService.verify(param.account(), param.code()); + } + if (tenantId == null) { + throw new BusinessException("账号不存在"); + } + Long resolvedTenantId = tenantId; + TenantContext.callWithTenant(resolvedTenantId, () -> { + User user = byEmail + ? userService.getByEmail(resolvedTenantId, param.account()) + : userService.getByPhone(resolvedTenantId, param.account()); + if (user == null) { + throw new BusinessException("账号不存在"); + } + userService.resetPasswordWithoutOld(user.getUserId(), param.newPassword()); + loginRecordService.record(resolvedTenantId, user.getUserId(), user.getUsername(), + LoginRecord.TYPE_LOGIN, true, "自助重置密码", request); + return null; + }); + return "密码已重置,请使用新密码登录"; + } + + /** + * 发送手机验证码。 + * + *

{@code scene=login} 只给已注册手机号真正发码;号码未注册时同样返回成功提示, + * 避免把「手机号是否注册」暴露成可枚举接口。

+ */ + public String sendSmsCaptcha(String phone, String scene, String clientIp) { + checkSendIpLimit(clientIp); + String resolvedScene = StrUtil.blankToDefault(scene, SCENE_LOGIN); + if (SCENE_LOGIN.equalsIgnoreCase(resolvedScene)) { + Long tenantId = TenantContext.getTenantId(); + if (tenantId != null && userService.getByPhone(tenantId, phone) == null) { + log.info("手机号未注册,静默返回不发送验证码:tenantId={}", tenantId); + return CAPTCHA_SENT_MESSAGE; + } + } + smsCaptchaService.send(phone, resolvedScene); + return CAPTCHA_SENT_MESSAGE; + } + + /** 发送邮箱验证码 */ + public String sendEmailCaptcha(String email, String clientIp) { + checkSendIpLimit(clientIp); + emailCaptchaService.send(email); + return CAPTCHA_SENT_MESSAGE; + } + + /** + * 授权码申请时的终端用户身份校验。 + * + *

该入口也是免登录的,且直接收账号密码,因此必须与账号密码登录共用同一套 + * 图形验证码与失败锁定策略——否则它就会变成绕过验证码、无限试密码的旁路。

+ */ + public User authenticateForAuthorize(Long tenantId, String username, String password, + String captchaId, String captchaCode, HttpServletRequest request) { + captchaService.verify(captchaId, captchaCode); + return checkPasswordAndLock(tenantId, username, password, request); + } + + /** 失败锁定 → 账号密码比对 → 账号状态校验 */ + private User checkPasswordAndLock(Long tenantId, String username, String password, + HttpServletRequest request) { + checkLoginLocked(tenantId, username); + User user = TenantContext.callWithTenant(tenantId, () -> { + tenantService.getRequired(tenantId); + User found = userService.getByUsername(tenantId, username); + if (found == null || StrUtil.isBlank(password) + || !passwordEncoder.matches(password, found.getPassword())) { + loginRecordService.record(tenantId, null, username, + LoginRecord.TYPE_LOGIN, false, "账号或密码错误", request); + increaseLoginFail(tenantId, username); + throw new BusinessException("账号或密码错误"); + } + checkUserEnabled(found, request); + return found; + }); + clearLoginFail(tenantId, username); + return user; + } + + /** 账号停用校验(各登录方式共用) */ + private void checkUserEnabled(User user, HttpServletRequest request) { + if (user.getStatus() == null || user.getStatus() != STATUS_ENABLED) { + loginRecordService.record(user.getTenantId(), user.getUserId(), user.getUsername(), + LoginRecord.TYPE_LOGIN, false, "账号已停用", request); + throw new BusinessException("账号已停用,请联系管理员"); + } + } + + /** 登录成功后的统一收尾:更新登录时间、签发令牌、写审计 */ + private LoginResult issueLogin(User user, HttpServletRequest request) { + userService.touchLastLogin(user.getUserId()); + String token = jwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId())); + loginRecordService.record(user.getTenantId(), user.getUserId(), user.getUsername(), + LoginRecord.TYPE_LOGIN, true, null, request); + log.info("用户登录成功 tenantId={} username={}", user.getTenantId(), user.getUsername()); + return new LoginResult(token, Constants.TOKEN_TYPE, jwtUtil.getExpireSeconds(), buildUserInfo(user)); + } + + /** 组装用户信息:角色与权限标识供控制台做按钮级控制 */ + private UserInfoResult buildUserInfo(User user) { + return TenantContext.callWithTenant(user.getTenantId(), () -> { + List roles = roleService.roleCodesByUserId(user.getUserId()); + List permissions = menuService.permissionsOfUser(user.getUserId()); + return new UserInfoResult(user.getUserId(), user.getTenantId(), user.getUsername(), + user.getNickname(), user.getAvatar(), user.getPhone(), user.getEmail(), + roles, permissions); + }); + } + + private User currentLoginUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + return null; + } + Object principal = authentication.getPrincipal(); + return principal instanceof User user ? user : null; + } + + private User requireCurrentUser() { + User user = currentLoginUser(); + if (user == null) { + throw new BusinessException(Constants.UNAUTHENTICATED_CODE, Constants.UNAUTHENTICATED_MSG); + } + return user; + } + + /** 把当前请求携带的令牌写入吊销名单 */ + private void revoke(HttpServletRequest request) { + if (request == null) { + return; + } + String rawToken = JwtUtil.getAccessToken(request); + if (StrUtil.isBlank(rawToken)) { + return; + } + try { + Claims claims = jwtUtil.parseToken(rawToken); + revokedTokenStore.revoke(claims.getId(), jwtUtil.remainingSeconds(claims)); + } catch (Exception e) { + log.debug("登出时解析令牌失败,忽略:{}", e.getMessage()); + } + } + + private void checkLoginLocked(Long tenantId, String account) { + if (cacheStore.exists(loginLockKey(tenantId, account))) { + long minutes = Math.max(1, authProperties.getLoginLockSeconds() / 60); + throw new BusinessException("账号已被锁定,请 " + minutes + " 分钟后再试"); + } + } + + private void increaseLoginFail(Long tenantId, String account) { + String failKey = "iam:login:fail:" + tenantId + ":" + account; + long count = cacheStore.increment(failKey, authProperties.getLoginLockSeconds()); + if (count >= authProperties.getLoginFailThreshold()) { + cacheStore.set(loginLockKey(tenantId, account), "1", authProperties.getLoginLockSeconds()); + cacheStore.delete(failKey); + } + } + + private void clearLoginFail(Long tenantId, String account) { + cacheStore.delete("iam:login:fail:" + tenantId + ":" + account); + cacheStore.delete(loginLockKey(tenantId, account)); + } + + private String loginLockKey(Long tenantId, String account) { + return "iam:login:lock:" + tenantId + ":" + account; + } + + /** 发码接口的 IP 维度限流:短间隔 + 每小时上限 */ + private void checkSendIpLimit(String clientIp) { + if (StrUtil.isBlank(clientIp)) { + return; + } + String prefix = "iam:code:send:ip:"; + boolean ok = cacheStore.setIfAbsent(prefix + clientIp, "1", authProperties.getCodeSendIpIntervalSeconds()); + if (!ok) { + throw new BusinessException("请求过于频繁,请稍后再试"); + } + long count = cacheStore.increment(prefix + clientIp + ":hourly", 3600); + if (count > authProperties.getCodeSendIpHourlyLimit()) { + throw new BusinessException("该 IP 发码次数已达上限,请稍后再试"); + } + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/service/CaptchaService.java b/iam-api/src/main/java/com/gxwebsoft/common/system/service/CaptchaService.java new file mode 100644 index 0000000..f72caf7 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/service/CaptchaService.java @@ -0,0 +1,80 @@ +package com.gxwebsoft.common.system.service; + +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.common.core.cache.CacheStore; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.system.config.AuthProperties; +import com.gxwebsoft.common.system.result.CaptchaResult; +import com.wf.captcha.SpecCaptcha; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** + * 图形验证码服务。 + * + *

答案只存缓存、不下发;校验成功后立即删除,避免同一个验证码被反复使用。 + * 缓存不可用时按配置降级放行,避免缓存故障导致全员无法登录。

+ * + * @author WebSoft + */ +@Service +public class CaptchaService { + + private static final Logger log = LoggerFactory.getLogger(CaptchaService.class); + + private static final String KEY_PREFIX = "iam:captcha:"; + + private static final int WIDTH = 130; + + private static final int HEIGHT = 48; + + private static final int LENGTH = 4; + + private final CacheStore cacheStore; + private final AuthProperties authProperties; + + public CaptchaService(CacheStore cacheStore, AuthProperties authProperties) { + this.cacheStore = cacheStore; + this.authProperties = authProperties; + } + + /** 生成验证码:返回标识与 Base64 图片 */ + public CaptchaResult generate() { + SpecCaptcha captcha = new SpecCaptcha(WIDTH, HEIGHT, LENGTH); + String captchaId = IdUtil.fastSimpleUUID(); + cacheStore.set(KEY_PREFIX + captchaId, captcha.text().toLowerCase(), authProperties.getCaptchaExpire()); + return new CaptchaResult(captchaId, captcha.toBase64()); + } + + /** + * 校验验证码。 + * + *

{@code config.auth.captcha-enabled=false} 时直接放行;缓存不可用时降级放行并打告警, + * 因为验证码失效只是「少一道风控」,而放行失败会让所有人都无法登录。

+ */ + public void verify(String captchaId, String captchaCode) { + if (!authProperties.isCaptchaEnabled()) { + return; + } + if (StrUtil.isBlank(captchaId) || StrUtil.isBlank(captchaCode)) { + throw new BusinessException("请输入图形验证码"); + } + String key = KEY_PREFIX + captchaId; + String expected; + try { + expected = cacheStore.get(key); + } catch (Exception e) { + log.warn("缓存不可用,图形验证码校验降级放行:{}", e.getMessage()); + return; + } + if (expected == null) { + throw new BusinessException("图形验证码已过期,请重新获取"); + } + if (!expected.equalsIgnoreCase(captchaCode.trim())) { + throw new BusinessException("图形验证码错误"); + } + cacheStore.delete(key); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/service/EmailCaptchaService.java b/iam-api/src/main/java/com/gxwebsoft/common/system/service/EmailCaptchaService.java new file mode 100644 index 0000000..04cbc18 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/service/EmailCaptchaService.java @@ -0,0 +1,96 @@ +package com.gxwebsoft.common.system.service; + +import cn.hutool.core.util.RandomUtil; +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.common.core.cache.CacheStore; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.system.config.AuthProperties; +import com.gxwebsoft.common.system.config.MailProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +/** + * 邮箱验证码:生成 / 限流 / 下发 / 校验。 + * + *

未配置 SMTP 时只写日志,保证本地与演示环境可用;生产配置 {@code spring.mail.*} 后自动发信。

+ * + * @author WebSoft + */ +@Service +public class EmailCaptchaService { + + private static final Logger log = LoggerFactory.getLogger(EmailCaptchaService.class); + + private static final String CODE_KEY = "iam:email:code:"; + private static final String INTERVAL_KEY = "iam:email:interval:"; + + private final CacheStore cacheStore; + private final AuthProperties authProperties; + private final MailProperties mailProperties; + private final ObjectProvider mailSenderProvider; + + public EmailCaptchaService(CacheStore cacheStore, AuthProperties authProperties, + MailProperties mailProperties, ObjectProvider mailSenderProvider) { + this.cacheStore = cacheStore; + this.authProperties = authProperties; + this.mailProperties = mailProperties; + this.mailSenderProvider = mailSenderProvider; + } + + /** 发送邮箱验证码;间隔 60 秒 */ + public void send(String email) { + if (!authProperties.isEmailCaptchaEnabled()) { + throw new BusinessException("邮箱验证码暂未开放"); + } + boolean ok = cacheStore.setIfAbsent(INTERVAL_KEY + email, "1", 60); + if (!ok) { + throw new BusinessException("请求过于频繁,请稍后再试"); + } + String code = RandomUtil.randomNumbers(6); + cacheStore.set(CODE_KEY + email, code, authProperties.getCaptchaExpire()); + sendMail(email, code); + } + + /** 校验并核销验证码 */ + public void verify(String email, String code) { + if (StrUtil.isBlank(email) || StrUtil.isBlank(code)) { + throw new BusinessException("验证码不能为空"); + } + String key = CODE_KEY + email; + String expected = cacheStore.get(key); + if (expected == null) { + throw new BusinessException("验证码已过期,请重新获取"); + } + if (!expected.equals(code.trim())) { + throw new BusinessException("验证码错误"); + } + cacheStore.delete(key); + } + + private void sendMail(String email, String code) { + JavaMailSender mailSender = mailSenderProvider.getIfAvailable(); + if (mailSender == null || !mailProperties.isEnabled()) { + log.info("邮件通道未启用,验证码仅记录日志:email={} code={}", email, code); + return; + } + try { + SimpleMailMessage message = new SimpleMailMessage(); + if (StrUtil.isNotBlank(mailProperties.getFrom())) { + message.setFrom(mailProperties.getFrom()); + } + message.setTo(email); + message.setSubject(mailProperties.getSubject()); + message.setText("您的验证码是:" + code + ",5 分钟内有效。如非本人操作请忽略本邮件。"); + mailSender.send(message); + } catch (Exception e) { + // 发信失败不能把验证码留在缓存里让别人无从得知,直接抛出并让调用方重试 + cacheStore.delete(CODE_KEY + email); + log.error("邮件发送失败:email={}", email, e); + throw new BusinessException("验证码发送失败,请稍后重试"); + } + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/service/LoggingSmsSender.java b/iam-api/src/main/java/com/gxwebsoft/common/system/service/LoggingSmsSender.java new file mode 100644 index 0000000..0f2e811 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/service/LoggingSmsSender.java @@ -0,0 +1,33 @@ +package com.gxwebsoft.common.system.service; + +import com.gxwebsoft.common.system.config.SmsProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * 默认短信通道:只写日志。 + * + *

接入真实短信服务时提供一个 {@link SmsSender} 实现,本 Bean 自动失效 + * (装配见 {@code SmsConfig})。

+ * + * @author WebSoft + */ +public class LoggingSmsSender implements SmsSender { + + private static final Logger log = LoggerFactory.getLogger(LoggingSmsSender.class); + + private final SmsProperties smsProperties; + + public LoggingSmsSender(SmsProperties smsProperties) { + this.smsProperties = smsProperties; + } + + @Override + public void send(String phone, String code) { + if (smsProperties.isEnabled()) { + log.warn("config.sms.enabled=true 但未接入短信通道,验证码仅记录日志:phone={} code={}", phone, code); + return; + } + log.info("短信通道未启用,验证码仅记录日志:phone={} code={}", phone, code); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/service/LoginRecordService.java b/iam-api/src/main/java/com/gxwebsoft/common/system/service/LoginRecordService.java new file mode 100644 index 0000000..df184ef --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/service/LoginRecordService.java @@ -0,0 +1,53 @@ +package com.gxwebsoft.common.system.service; + +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.common.core.util.IpUtils; +import com.gxwebsoft.common.system.entity.LoginRecord; +import com.gxwebsoft.common.system.mapper.LoginRecordMapper; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** + * 登录审计流水。 + * + *

审计属于旁路能力:写库失败不能把登录流程带崩,因此这里吞掉异常只记日志。

+ * + * @author WebSoft + */ +@Service +public class LoginRecordService { + + private static final Logger log = LoggerFactory.getLogger(LoginRecordService.class); + + private static final int USER_AGENT_MAX_LENGTH = 500; + private static final int MESSAGE_MAX_LENGTH = 255; + + private final LoginRecordMapper loginRecordMapper; + + public LoginRecordService(LoginRecordMapper loginRecordMapper) { + this.loginRecordMapper = loginRecordMapper; + } + + /** 记录一条审计流水 */ + public void record(Long tenantId, Long userId, String username, int type, boolean success, + String message, HttpServletRequest request) { + try { + LoginRecord record = new LoginRecord(); + record.setTenantId(tenantId); + record.setUserId(userId); + record.setUsername(username); + record.setType(type); + record.setStatus(success ? LoginRecord.STATUS_SUCCESS : LoginRecord.STATUS_FAILURE); + record.setMessage(StrUtil.brief(message, MESSAGE_MAX_LENGTH)); + if (request != null) { + record.setIp(IpUtils.clientIp(request)); + record.setUserAgent(StrUtil.brief(request.getHeader("User-Agent"), USER_AGENT_MAX_LENGTH)); + } + loginRecordMapper.insert(record); + } catch (Exception e) { + log.warn("登录审计写入失败 tenantId={} username={}:{}", tenantId, username, e.getMessage()); + } + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/service/MenuService.java b/iam-api/src/main/java/com/gxwebsoft/common/system/service/MenuService.java new file mode 100644 index 0000000..f7aae5f --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/service/MenuService.java @@ -0,0 +1,147 @@ +package com.gxwebsoft.common.system.service; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.system.entity.Menu; +import com.gxwebsoft.common.system.mapper.MenuMapper; +import com.gxwebsoft.common.system.param.MenuPageParam; +import com.gxwebsoft.common.system.param.MenuSaveParam; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 菜单 / 权限服务。 + * + *

菜单是平台级元数据({@code sys_menu} 无 tenant_id),角色通过 {@code sys_role_menu} + * 决定各自可见范围,因此菜单本身不做租户隔离。

+ * + * @author WebSoft + */ +@Service +public class MenuService { + + private static final int STATUS_ENABLED = 1; + + private final MenuMapper menuMapper; + + public MenuService(MenuMapper menuMapper) { + this.menuMapper = menuMapper; + } + + public Menu getRequired(Long menuId) { + Menu menu = menuMapper.selectById(menuId); + if (menu == null) { + throw new BusinessException("菜单不存在"); + } + return menu; + } + + /** 启用菜单(扁平列表) */ + public List listEnabled(MenuPageParam param) { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery().eq(Menu::getStatus, STATUS_ENABLED); + if (param != null) { + if (StrUtil.isNotBlank(param.getMenuName())) { + wrapper.like(Menu::getMenuName, param.getMenuName().trim()); + } + if (param.getMenuType() != null) { + wrapper.eq(Menu::getMenuType, param.getMenuType()); + } + } + return menuMapper.selectList(wrapper.orderByAsc(Menu::getSort, Menu::getMenuId)); + } + + /** 菜单树 */ + public List tree(MenuPageParam param) { + return buildTree(listEnabled(param)); + } + + /** 用户可见的菜单树(按已授权角色过滤) */ + public List treeOfUser(Long userId) { + return buildTree(menuMapper.selectByUserId(userId)); + } + + /** 用户权限标识集合 */ + public List permissionsOfUser(Long userId) { + return menuMapper.selectPermissionsByUserId(userId); + } + + public Long save(MenuSaveParam param) { + if (param.menuId() == null) { + checkPermissionUnique(param.permission(), null); + Menu menu = new Menu(); + apply(menu, param); + menuMapper.insert(menu); + return menu.getMenuId(); + } + Menu existing = getRequired(param.menuId()); + if (param.menuId().equals(param.parentId())) { + throw new BusinessException("上级菜单不能是自己"); + } + checkPermissionUnique(param.permission(), param.menuId()); + apply(existing, param); + menuMapper.updateById(existing); + return existing.getMenuId(); + } + + /** 逻辑删除菜单:存在下级菜单时不允许删除,避免权限静默失效 */ + public void remove(Long menuId) { + getRequired(menuId); + Long children = menuMapper.selectCount(Wrappers.lambdaQuery().eq(Menu::getParentId, menuId)); + if (children != null && children > 0) { + throw new BusinessException("请先删除下级菜单"); + } + menuMapper.deleteById(menuId); + } + + private void apply(Menu menu, MenuSaveParam param) { + menu.setParentId(param.parentId() == null ? 0L : param.parentId()); + menu.setMenuName(param.menuName()); + menu.setMenuType(param.menuType()); + menu.setPath(param.path()); + menu.setComponent(param.component()); + menu.setPermission(param.permission()); + menu.setIcon(param.icon()); + menu.setSort(param.sort() == null ? 0 : param.sort()); + menu.setVisible(param.visible() == null ? 1 : param.visible()); + menu.setStatus(param.status() == null ? STATUS_ENABLED : param.status()); + } + + private void checkPermissionUnique(String permission, Long excludeMenuId) { + if (StrUtil.isBlank(permission)) { + return; + } + Long count = menuMapper.selectCount(Wrappers.lambdaQuery() + .eq(Menu::getPermission, permission.trim()) + .ne(excludeMenuId != null, Menu::getMenuId, excludeMenuId)); + if (count != null && count > 0) { + throw new BusinessException("权限标识已存在"); + } + } + + /** 扁平列表构建为树:先建索引再挂父子,避免 O(n^2) */ + private List buildTree(List menus) { + Map index = new LinkedHashMap<>(); + for (Menu menu : menus) { + menu.setChildren(new ArrayList<>()); + index.put(menu.getMenuId(), menu); + } + List roots = new ArrayList<>(); + for (Menu menu : menus) { + Long parentId = menu.getParentId(); + Menu parent = parentId == null ? null : index.get(parentId); + if (parent == null) { + // 父节点不在可见集合内(未授权 / 已停用)时按根节点处理,避免整棵子树丢失 + roots.add(menu); + } else { + parent.getChildren().add(menu); + } + } + return roots; + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/service/RoleService.java b/iam-api/src/main/java/com/gxwebsoft/common/system/service/RoleService.java new file mode 100644 index 0000000..4cd273c --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/service/RoleService.java @@ -0,0 +1,132 @@ +package com.gxwebsoft.common.system.service; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.gxwebsoft.common.core.context.TenantContext; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.core.web.PageParam; +import com.gxwebsoft.common.system.entity.Role; +import com.gxwebsoft.common.system.entity.RoleMenu; +import com.gxwebsoft.common.system.mapper.RoleMapper; +import com.gxwebsoft.common.system.mapper.RoleMenuMapper; +import com.gxwebsoft.common.system.param.RolePageParam; +import com.gxwebsoft.common.system.param.RoleSaveParam; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Objects; + +/** + * 角色服务。 + * + * @author WebSoft + */ +@Service +public class RoleService { + + private static final int STATUS_ENABLED = 1; + + private final RoleMapper roleMapper; + private final RoleMenuMapper roleMenuMapper; + + public RoleService(RoleMapper roleMapper, RoleMenuMapper roleMenuMapper) { + this.roleMapper = roleMapper; + this.roleMenuMapper = roleMenuMapper; + } + + public Role getRequired(Long roleId) { + Role role = roleMapper.selectById(roleId); + if (role == null) { + throw new BusinessException("角色不存在"); + } + return role; + } + + public Page page(RolePageParam param) { + PageParam page = new PageParam<>(Role.class, param); + page.setDefaultOrder("sort asc, role_id asc"); + return roleMapper.selectPage(page, page.getWrapper()); + } + + /** 当前租户全部启用角色(下拉选择用) */ + public List listEnabled() { + return roleMapper.selectList(Wrappers.lambdaQuery() + .eq(Role::getStatus, STATUS_ENABLED) + .orderByAsc(Role::getSort, Role::getRoleId)); + } + + public List listByUserId(Long userId) { + return roleMapper.selectByUserId(userId); + } + + public List roleCodesByUserId(Long userId) { + return roleMapper.selectRoleCodesByUserId(userId); + } + + public Long save(RoleSaveParam param) { + Long tenantId = param.tenantId() != null ? param.tenantId() : TenantContext.getTenantId(); + if (tenantId == null) { + throw new BusinessException("租户ID不能为空"); + } + if (param.roleId() == null) { + checkCodeUnique(tenantId, param.roleCode(), null); + Role role = new Role(); + role.setTenantId(tenantId); + apply(role, param); + roleMapper.insert(role); + return role.getRoleId(); + } + Role existing = getRequired(param.roleId()); + checkCodeUnique(existing.getTenantId(), param.roleCode(), param.roleId()); + apply(existing, param); + roleMapper.updateById(existing); + return existing.getRoleId(); + } + + @Transactional(rollbackFor = Exception.class) + public void remove(Long roleId) { + getRequired(roleId); + roleMenuMapper.delete(Wrappers.lambdaQuery().eq(RoleMenu::getRoleId, roleId)); + roleMapper.deleteById(roleId); + } + + /** 覆盖角色的菜单 / 权限授权 */ + @Transactional(rollbackFor = Exception.class) + public void assignMenus(Long roleId, List menuIds) { + getRequired(roleId); + roleMenuMapper.delete(Wrappers.lambdaQuery().eq(RoleMenu::getRoleId, roleId)); + if (menuIds == null) { + return; + } + menuIds.stream().filter(Objects::nonNull).distinct() + .forEach(menuId -> roleMenuMapper.insert(new RoleMenu(roleId, menuId))); + } + + public List menuIdsOfRole(Long roleId) { + return roleMenuMapper.selectList(Wrappers.lambdaQuery().eq(RoleMenu::getRoleId, roleId)) + .stream().map(RoleMenu::getMenuId).toList(); + } + + private void apply(Role role, RoleSaveParam param) { + role.setRoleName(param.roleName()); + role.setRoleCode(param.roleCode()); + role.setSort(param.sort() == null ? 0 : param.sort()); + role.setRemark(param.remark()); + role.setStatus(param.status() == null ? STATUS_ENABLED : param.status()); + } + + private void checkCodeUnique(Long tenantId, String roleCode, Long excludeRoleId) { + if (StrUtil.isBlank(roleCode)) { + return; + } + Long count = roleMapper.selectCount(Wrappers.lambdaQuery() + .eq(Role::getTenantId, tenantId) + .eq(Role::getRoleCode, roleCode.trim()) + .ne(excludeRoleId != null, Role::getRoleId, excludeRoleId)); + if (count != null && count > 0) { + throw new BusinessException("角色标识已存在"); + } + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/service/SmsCaptchaService.java b/iam-api/src/main/java/com/gxwebsoft/common/system/service/SmsCaptchaService.java new file mode 100644 index 0000000..89e60df --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/service/SmsCaptchaService.java @@ -0,0 +1,109 @@ +package com.gxwebsoft.common.system.service; + +import cn.hutool.core.util.RandomUtil; +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.common.core.cache.CacheStore; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.system.config.AuthProperties; +import com.gxwebsoft.common.system.config.SmsProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** + * 短信验证码:生成 / 限流 / 下发 / 校验。 + * + * @author WebSoft + */ +@Service +public class SmsCaptchaService { + + private static final Logger log = LoggerFactory.getLogger(SmsCaptchaService.class); + + /** 统一的发码提示:账号是否存在不对外区分,避免手机号枚举 */ + public static final String SENT_MESSAGE = "验证码已发送,请注意查收(5 分钟内有效)"; + + private static final String CODE_KEY = "iam:sms:code:"; + private static final String INTERVAL_KEY = "iam:sms:interval:"; + private static final String DAILY_KEY = "iam:sms:daily:"; + + /** 场景:登录(默认),只对已注册账号发码 */ + private static final String SCENE_LOGIN = "login"; + + /** 场景:注册,允许未注册账号发码 */ + private static final String SCENE_REGISTER = "register"; + + private final CacheStore cacheStore; + private final SmsProperties smsProperties; + private final AuthProperties authProperties; + private final SmsSender smsSender; + + public SmsCaptchaService(CacheStore cacheStore, SmsProperties smsProperties, + AuthProperties authProperties, SmsSender smsSender) { + this.cacheStore = cacheStore; + this.smsProperties = smsProperties; + this.authProperties = authProperties; + this.smsSender = smsSender; + } + + /** + * 发送验证码。 + * + * @param scene login 只对已注册手机号发码;register 允许未注册(需开启自助注册) + */ + public void send(String phone, String scene) { + if (!SCENE_LOGIN.equalsIgnoreCase(StrUtil.blankToDefault(scene, SCENE_LOGIN))) { + if (!SCENE_REGISTER.equalsIgnoreCase(scene) || !authProperties.isRegisterEnabled()) { + throw new BusinessException("该场景暂未开放"); + } + } + checkInterval(phone); + checkDailyLimit(phone); + String code = RandomUtil.randomNumbers(6); + cacheStore.set(CODE_KEY + phone, code, smsProperties.getCodeExpire()); + smsSender.send(phone, code); + log.info("短信验证码已下发:phone={} scene={}", phone, scene); + } + + /** 校验并核销验证码(成功后立即删除,避免同一验证码被反复试探) */ + public void verify(String phone, String code) { + if (StrUtil.isBlank(phone) || StrUtil.isBlank(code)) { + throw new BusinessException("验证码不能为空"); + } + String key = CODE_KEY + phone; + String expected; + try { + expected = cacheStore.get(key); + } catch (Exception e) { + log.warn("缓存不可用,短信验证码校验降级放行:{}", e.getMessage()); + return; + } + if (expected == null) { + throw new BusinessException("验证码已过期,请重新获取"); + } + if (!expected.equals(code.trim())) { + throw new BusinessException("验证码错误"); + } + cacheStore.delete(key); + } + + private void checkInterval(String phone) { + if (smsProperties.getIntervalSeconds() <= 0) { + return; + } + boolean ok = cacheStore.setIfAbsent(INTERVAL_KEY + phone, "1", smsProperties.getIntervalSeconds()); + if (!ok) { + throw new BusinessException("请求过于频繁,请稍后再试"); + } + } + + private void checkDailyLimit(String phone) { + if (smsProperties.getDailyLimit() <= 0) { + return; + } + long count = cacheStore.increment(DAILY_KEY + phone, 86400); + if (count > smsProperties.getDailyLimit()) { + throw new BusinessException("今日验证码发送次数已达上限"); + } + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/service/SmsSender.java b/iam-api/src/main/java/com/gxwebsoft/common/system/service/SmsSender.java new file mode 100644 index 0000000..ae31d1b --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/service/SmsSender.java @@ -0,0 +1,16 @@ +package com.gxwebsoft.common.system.service; + +/** + * 短信下发扩展点。 + * + *

iam-api 只负责「生成 / 校验 / 限流」验证码,具体短信通道由实现决定。 + * 默认实现 {@link LoggingSmsSender} 只写日志,接入阿里云 / 腾讯云时新增一个实现即可, + * 不需要改动验证码逻辑。

+ * + * @author WebSoft + */ +public interface SmsSender { + + /** 下发验证码短信 */ + void send(String phone, String code); +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/service/TenantService.java b/iam-api/src/main/java/com/gxwebsoft/common/system/service/TenantService.java new file mode 100644 index 0000000..8bd1a32 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/service/TenantService.java @@ -0,0 +1,146 @@ +package com.gxwebsoft.common.system.service; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.gxwebsoft.common.core.context.TenantContext; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.core.web.PageParam; +import com.gxwebsoft.common.system.entity.Tenant; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.common.system.mapper.TenantMapper; +import com.gxwebsoft.common.system.param.TenantOpenParam; +import com.gxwebsoft.common.system.param.TenantPageParam; +import com.gxwebsoft.common.system.param.TenantSaveParam; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * 租户服务。 + * + *

租户是平台级数据({@code sys_tenant} 不做租户隔离),只有平台运营角色可以管理, + * 权限控制在 Controller 层通过 {@code @PreAuthorize} 完成。

+ * + * @author WebSoft + */ +@Service +public class TenantService { + + private static final Logger log = LoggerFactory.getLogger(TenantService.class); + + private static final int STATUS_ENABLED = 1; + + private final TenantMapper tenantMapper; + private final UserService userService; + private final PasswordEncoder passwordEncoder; + + public TenantService(TenantMapper tenantMapper, UserService userService, PasswordEncoder passwordEncoder) { + this.tenantMapper = tenantMapper; + this.userService = userService; + this.passwordEncoder = passwordEncoder; + } + + /** 取租户并校验存在 / 启用 / 未过期 */ + public Tenant getRequired(Long tenantId) { + if (tenantId == null) { + throw new BusinessException("租户ID不能为空"); + } + Tenant tenant = TenantContext.callIgnoreTenant(() -> tenantMapper.selectById(tenantId)); + if (tenant == null) { + throw new BusinessException("租户不存在"); + } + if (tenant.getStatus() == null || tenant.getStatus() != STATUS_ENABLED) { + throw new BusinessException("租户已停用,请联系平台管理员"); + } + if (tenant.getExpireTime() != null && tenant.getExpireTime().isBefore(LocalDateTime.now())) { + throw new BusinessException("租户已到期,请联系平台管理员续期"); + } + return tenant; + } + + /** 分页查询 */ + public Page page(TenantPageParam param) { + PageParam page = new PageParam<>(Tenant.class, param); + page.setDefaultOrder("tenant_id desc"); + return TenantContext.callIgnoreTenant(() -> tenantMapper.selectPage(page, page.getWrapper())); + } + + /** 新增 / 修改租户 */ + public Long save(TenantSaveParam param) { + if (param.tenantId() == null) { + checkNameUnique(param.tenantName(), null); + Tenant tenant = new Tenant(); + apply(tenant, param); + TenantContext.callIgnoreTenant(() -> tenantMapper.insert(tenant)); + return tenant.getTenantId(); + } + Tenant existing = getRequired(param.tenantId()); + checkNameUnique(param.tenantName(), param.tenantId()); + apply(existing, param); + TenantContext.callIgnoreTenant(() -> tenantMapper.updateById(existing)); + return existing.getTenantId(); + } + + /** 逻辑删除租户 */ + public void remove(Long tenantId) { + getRequired(tenantId); + TenantContext.callIgnoreTenant(() -> tenantMapper.deleteById(tenantId)); + } + + /** + * 开通租户账号:一次请求创建租户与该租户的管理员账号。 + * + *

自助注册关闭时,这是新租户进入平台的唯一入口(平台运营角色调用)。

+ */ + @Transactional(rollbackFor = Exception.class) + public Long open(TenantOpenParam param) { + // 开通租户是对外暴露的入口,管理员密码必须先过强度校验,避免留下弱口令后门 + userService.checkPasswordStrength(param.adminPassword()); + checkNameUnique(param.tenantName(), null); + Tenant tenant = new Tenant(); + tenant.setTenantName(param.tenantName()); + tenant.setTenantCode(param.tenantCode()); + tenant.setContactName(param.contactName()); + tenant.setContactPhone(param.contactPhone()); + tenant.setStatus(STATUS_ENABLED); + TenantContext.callIgnoreTenant(() -> tenantMapper.insert(tenant)); + + User admin = new User(); + admin.setTenantId(tenant.getTenantId()); + admin.setUsername(param.adminUsername()); + admin.setPassword(passwordEncoder.encode(param.adminPassword())); + admin.setNickname(param.adminUsername()); + admin.setStatus(STATUS_ENABLED); + TenantContext.callWithTenant(tenant.getTenantId(), () -> userService.createAdmin(admin)); + + log.info("租户开通成功 tenantId={} tenantName={}", tenant.getTenantId(), param.tenantName()); + return tenant.getTenantId(); + } + + private void apply(Tenant tenant, TenantSaveParam param) { + tenant.setTenantName(param.tenantName()); + tenant.setTenantCode(param.tenantCode()); + tenant.setContactName(param.contactName()); + tenant.setContactPhone(param.contactPhone()); + tenant.setExpireTime(param.expireTime()); + tenant.setStatus(param.status() == null ? STATUS_ENABLED : param.status()); + } + + private void checkNameUnique(String tenantName, Long excludeTenantId) { + if (StrUtil.isBlank(tenantName)) { + return; + } + Long count = TenantContext.callIgnoreTenant(() -> tenantMapper.selectCount( + Wrappers.lambdaQuery() + .eq(Tenant::getTenantName, tenantName.trim()) + .ne(excludeTenantId != null, Tenant::getTenantId, excludeTenantId))); + if (count != null && count > 0) { + throw new BusinessException("租户名称已存在"); + } + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/service/UserService.java b/iam-api/src/main/java/com/gxwebsoft/common/system/service/UserService.java new file mode 100644 index 0000000..3a135a2 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/common/system/service/UserService.java @@ -0,0 +1,261 @@ +package com.gxwebsoft.common.system.service; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.gxwebsoft.common.core.context.TenantContext; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.core.web.PageParam; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.common.system.entity.UserRole; +import com.gxwebsoft.common.system.mapper.UserMapper; +import com.gxwebsoft.common.system.mapper.UserRoleMapper; +import com.gxwebsoft.common.system.param.UserPageParam; +import com.gxwebsoft.common.system.param.UserSaveParam; +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.List; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * 用户服务。 + * + * @author WebSoft + */ +@Service +public class UserService { + + private static final int STATUS_ENABLED = 1; + + private static final int PASSWORD_MIN_LENGTH = 8; + + private static final Pattern PASSWORD_HAS_LETTER = Pattern.compile(".*[A-Za-z].*"); + + private static final Pattern PASSWORD_HAS_DIGIT = Pattern.compile(".*\\d.*"); + + private final UserMapper userMapper; + private final UserRoleMapper userRoleMapper; + private final PasswordEncoder passwordEncoder; + + public UserService(UserMapper userMapper, UserRoleMapper userRoleMapper, PasswordEncoder passwordEncoder) { + this.userMapper = userMapper; + this.userRoleMapper = userRoleMapper; + this.passwordEncoder = passwordEncoder; + } + + public User getById(Long userId) { + return userMapper.selectById(userId); + } + + /** 按「租户 + 账号」查用户 */ + public User getByUsername(Long tenantId, String username) { + return oneBy(Wrappers.lambdaQuery() + .eq(User::getTenantId, tenantId) + .eq(User::getUsername, username)); + } + + /** 按「租户 + 邮箱」查用户 */ + public User getByEmail(Long tenantId, String email) { + return oneBy(Wrappers.lambdaQuery() + .eq(User::getTenantId, tenantId) + .eq(User::getEmail, email)); + } + + /** 按「租户 + 手机号」查用户 */ + public User getByPhone(Long tenantId, String phone) { + return oneBy(Wrappers.lambdaQuery() + .eq(User::getTenantId, tenantId) + .eq(User::getPhone, phone)); + } + + /** + * 按邮箱跨租户反查所属租户。 + * + *

邮箱是平台级登录标识:登录页因此无需先选租户。多租户插件默认会给 {@code sys_user} + * 注入租户条件,这里必须显式忽略,否则永远查不到。

+ */ + public Long resolveTenantIdByEmail(String email) { + User user = TenantContext.callIgnoreTenant(() -> oneBy(Wrappers.lambdaQuery() + .eq(User::getEmail, email) + .eq(User::getStatus, STATUS_ENABLED))); + return user == null ? null : user.getTenantId(); + } + + /** 分页查询 */ + public Page page(UserPageParam param) { + PageParam page = new PageParam<>(User.class, param); + page.setDefaultOrder("user_id desc"); + return userMapper.selectPage(page, page.getWrapper()); + } + + /** 新增 / 修改用户,同时覆盖角色关系 */ + @Transactional(rollbackFor = Exception.class) + public Long save(UserSaveParam param) { + Long tenantId = param.tenantId() != null ? param.tenantId() : TenantContext.getTenantId(); + if (tenantId == null) { + throw new BusinessException("租户ID不能为空"); + } + if (param.userId() == null) { + if (StrUtil.isBlank(param.password())) { + throw new BusinessException("新建用户必须设置初始密码"); + } + checkPasswordStrength(param.password()); + checkUnique(tenantId, param.username(), param.email(), param.phone(), null); + User user = new User(); + user.setTenantId(tenantId); + user.setUsername(param.username()); + user.setPassword(passwordEncoder.encode(param.password())); + applyProfile(user, param); + userMapper.insert(user); + replaceRoles(user.getUserId(), param.roleIds()); + return user.getUserId(); + } + + User existing = requireUser(param.userId()); + checkUnique(existing.getTenantId(), param.username(), param.email(), param.phone(), param.userId()); + existing.setUsername(param.username()); + if (StrUtil.isNotBlank(param.password())) { + checkPasswordStrength(param.password()); + existing.setPassword(passwordEncoder.encode(param.password())); + } + applyProfile(existing, param); + userMapper.updateById(existing); + replaceRoles(existing.getUserId(), param.roleIds()); + return existing.getUserId(); + } + + /** 创建租户管理员:租户开通流程内部使用,调用方已切好租户上下文 */ + public Long createAdmin(User user) { + userMapper.insert(user); + return user.getUserId(); + } + + /** 逻辑删除用户:同时清掉角色关系,避免残留授权 */ + @Transactional(rollbackFor = Exception.class) + public void remove(Long userId) { + User user = requireUser(userId); + userRoleMapper.delete(Wrappers.lambdaQuery().eq(UserRole::getUserId, user.getUserId())); + userMapper.deleteById(userId); + } + + /** 管理员重置密码:不需要原密码 */ + public void resetPassword(Long userId, String newPassword) { + checkPasswordStrength(newPassword); + User user = requireUser(userId); + user.setPassword(passwordEncoder.encode(newPassword)); + userMapper.updateById(user); + } + + /** 自助修改密码:校验原密码,且新密码不能与原密码相同 */ + public void changePassword(Long userId, String oldPassword, String newPassword) { + checkPasswordStrength(newPassword); + User user = requireUser(userId); + if (!passwordEncoder.matches(oldPassword, user.getPassword())) { + throw new BusinessException("原密码不正确"); + } + if (passwordEncoder.matches(newPassword, user.getPassword())) { + throw new BusinessException("新密码不能与原密码相同"); + } + user.setPassword(passwordEncoder.encode(newPassword)); + userMapper.updateById(user); + } + + /** 自助找回密码:身份由验证码保证,不校验原密码 */ + public void resetPasswordWithoutOld(Long userId, String newPassword) { + checkPasswordStrength(newPassword); + User user = requireUser(userId); + user.setPassword(passwordEncoder.encode(newPassword)); + userMapper.updateById(user); + } + + /** 绑定 / 换绑手机号 */ + public void bindPhone(Long userId, String phone) { + User user = requireUser(userId); + checkUnique(user.getTenantId(), null, null, phone, userId); + user.setPhone(phone); + userMapper.updateById(user); + } + + /** 更新最后登录时间 */ + public void touchLastLogin(Long userId) { + User update = new User(); + update.setUserId(userId); + update.setLastLoginTime(LocalDateTime.now()); + userMapper.updateById(update); + } + + /** 密码强度:至少 8 位且同时包含字母与数字 */ + public void checkPasswordStrength(String password) { + if (StrUtil.isBlank(password) || password.length() < PASSWORD_MIN_LENGTH + || !PASSWORD_HAS_LETTER.matcher(password).matches() + || !PASSWORD_HAS_DIGIT.matcher(password).matches()) { + throw new BusinessException("密码至少 8 位,且必须同时包含字母和数字"); + } + } + + private void replaceRoles(Long userId, List roleIds) { + userRoleMapper.delete(Wrappers.lambdaQuery().eq(UserRole::getUserId, userId)); + if (roleIds == null) { + return; + } + roleIds.stream().filter(Objects::nonNull).distinct() + .forEach(roleId -> userRoleMapper.insert(new UserRole(userId, roleId))); + } + + private void applyProfile(User user, UserSaveParam param) { + user.setNickname(param.nickname()); + user.setEmail(param.email()); + user.setAvatar(param.avatar()); + user.setStatus(param.status() == null ? STATUS_ENABLED : param.status()); + if (StrUtil.isNotBlank(param.phone())) { + user.setPhone(param.phone()); + } + } + + private void checkUnique(Long tenantId, String username, String email, String phone, Long excludeUserId) { + if (StrUtil.isNotBlank(username)) { + Long count = userMapper.selectCount(Wrappers.lambdaQuery() + .eq(User::getTenantId, tenantId) + .eq(User::getUsername, username.trim()) + .ne(excludeUserId != null, User::getUserId, excludeUserId)); + if (count != null && count > 0) { + throw new BusinessException("账号已存在"); + } + } + if (StrUtil.isNotBlank(email)) { + Long count = userMapper.selectCount(Wrappers.lambdaQuery() + .eq(User::getTenantId, tenantId) + .eq(User::getEmail, email.trim()) + .ne(excludeUserId != null, User::getUserId, excludeUserId)); + if (count != null && count > 0) { + throw new BusinessException("邮箱已被使用"); + } + } + if (StrUtil.isNotBlank(phone)) { + Long count = userMapper.selectCount(Wrappers.lambdaQuery() + .eq(User::getTenantId, tenantId) + .eq(User::getPhone, phone.trim()) + .ne(excludeUserId != null, User::getUserId, excludeUserId)); + if (count != null && count > 0) { + throw new BusinessException("手机号已被使用"); + } + } + } + + private User requireUser(Long userId) { + User user = userMapper.selectById(userId); + if (user == null) { + throw new BusinessException("用户不存在"); + } + return user; + } + + private User oneBy(LambdaQueryWrapper wrapper) { + return userMapper.selectOne(wrapper.last("limit 1")); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/iam/config/IamJwtConfig.java b/iam-api/src/main/java/com/gxwebsoft/iam/config/IamJwtConfig.java new file mode 100644 index 0000000..0635318 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/iam/config/IamJwtConfig.java @@ -0,0 +1,124 @@ +package com.gxwebsoft.iam.config; + +import cn.hutool.core.util.StrUtil; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; + +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateCrtKey; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.RSAPublicKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +/** + * 统一令牌非对称密钥与 JWT 编解码器。 + * + *

资源服务(open-api / dp-api)只通过 JWKS 获取公钥校验令牌,不接触私钥, + * 也不需要共享对称密钥。

+ * + * @author WebSoft + */ +@Configuration +public class IamJwtConfig { + + private static final Logger log = LoggerFactory.getLogger(IamJwtConfig.class); + + @Bean + public RSAKey iamRsaKey(IamJwtProperties properties) { + if (StrUtil.isBlank(properties.getPrivateKey())) { + log.warn("未配置 config.iam-jwt.private-key,已生成临时 RSA 密钥对;" + + "重启后此前签发的令牌会全部失效,生产环境必须配置固定密钥"); + return generateKey(properties.getKeyId()); + } + try { + RSAPrivateKey privateKey = parsePrivateKey(properties.getPrivateKey()); + RSAPublicKey publicKey = StrUtil.isBlank(properties.getPublicKey()) + ? derivePublicKey(privateKey) + : parsePublicKey(properties.getPublicKey()); + return new RSAKey.Builder(publicKey) + .privateKey(privateKey) + .keyID(properties.getKeyId()) + .algorithm(JWSAlgorithm.RS256) + .build(); + } catch (Exception e) { + throw new IllegalStateException("iam 令牌 RSA 密钥解析失败,请检查 config.iam-jwt.private-key", e); + } + } + + @Bean + public JWKSource iamJwkSource(RSAKey iamRsaKey) { + return new ImmutableJWKSet<>(new JWKSet(iamRsaKey)); + } + + @Bean + public JwtEncoder iamJwtEncoder(JWKSource iamJwkSource) { + return new NimbusJwtEncoder(iamJwkSource); + } + + @Bean + public JwtDecoder iamJwtDecoder(RSAKey iamRsaKey) { + try { + return NimbusJwtDecoder.withPublicKey(iamRsaKey.toRSAPublicKey()).build(); + } catch (Exception e) { + throw new IllegalStateException("iam 令牌解码器初始化失败", e); + } + } + + private RSAKey generateKey(String keyId) { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + KeyPair keyPair = generator.generateKeyPair(); + return new RSAKey.Builder((RSAPublicKey) keyPair.getPublic()) + .privateKey((RSAPrivateKey) keyPair.getPrivate()) + .keyID(keyId) + .algorithm(JWSAlgorithm.RS256) + .build(); + } catch (Exception e) { + throw new IllegalStateException("生成 iam 令牌 RSA 密钥失败", e); + } + } + + /** 私钥缺省公钥时,用 CRT 参数还原公钥 */ + private RSAPublicKey derivePublicKey(RSAPrivateKey privateKey) throws Exception { + if (privateKey instanceof RSAPrivateCrtKey crtKey) { + RSAPublicKeySpec spec = new RSAPublicKeySpec(crtKey.getModulus(), crtKey.getPublicExponent()); + return (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(spec); + } + throw new IllegalStateException("无法从私钥推导公钥,请同时配置 config.iam-jwt.public-key"); + } + + private RSAPrivateKey parsePrivateKey(String pem) throws Exception { + byte[] content = decodePem(pem, "PRIVATE KEY"); + return (RSAPrivateKey) KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(content)); + } + + private RSAPublicKey parsePublicKey(String pem) throws Exception { + byte[] content = decodePem(pem, "PUBLIC KEY"); + return (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(content)); + } + + private byte[] decodePem(String pem, String type) { + String content = pem.replace("-----BEGIN " + type + "-----", "") + .replace("-----END " + type + "-----", "") + .replaceAll("\\s", ""); + return Base64.getDecoder().decode(content); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/iam/config/IamJwtProperties.java b/iam-api/src/main/java/com/gxwebsoft/iam/config/IamJwtProperties.java new file mode 100644 index 0000000..457f166 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/iam/config/IamJwtProperties.java @@ -0,0 +1,40 @@ +package com.gxwebsoft.iam.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 统一令牌(RS256)配置(前缀 {@code config.iam-jwt})。 + * + * @author WebSoft + */ +@Getter +@Setter +@ConfigurationProperties(prefix = "config.iam-jwt") +public class IamJwtProperties { + + /** 签发方,资源服务需校验 iss */ + private String issuer = "https://iam.websoft.top"; + + /** 默认 audience;接入方有自己的 audience 时以接入方配置为准 */ + private String audience = "websoft-platform"; + + /** 密钥标识,JWKS 中返回 */ + private String keyId = "iam-1"; + + /** RS256 私钥(PKCS#8 PEM);为空时启动生成临时密钥并告警 */ + private String privateKey; + + /** RS256 公钥(X.509 PEM);为空时从私钥推导 */ + private String publicKey; + + /** access token 有效期(秒) */ + private long accessExpire = 7200; + + /** refresh token 有效期(秒) */ + private long refreshExpire = 2592000; + + /** 授权码有效期(秒) */ + private long codeExpire = 300; +} diff --git a/iam-api/src/main/java/com/gxwebsoft/iam/controller/IamClientController.java b/iam-api/src/main/java/com/gxwebsoft/iam/controller/IamClientController.java new file mode 100644 index 0000000..accf082 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/iam/controller/IamClientController.java @@ -0,0 +1,86 @@ +package com.gxwebsoft.iam.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.iam.entity.IamClient; +import com.gxwebsoft.iam.param.IamClientPageParam; +import com.gxwebsoft.iam.param.IamClientSaveParam; +import com.gxwebsoft.iam.result.IamClientSecretResult; +import com.gxwebsoft.iam.service.IamClientService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 接入方管理接口。 + * + * @author WebSoft + */ +@Tag(name = "接入方管理") +@RestController +@RequestMapping("/api/iam/client") +public class IamClientController extends BaseController { + + private final IamClientService clientService; + + public IamClientController(IamClientService clientService) { + this.clientService = clientService; + } + + @Operation(summary = "接入方分页") + @GetMapping + @PreAuthorize("hasAuthority('iam:client:list')") + public ApiResult page(IamClientPageParam param) { + Page page = clientService.page(param); + return success(page); + } + + @Operation(summary = "接入方详情") + @GetMapping("/{id}") + @PreAuthorize("hasAuthority('iam:client:list')") + public ApiResult detail(@PathVariable Long id) { + return success(clientService.getRequired(id)); + } + + @Operation(summary = "新建接入方(明文密钥只返回一次)") + @PostMapping + @PreAuthorize("hasAuthority('iam:client:add')") + public ApiResult create(@Valid @RequestBody IamClientSaveParam param) { + return success("创建成功,请立即保存客户端密钥(只显示一次)", clientService.create(param)); + } + + @Operation(summary = "修改接入方") + @PutMapping("/{id}") + @PreAuthorize("hasAuthority('iam:client:edit')") + public ApiResult update(@PathVariable Long id, @Valid @RequestBody IamClientSaveParam param) { + IamClientSaveParam target = new IamClientSaveParam(id, param.clientId(), param.clientName(), + param.audience(), param.scopes(), param.grantTypes(), param.accessTokenSeconds(), + param.refreshTokenSeconds(), param.redirectUris(), param.status()); + return success("修改成功", clientService.update(target)); + } + + @Operation(summary = "重置客户端密钥(旧密钥立即失效)") + @PostMapping("/{id}/secret") + @PreAuthorize("hasAuthority('iam:client:edit')") + public ApiResult resetSecret(@PathVariable Long id) { + return success("密钥已重置,请立即保存新密钥(只显示一次)", clientService.resetSecret(id)); + } + + @Operation(summary = "删除接入方") + @DeleteMapping("/{id}") + @PreAuthorize("hasAuthority('iam:client:remove')") + public ApiResult remove(@PathVariable Long id) { + clientService.remove(id); + return success("删除成功"); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/iam/controller/IamOAuthController.java b/iam-api/src/main/java/com/gxwebsoft/iam/controller/IamOAuthController.java new file mode 100644 index 0000000..ef951fa --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/iam/controller/IamOAuthController.java @@ -0,0 +1,165 @@ +package com.gxwebsoft.iam.controller; + +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.common.core.Constants; +import com.gxwebsoft.common.core.config.ConfigProperties; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.common.system.service.AuthService; +import com.gxwebsoft.iam.config.IamJwtProperties; +import com.gxwebsoft.iam.entity.IamAuthCode; +import com.gxwebsoft.iam.entity.IamClient; +import com.gxwebsoft.iam.param.IamAuthorizeParam; +import com.gxwebsoft.iam.param.OpenIntrospectParam; +import com.gxwebsoft.iam.param.OpenRevokeParam; +import com.gxwebsoft.iam.param.OpenTokenParam; +import com.gxwebsoft.iam.result.IamIntrospectResult; +import com.gxwebsoft.iam.result.IamTokenResult; +import com.gxwebsoft.iam.service.IamAuthCodeService; +import com.gxwebsoft.iam.service.IamClientService; +import com.gxwebsoft.iam.service.IamTokenService; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKMatcher; +import com.nimbusds.jose.jwk.JWKSelector; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 统一令牌端点:授权码、换令牌、公钥集合、自省、吊销。 + * + * @author WebSoft + */ +@Tag(name = "统一令牌") +@RestController +@RequestMapping("/api/v1/oauth") +public class IamOAuthController extends BaseController { + + private static final String GRANT_CLIENT_CREDENTIALS = "client_credentials"; + private static final String GRANT_AUTHORIZATION_CODE = "authorization_code"; + private static final String GRANT_REFRESH_TOKEN = "refresh_token"; + + private final IamClientService clientService; + private final IamTokenService tokenService; + private final IamAuthCodeService authCodeService; + private final AuthService authService; + private final JWKSource jwkSource; + private final ConfigProperties configProperties; + private final IamJwtProperties iamJwtProperties; + + public IamOAuthController(IamClientService clientService, IamTokenService tokenService, + IamAuthCodeService authCodeService, AuthService authService, + JWKSource jwkSource, ConfigProperties configProperties, + IamJwtProperties iamJwtProperties) { + this.clientService = clientService; + this.tokenService = tokenService; + this.authCodeService = authCodeService; + this.authService = authService; + this.jwkSource = jwkSource; + this.configProperties = configProperties; + this.iamJwtProperties = iamJwtProperties; + } + + /** + * 申请授权码:终端用户用账号密码换授权码。 + * + *

与账号密码登录共用图形验证码与失败锁定;返回的授权码一次性、短有效期。

+ */ + @Operation(summary = "申请授权码") + @PostMapping("/authorize") + public ApiResult> authorize(@Valid @RequestBody IamAuthorizeParam param, + HttpServletRequest request) { + IamClient client = clientService.getEnabled(param.clientId()); + if (!clientService.supportsGrantType(client, GRANT_AUTHORIZATION_CODE)) { + throw new BusinessException("该接入方未开通授权码模式"); + } + clientService.checkRedirectUri(client, param.redirectUri()); + User user = authService.authenticateForAuthorize(param.tenantId(), param.username(), param.password(), + param.captchaId(), param.captchaCode(), request); + String scope = clientService.resolveScope(client, param.scope()); + String code = authCodeService.create(user, client.getClientId(), param.redirectUri(), scope, + param.codeChallenge(), param.codeChallengeMethod()); + return ApiResult.ok(Map.of("code", code, "scope", scope, + "expiresIn", iamJwtProperties.getCodeExpire())); + } + + /** 换取令牌:client_credentials / authorization_code / refresh_token */ + @Operation(summary = "换取令牌") + @PostMapping("/token") + public ApiResult token(@Valid @RequestBody OpenTokenParam param) { + IamClient client = clientService.authenticate(param.clientId(), param.clientSecret()); + String grantType = param.grantType().trim(); + if (!clientService.supportsGrantType(client, grantType)) { + throw new BusinessException("该接入方未开通此授权类型"); + } + return ApiResult.ok(switch (grantType) { + case GRANT_CLIENT_CREDENTIALS -> tokenService.issueClientCredentials(client, + clientService.resolveScope(client, param.scope())); + case GRANT_AUTHORIZATION_CODE -> exchangeCode(client, param); + case GRANT_REFRESH_TOKEN -> tokenService.refresh(client, param.refreshToken()); + default -> throw new BusinessException("不支持的 grantType"); + }); + } + + @Operation(summary = "公钥集合(JWKS)") + @GetMapping("/jwks") + public Map jwks() { + try { + var keys = jwkSource.get(new JWKSelector(new JWKMatcher.Builder().build()), null); + return new JWKSet((java.util.List) keys).toJSONObject(true); + } catch (Exception e) { + throw new BusinessException("公钥集合获取失败"); + } + } + + /** + * 令牌自省:调用方是资源服务,用 {@code X-Internal-Key} 自证,不走用户令牌。 + */ + @Operation(summary = "令牌自省") + @PostMapping("/introspect") + public ApiResult introspect(@Valid @RequestBody OpenIntrospectParam param, + @RequestHeader(value = Constants.INTERNAL_KEY_HEADER, required = false) + String internalKey) { + checkInternalKey(internalKey); + return ApiResult.ok(tokenService.introspect(param.token())); + } + + /** 吊销令牌:内部服务自证后按 token 归属吊销 */ + @Operation(summary = "吊销令牌") + @PostMapping("/revoke") + public ApiResult revoke(@Valid @RequestBody OpenRevokeParam param, + @RequestHeader(value = Constants.INTERNAL_KEY_HEADER, required = false) + String internalKey) { + checkInternalKey(internalKey); + tokenService.revoke(param.token()); + return ApiResult.ok("令牌已吊销"); + } + + private IamTokenResult exchangeCode(IamClient client, OpenTokenParam param) { + IamAuthCode authCode = authCodeService.consume(param.code(), client.getClientId(), + param.redirectUri(), param.codeVerifier()); + return tokenService.issueForUser(client, authCodeService.toUser(authCode), authCode.getScopes()); + } + + /** 内网自证密钥:fail closed,未配置或为空一律拒绝 */ + private void checkInternalKey(String provided) { + String expected = configProperties.getInternalKey(); + if (StrUtil.isBlank(expected) || expected.contains("${") || !expected.equals(provided)) { + throw new BusinessException(Constants.UNAUTHORIZED_CODE, "内部密钥校验失败"); + } + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/iam/entity/IamAuthCode.java b/iam-api/src/main/java/com/gxwebsoft/iam/entity/IamAuthCode.java new file mode 100644 index 0000000..c172c04 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/iam/entity/IamAuthCode.java @@ -0,0 +1,66 @@ +package com.gxwebsoft.iam.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * OAuth2 授权码:一次性、短有效期。 + * + * @author WebSoft + */ +@Getter +@Setter +@TableName("iam_auth_code") +@Schema(description = "授权码") +public class IamAuthCode implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + @Schema(description = "授权码") + private String code; + + @Schema(description = "客户端标识") + private String clientId; + + @Schema(description = "终端用户") + private Long userId; + + private Long tenantId; + + private String username; + + @Schema(description = "回调地址") + private String redirectUri; + + @Schema(description = "授权的 scope,逗号分隔") + private String scopes; + + @Schema(description = "PKCE code_challenge") + private String codeChallenge; + + @Schema(description = "PKCE 方法:S256 / plain") + private String codeChallengeMethod; + + @Schema(description = "是否已核销:0未用 1已用") + private Integer used; + + @Schema(description = "过期时间") + private LocalDateTime expireTime; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; +} diff --git a/iam-api/src/main/java/com/gxwebsoft/iam/entity/IamClient.java b/iam-api/src/main/java/com/gxwebsoft/iam/entity/IamClient.java new file mode 100644 index 0000000..051d481 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/iam/entity/IamClient.java @@ -0,0 +1,78 @@ +package com.gxwebsoft.iam.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 接入方(令牌消费方):open-api / dp-api / 第三方应用各占一条。 + * + *

平台级元数据,不做租户隔离。

+ * + * @author WebSoft + */ +@Getter +@Setter +@TableName("iam_client") +@Schema(description = "接入方") +public class IamClient implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + @Schema(description = "客户端标识") + private String clientId; + + /** 客户端密钥(BCrypt 存储),只进不出 */ + @JsonIgnore + @Schema(description = "客户端密钥", hidden = true) + private String clientSecret; + + @Schema(description = "名称") + private String clientName; + + @Schema(description = "允许的 audience(资源服务名),如 dp-api") + private String audience; + + @Schema(description = "允许的 scope,逗号分隔") + private String scopes; + + @Schema(description = "允许的授权类型,逗号分隔") + private String grantTypes; + + @Schema(description = "访问令牌有效期(秒),空表示用全局默认") + private Long accessTokenSeconds; + + @Schema(description = "刷新令牌有效期(秒),空表示用全局默认") + private Long refreshTokenSeconds; + + @Schema(description = "回调地址白名单,逗号分隔;为空表示不限制") + private String redirectUris; + + @Schema(description = "状态:0停用 1启用") + private Integer status; + + @TableLogic + @Schema(description = "逻辑删除", hidden = true) + private Integer delFlag; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/iam-api/src/main/java/com/gxwebsoft/iam/mapper/IamAuthCodeMapper.java b/iam-api/src/main/java/com/gxwebsoft/iam/mapper/IamAuthCodeMapper.java new file mode 100644 index 0000000..60726f7 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/iam/mapper/IamAuthCodeMapper.java @@ -0,0 +1,12 @@ +package com.gxwebsoft.iam.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.iam.entity.IamAuthCode; + +/** + * 授权码 Mapper。 + * + * @author WebSoft + */ +public interface IamAuthCodeMapper extends BaseMapper { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/iam/mapper/IamClientMapper.java b/iam-api/src/main/java/com/gxwebsoft/iam/mapper/IamClientMapper.java new file mode 100644 index 0000000..cfc53c4 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/iam/mapper/IamClientMapper.java @@ -0,0 +1,12 @@ +package com.gxwebsoft.iam.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.iam.entity.IamClient; + +/** + * 接入方 Mapper。 + * + * @author WebSoft + */ +public interface IamClientMapper extends BaseMapper { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/iam/param/IamAuthorizeParam.java b/iam-api/src/main/java/com/gxwebsoft/iam/param/IamAuthorizeParam.java new file mode 100644 index 0000000..07a6801 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/iam/param/IamAuthorizeParam.java @@ -0,0 +1,37 @@ +package com.gxwebsoft.iam.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * 授权码申请参数(终端用户身份 + 客户端信息)。 + * + * @author WebSoft + */ +@Schema(description = "授权码申请") +public record IamAuthorizeParam( + @NotNull(message = "租户ID不能为空") + @Schema(description = "租户ID") Long tenantId, + + @NotBlank(message = "账号不能为空") + @Schema(description = "账号") String username, + + @NotBlank(message = "密码不能为空") + @Schema(description = "密码") String password, + + @Schema(description = "图形验证码标识") String captchaId, + + @Schema(description = "图形验证码") String captchaCode, + + @NotBlank(message = "client_id 不能为空") + @Schema(description = "客户端标识") String clientId, + + @Schema(description = "回调地址") String redirectUri, + + @Schema(description = "申请的 scope") String scope, + + @Schema(description = "PKCE code_challenge") String codeChallenge, + + @Schema(description = "PKCE 方法:S256 / plain") String codeChallengeMethod) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/iam/param/IamClientPageParam.java b/iam-api/src/main/java/com/gxwebsoft/iam/param/IamClientPageParam.java new file mode 100644 index 0000000..7aec1eb --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/iam/param/IamClientPageParam.java @@ -0,0 +1,30 @@ +package com.gxwebsoft.iam.param; + +import com.gxwebsoft.common.core.annotation.QueryField; +import com.gxwebsoft.common.core.annotation.QueryType; +import com.gxwebsoft.common.core.web.BaseParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +/** + * 接入方查询参数。 + * + * @author WebSoft + */ +@Getter +@Setter +public class IamClientPageParam extends BaseParam { + + @QueryField(type = QueryType.LIKE, keywords = true) + @Schema(description = "客户端标识") + private String clientId; + + @QueryField(type = QueryType.LIKE, keywords = true) + @Schema(description = "名称") + private String clientName; + + @QueryField + @Schema(description = "状态:0停用 1启用") + private Integer status; +} diff --git a/iam-api/src/main/java/com/gxwebsoft/iam/param/IamClientSaveParam.java b/iam-api/src/main/java/com/gxwebsoft/iam/param/IamClientSaveParam.java new file mode 100644 index 0000000..2737e58 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/iam/param/IamClientSaveParam.java @@ -0,0 +1,34 @@ +package com.gxwebsoft.iam.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +/** + * 接入方保存参数。 + * + * @author WebSoft + */ +@Schema(description = "接入方保存") +public record IamClientSaveParam( + @Schema(description = "接入方ID,新建时为空") Long id, + + @NotBlank(message = "客户端标识不能为空") + @Schema(description = "客户端标识,如 dp-api") String clientId, + + @NotBlank(message = "名称不能为空") + @Schema(description = "名称") String clientName, + + @Schema(description = "允许的 audience,如 dp-api") String audience, + + @Schema(description = "允许的 scope,逗号分隔") String scopes, + + @Schema(description = "允许的授权类型,逗号分隔") String grantTypes, + + @Schema(description = "access token 有效期(秒)") Long accessTokenSeconds, + + @Schema(description = "refresh token 有效期(秒)") Long refreshTokenSeconds, + + @Schema(description = "回调地址白名单,逗号分隔") String redirectUris, + + @Schema(description = "状态:0停用 1启用") Integer status) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/iam/param/OpenIntrospectParam.java b/iam-api/src/main/java/com/gxwebsoft/iam/param/OpenIntrospectParam.java new file mode 100644 index 0000000..7b7207e --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/iam/param/OpenIntrospectParam.java @@ -0,0 +1,15 @@ +package com.gxwebsoft.iam.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +/** + * 令牌自省参数。 + * + * @author WebSoft + */ +@Schema(description = "令牌自省") +public record OpenIntrospectParam( + @NotBlank(message = "token 不能为空") + @Schema(description = "待查询的令牌") String token) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/iam/param/OpenRevokeParam.java b/iam-api/src/main/java/com/gxwebsoft/iam/param/OpenRevokeParam.java new file mode 100644 index 0000000..759b2f3 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/iam/param/OpenRevokeParam.java @@ -0,0 +1,15 @@ +package com.gxwebsoft.iam.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +/** + * 令牌吊销参数。 + * + * @author WebSoft + */ +@Schema(description = "令牌吊销") +public record OpenRevokeParam( + @NotBlank(message = "token 不能为空") + @Schema(description = "待吊销的 access token 或 refresh token") String token) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/iam/param/OpenTokenParam.java b/iam-api/src/main/java/com/gxwebsoft/iam/param/OpenTokenParam.java new file mode 100644 index 0000000..0483ef6 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/iam/param/OpenTokenParam.java @@ -0,0 +1,31 @@ +package com.gxwebsoft.iam.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +/** + * 换取令牌参数。 + * + * @author WebSoft + */ +@Schema(description = "换取令牌") +public record OpenTokenParam( + @NotBlank(message = "grantType 不能为空") + @Schema(description = "client_credentials / authorization_code / refresh_token") String grantType, + + @NotBlank(message = "clientId 不能为空") + @Schema(description = "客户端标识") String clientId, + + @NotBlank(message = "clientSecret 不能为空") + @Schema(description = "客户端密钥") String clientSecret, + + @Schema(description = "申请的 scope,空格或逗号分隔") String scope, + + @Schema(description = "授权码,authorization_code 模式必填") String code, + + @Schema(description = "回调地址,与申请授权码时一致") String redirectUri, + + @Schema(description = "PKCE code_verifier") String codeVerifier, + + @Schema(description = "刷新令牌,refresh_token 模式必填") String refreshToken) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/iam/result/IamClientSecretResult.java b/iam-api/src/main/java/com/gxwebsoft/iam/result/IamClientSecretResult.java new file mode 100644 index 0000000..df7cc05 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/iam/result/IamClientSecretResult.java @@ -0,0 +1,12 @@ +package com.gxwebsoft.iam.result; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 客户端密钥:明文只在创建 / 重置时返回一次。 + * + * @author WebSoft + */ +@Schema(description = "客户端密钥") +public record IamClientSecretResult(String clientId, String clientSecret) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/iam/result/IamIntrospectResult.java b/iam-api/src/main/java/com/gxwebsoft/iam/result/IamIntrospectResult.java new file mode 100644 index 0000000..9a30415 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/iam/result/IamIntrospectResult.java @@ -0,0 +1,29 @@ +package com.gxwebsoft.iam.result; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 令牌自省结果。 + * + * @param active 令牌是否有效 + * @param clientId 所属接入方 + * @param tenantId 租户 + * @param userId 用户(client_credentials 为空) + * @param username 账号(client_credentials 为空) + * @param scope 授权范围 + * @param tokenType access / refresh + * @param expiresAt 过期时间(epoch 秒) + * @param issuedAt 签发时间(epoch 秒) + * @param jwtId 令牌标识 + * @author WebSoft + */ +@Schema(description = "令牌自省结果") +public record IamIntrospectResult(boolean active, String clientId, Long tenantId, Long userId, + String username, String scope, String tokenType, + Long expiresAt, Long issuedAt, String jwtId) { + + /** 无效令牌:只回 {@code active=false},不泄露任何归属信息 */ + public static IamIntrospectResult inactive() { + return new IamIntrospectResult(false, null, null, null, null, null, null, null, null, null); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/iam/result/IamTokenResult.java b/iam-api/src/main/java/com/gxwebsoft/iam/result/IamTokenResult.java new file mode 100644 index 0000000..9cc7ae7 --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/iam/result/IamTokenResult.java @@ -0,0 +1,18 @@ +package com.gxwebsoft.iam.result; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 令牌签发结果。 + * + * @param accessToken access token(JWT,RS256) + * @param refreshToken refresh token,仅授权码模式返回 + * @param tokenType 固定 Bearer + * @param expiresIn access token 有效期(秒) + * @param scope 实际授予的 scope + * @author WebSoft + */ +@Schema(description = "令牌签发结果") +public record IamTokenResult(String accessToken, String refreshToken, String tokenType, + Long expiresIn, String scope) { +} diff --git a/iam-api/src/main/java/com/gxwebsoft/iam/service/IamAuthCodeService.java b/iam-api/src/main/java/com/gxwebsoft/iam/service/IamAuthCodeService.java new file mode 100644 index 0000000..da232af --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/iam/service/IamAuthCodeService.java @@ -0,0 +1,139 @@ +package com.gxwebsoft.iam.service; + +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.iam.config.IamJwtProperties; +import com.gxwebsoft.iam.entity.IamAuthCode; +import com.gxwebsoft.iam.mapper.IamAuthCodeMapper; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.List; + +/** + * 授权码服务:签发与一次性核销,含 PKCE 校验。 + * + * @author WebSoft + */ +@Service +public class IamAuthCodeService { + + private static final String METHOD_S256 = "S256"; + + private static final int USED = 1; + + private final IamAuthCodeMapper authCodeMapper; + private final IamJwtProperties properties; + + public IamAuthCodeService(IamAuthCodeMapper authCodeMapper, IamJwtProperties properties) { + this.authCodeMapper = authCodeMapper; + this.properties = properties; + } + + /** 签发授权码 */ + public String create(User user, String clientId, String redirectUri, String scope, + String codeChallenge, String codeChallengeMethod) { + IamAuthCode authCode = new IamAuthCode(); + authCode.setCode(IdUtil.fastSimpleUUID()); + authCode.setClientId(clientId); + authCode.setUserId(user.getUserId()); + authCode.setTenantId(user.getTenantId()); + authCode.setUsername(user.getUsername()); + authCode.setRedirectUri(redirectUri); + authCode.setScopes(scope); + authCode.setCodeChallenge(codeChallenge); + authCode.setCodeChallengeMethod(codeChallengeMethod); + authCode.setUsed(0); + authCode.setExpireTime(LocalDateTime.now().plusSeconds(properties.getCodeExpire())); + authCodeMapper.insert(authCode); + return authCode.getCode(); + } + + /** + * 核销授权码并返回其内容。 + * + *

校验顺序:存在性 → 未过期 → 未使用 → 归属客户端 → 回调地址一致 → PKCE。 + * 通过后立即标记为已使用,授权码只能换一次令牌。

+ */ + public IamAuthCode consume(String code, String clientId, String redirectUri, String codeVerifier) { + IamAuthCode authCode = authCodeMapper.selectOne(Wrappers.lambdaQuery() + .eq(IamAuthCode::getCode, code).last("limit 1")); + if (authCode == null) { + throw new BusinessException("授权码无效"); + } + if (authCode.getExpireTime() == null || authCode.getExpireTime().isBefore(LocalDateTime.now())) { + throw new BusinessException("授权码已过期"); + } + if (authCode.getUsed() != null && authCode.getUsed() == USED) { + throw new BusinessException("授权码已被使用"); + } + if (!StrUtil.equals(clientId, authCode.getClientId())) { + throw new BusinessException("授权码不属于该客户端"); + } + if (StrUtil.isNotBlank(authCode.getRedirectUri()) && !StrUtil.equals(redirectUri, authCode.getRedirectUri())) { + throw new BusinessException("回调地址与申请授权码时不一致"); + } + verifyPkce(authCode, codeVerifier); + + IamAuthCode update = new IamAuthCode(); + update.setId(authCode.getId()); + update.setUsed(USED); + authCodeMapper.updateById(update); + return authCode; + } + + /** 按用户重建登录主体,供签发令牌使用 */ + public User toUser(IamAuthCode authCode) { + User user = new User(); + user.setUserId(authCode.getUserId()); + user.setTenantId(authCode.getTenantId()); + user.setUsername(authCode.getUsername()); + return user; + } + + /** + * PKCE 校验。 + * + *

仅当发码时带了 {@code code_challenge} 才校验;S256 用 + * {@code BASE64URL(SHA256(verifier))} 比对,plain 则直接字符串比对。

+ */ + private void verifyPkce(IamAuthCode authCode, String codeVerifier) { + String challenge = authCode.getCodeChallenge(); + if (StrUtil.isBlank(challenge)) { + return; + } + if (StrUtil.isBlank(codeVerifier)) { + throw new BusinessException("缺少 code_verifier"); + } + String method = StrUtil.blankToDefault(authCode.getCodeChallengeMethod(), "plain"); + String actual = METHOD_S256.equalsIgnoreCase(method) ? sha256Base64Url(codeVerifier) : codeVerifier; + if (!MessageDigest.isEqual(challenge.getBytes(StandardCharsets.UTF_8), + actual.getBytes(StandardCharsets.UTF_8))) { + throw new BusinessException("code_verifier 校验失败"); + } + } + + private String sha256Base64Url(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (Exception e) { + throw new BusinessException("PKCE 计算失败"); + } + } + + /** 清理某个用户的过期授权码(定时任务可调用) */ + public int removeExpired() { + List expired = authCodeMapper.selectList(Wrappers.lambdaQuery() + .lt(IamAuthCode::getExpireTime, LocalDateTime.now())); + expired.forEach(item -> authCodeMapper.deleteById(item.getId())); + return expired.size(); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/iam/service/IamClientService.java b/iam-api/src/main/java/com/gxwebsoft/iam/service/IamClientService.java new file mode 100644 index 0000000..3e240db --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/iam/service/IamClientService.java @@ -0,0 +1,198 @@ +package com.gxwebsoft.iam.service; + +import cn.hutool.core.util.RandomUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.core.web.PageParam; +import com.gxwebsoft.iam.entity.IamClient; +import com.gxwebsoft.iam.mapper.IamClientMapper; +import com.gxwebsoft.iam.param.IamClientPageParam; +import com.gxwebsoft.iam.param.IamClientSaveParam; +import com.gxwebsoft.iam.result.IamClientSecretResult; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 接入方服务。 + * + *

client_secret 只以 BCrypt 存储,明文仅在创建 / 重置时返回一次。

+ * + * @author WebSoft + */ +@Service +public class IamClientService { + + private static final int STATUS_ENABLED = 1; + + private final IamClientMapper clientMapper; + private final PasswordEncoder passwordEncoder; + + public IamClientService(IamClientMapper clientMapper, PasswordEncoder passwordEncoder) { + this.clientMapper = clientMapper; + this.passwordEncoder = passwordEncoder; + } + + public IamClient getRequired(Long id) { + IamClient client = clientMapper.selectById(id); + if (client == null) { + throw new BusinessException("接入方不存在"); + } + return client; + } + + /** 按 clientId 取启用的接入方 */ + public IamClient getEnabled(String clientId) { + IamClient client = clientMapper.selectOne(Wrappers.lambdaQuery() + .eq(IamClient::getClientId, clientId).last("limit 1")); + if (client == null || client.getStatus() == null || client.getStatus() != STATUS_ENABLED) { + throw new BusinessException("客户端不存在或已停用"); + } + return client; + } + + /** + * 校验客户端身份。 + * + *

校验失败统一返回同一条提示,避免通过错误差异枚举有效的 client_id。

+ */ + public IamClient authenticate(String clientId, String clientSecret) { + if (StrUtil.isBlank(clientId) || StrUtil.isBlank(clientSecret)) { + throw new BusinessException("客户端认证失败"); + } + IamClient client = clientMapper.selectOne(Wrappers.lambdaQuery() + .eq(IamClient::getClientId, clientId).last("limit 1")); + if (client == null || client.getStatus() == null || client.getStatus() != STATUS_ENABLED + || !passwordEncoder.matches(clientSecret, client.getClientSecret())) { + throw new BusinessException("客户端认证失败"); + } + return client; + } + + public Page page(IamClientPageParam param) { + PageParam page = new PageParam<>(IamClient.class, param); + page.setDefaultOrder("id desc"); + return clientMapper.selectPage(page, page.getWrapper()); + } + + /** 新建接入方:生成并返回一次明文密钥 */ + public IamClientSecretResult create(IamClientSaveParam param) { + checkClientIdUnique(param.clientId(), null); + String plainSecret = randomSecret(); + IamClient client = new IamClient(); + client.setClientId(param.clientId()); + client.setClientSecret(passwordEncoder.encode(plainSecret)); + apply(client, param); + clientMapper.insert(client); + return new IamClientSecretResult(client.getClientId(), plainSecret); + } + + /** 修改接入方(不改密钥) */ + public Long update(IamClientSaveParam param) { + IamClient existing = getRequired(param.id()); + checkClientIdUnique(param.clientId(), param.id()); + existing.setClientId(param.clientId()); + apply(existing, param); + clientMapper.updateById(existing); + return existing.getId(); + } + + /** 重置密钥:旧密钥立即失效 */ + public IamClientSecretResult resetSecret(Long id) { + IamClient client = getRequired(id); + String plainSecret = randomSecret(); + client.setClientSecret(passwordEncoder.encode(plainSecret)); + clientMapper.updateById(client); + return new IamClientSecretResult(client.getClientId(), plainSecret); + } + + public void remove(Long id) { + getRequired(id); + clientMapper.deleteById(id); + } + + /** + * 收敛申请的 scope:只保留接入方允许范围内的部分。 + * + *

空申请表示「取全部允许的 scope」;申请了范围外的 scope 会被丢弃而不是报错, + * 与 OAuth2 的 down-scope 语义一致。

+ */ + public String resolveScope(IamClient client, String requested) { + List allowed = split(client.getScopes()); + if (allowed.isEmpty()) { + return ""; + } + if (StrUtil.isBlank(requested)) { + return String.join(",", allowed); + } + Set result = new LinkedHashSet<>(); + for (String item : split(requested)) { + if (allowed.contains(item)) { + result.add(item); + } + } + return String.join(",", result); + } + + /** 判断接入方是否支持某授权类型 */ + public boolean supportsGrantType(IamClient client, String grantType) { + return split(client.getGrantTypes()).contains(grantType); + } + + /** + * 回调地址白名单校验。 + * + *

未配置白名单表示不限制(仅适合调试台);配置了就必须命中,避免授权码被 + * 带到攻击者控制的地址。

+ */ + public void checkRedirectUri(IamClient client, String redirectUri) { + List allowList = split(client.getRedirectUris()); + if (allowList.isEmpty()) { + return; + } + if (StrUtil.isBlank(redirectUri) || !allowList.contains(redirectUri.trim())) { + throw new BusinessException("回调地址不在白名单内"); + } + } + + private void apply(IamClient client, IamClientSaveParam param) { + client.setClientName(param.clientName()); + client.setAudience(param.audience()); + client.setScopes(param.scopes()); + client.setGrantTypes(param.grantTypes()); + client.setAccessTokenSeconds(param.accessTokenSeconds()); + client.setRefreshTokenSeconds(param.refreshTokenSeconds()); + client.setRedirectUris(param.redirectUris()); + client.setStatus(param.status() == null ? STATUS_ENABLED : param.status()); + } + + private void checkClientIdUnique(String clientId, Long excludeId) { + Long count = clientMapper.selectCount(Wrappers.lambdaQuery() + .eq(IamClient::getClientId, clientId.trim()) + .ne(excludeId != null, IamClient::getId, excludeId)); + if (count != null && count > 0) { + throw new BusinessException("客户端标识已存在"); + } + } + + private static String randomSecret() { + return RandomUtil.randomString("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 40); + } + + private static List split(String value) { + if (StrUtil.isBlank(value)) { + return List.of(); + } + return Arrays.stream(value.split("[,\\s]+")) + .map(String::trim) + .filter(StrUtil::isNotBlank) + .distinct() + .toList(); + } +} diff --git a/iam-api/src/main/java/com/gxwebsoft/iam/service/IamTokenService.java b/iam-api/src/main/java/com/gxwebsoft/iam/service/IamTokenService.java new file mode 100644 index 0000000..06e725f --- /dev/null +++ b/iam-api/src/main/java/com/gxwebsoft/iam/service/IamTokenService.java @@ -0,0 +1,214 @@ +package com.gxwebsoft.iam.service; + +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.common.core.Constants; +import com.gxwebsoft.common.core.context.TenantContext; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.core.security.RevokedTokenStore; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.common.system.service.RoleService; +import com.gxwebsoft.iam.config.IamJwtProperties; +import com.gxwebsoft.iam.entity.IamClient; +import com.gxwebsoft.iam.result.IamIntrospectResult; +import com.gxwebsoft.iam.result.IamTokenResult; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; + +/** + * 统一令牌签发 / 自省 / 吊销。 + * + *

access token 用于资源服务鉴权,refresh token 只能用于续期 + * (自省时 {@code token_type != access},资源服务据此拒绝)。 + * 刷新即轮换:旧的 refresh token 会被写入吊销名单。

+ * + * @author WebSoft + */ +@Service +public class IamTokenService { + + /** 令牌用途:访问 */ + public static final String TOKEN_TYPE_ACCESS = "access"; + + /** 令牌用途:刷新 */ + public static final String TOKEN_TYPE_REFRESH = "refresh"; + + private static final String CLAIM_TOKEN_TYPE = "token_type"; + private static final String CLAIM_TENANT_ID = "tenant_id"; + private static final String CLAIM_USER_ID = "user_id"; + private static final String CLAIM_USERNAME = "username"; + private static final String CLAIM_SCOPE = "scope"; + private static final String CLAIM_CLIENT_ID = "client_id"; + private static final String CLAIM_ROLES = "roles"; + + private final JwtEncoder jwtEncoder; + private final JwtDecoder jwtDecoder; + private final IamJwtProperties properties; + private final RevokedTokenStore revokedTokenStore; + private final RoleService roleService; + + public IamTokenService(JwtEncoder jwtEncoder, JwtDecoder jwtDecoder, IamJwtProperties properties, + RevokedTokenStore revokedTokenStore, RoleService roleService) { + this.jwtEncoder = jwtEncoder; + this.jwtDecoder = jwtDecoder; + this.properties = properties; + this.revokedTokenStore = revokedTokenStore; + this.roleService = roleService; + } + + /** 客户端凭证模式:没有终端用户,只有接入方身份 */ + public IamTokenResult issueClientCredentials(IamClient client, String scope) { + long accessSeconds = accessSecondsOf(client); + String accessToken = buildToken(client, null, scope, TOKEN_TYPE_ACCESS, accessSeconds); + return new IamTokenResult(accessToken, null, Constants.TOKEN_TYPE, accessSeconds, scope); + } + + /** 授权码模式:签发 access + refresh */ + public IamTokenResult issueForUser(IamClient client, User user, String scope) { + long accessSeconds = accessSecondsOf(client); + long refreshSeconds = refreshSecondsOf(client); + String accessToken = buildToken(client, user, scope, TOKEN_TYPE_ACCESS, accessSeconds); + String refreshToken = buildToken(client, user, scope, TOKEN_TYPE_REFRESH, refreshSeconds); + return new IamTokenResult(accessToken, refreshToken, Constants.TOKEN_TYPE, accessSeconds, scope); + } + + /** + * 刷新令牌:校验旧 refresh token 后签发新 access + 新 refresh,并吊销旧的。 + * + *

刷新即轮换:接入方必须用新返回的 refreshToken 覆盖本地存储,否则下次刷新会失败。

+ */ + public IamTokenResult refresh(IamClient client, String refreshToken) { + Jwt jwt = decode(refreshToken); + if (jwt == null) { + throw new BusinessException("令牌已失效"); + } + if (!TOKEN_TYPE_REFRESH.equals(jwt.getClaimAsString(CLAIM_TOKEN_TYPE))) { + throw new BusinessException("令牌类型不正确,需要 refresh token"); + } + if (!StrUtil.equals(client.getClientId(), jwt.getClaimAsString(CLAIM_CLIENT_ID))) { + throw new BusinessException("令牌不属于该客户端"); + } + if (revokedTokenStore.isRevoked(jwt.getId())) { + throw new BusinessException("令牌已失效"); + } + Long userId = jwt.getClaim(CLAIM_USER_ID); + Long tenantId = jwt.getClaim(CLAIM_TENANT_ID); + if (userId == null || tenantId == null) { + throw new BusinessException("令牌缺少用户信息"); + } + revokedTokenStore.revoke(jwt.getId(), remainingSeconds(jwt)); + + User user = TenantContext.callWithTenant(tenantId, () -> { + User loaded = new User(); + loaded.setUserId(userId); + loaded.setTenantId(tenantId); + loaded.setUsername(jwt.getClaimAsString(CLAIM_USERNAME)); + return loaded; + }); + return issueForUser(client, user, jwt.getClaimAsString(CLAIM_SCOPE)); + } + + /** + * 令牌自省:任何异常都归为「无效」,只回 {@code active=false},不泄露失败原因。 + */ + public IamIntrospectResult introspect(String token) { + Jwt jwt; + try { + jwt = decode(token); + } catch (Exception e) { + return IamIntrospectResult.inactive(); + } + if (jwt == null || revokedTokenStore.isRevoked(jwt.getId())) { + return IamIntrospectResult.inactive(); + } + return new IamIntrospectResult(true, + jwt.getClaimAsString(CLAIM_CLIENT_ID), + jwt.getClaim(CLAIM_TENANT_ID), + jwt.getClaim(CLAIM_USER_ID), + jwt.getClaimAsString(CLAIM_USERNAME), + jwt.getClaimAsString(CLAIM_SCOPE), + jwt.getClaimAsString(CLAIM_TOKEN_TYPE), + jwt.getExpiresAt() == null ? null : jwt.getExpiresAt().getEpochSecond(), + jwt.getIssuedAt() == null ? null : jwt.getIssuedAt().getEpochSecond(), + jwt.getId()); + } + + /** 吊销令牌:签名无效 / 已过期的令牌直接按成功处理(幂等) */ + public void revoke(String token) { + try { + Jwt jwt = decode(token); + if (jwt != null) { + revokedTokenStore.revoke(jwt.getId(), remainingSeconds(jwt)); + } + } catch (Exception e) { + // 幂等:令牌本身已无效,无需报错 + } + } + + /** 校验并解析令牌;无效返回 null(不抛出 jjwt / Nimbus 的内部异常) */ + private Jwt decode(String token) { + if (StrUtil.isBlank(token)) { + return null; + } + try { + return jwtDecoder.decode(token); + } catch (Exception e) { + return null; + } + } + + private String buildToken(IamClient client, User user, String scope, String tokenType, long seconds) { + Instant now = Instant.now(); + JwtClaimsSet.Builder claims = JwtClaimsSet.builder() + .issuer(properties.getIssuer()) + .audience(List.of(audienceOf(client))) + .issuedAt(now) + .expiresAt(now.plusSeconds(seconds)) + .id(IdUtil.fastSimpleUUID()) + .claim(CLAIM_TOKEN_TYPE, tokenType) + .claim(CLAIM_CLIENT_ID, client.getClientId()) + .claim(CLAIM_SCOPE, scope == null ? "" : scope); + if (user == null) { + claims.subject(client.getClientId()); + } else { + claims.subject(String.valueOf(user.getUserId())) + .claim(CLAIM_USER_ID, user.getUserId()) + .claim(CLAIM_TENANT_ID, user.getTenantId()) + .claim(CLAIM_USERNAME, user.getUsername()) + .claim(CLAIM_ROLES, TenantContext.callWithTenant(user.getTenantId(), + () -> roleService.roleCodesByUserId(user.getUserId()))); + } + JwsHeader header = JwsHeader.with(SignatureAlgorithm.RS256).build(); + return jwtEncoder.encode(JwtEncoderParameters.from(header, claims.build())).getTokenValue(); + } + + private String audienceOf(IamClient client) { + return StrUtil.isBlank(client.getAudience()) ? properties.getAudience() : client.getAudience(); + } + + private long accessSecondsOf(IamClient client) { + return client.getAccessTokenSeconds() != null && client.getAccessTokenSeconds() > 0 + ? client.getAccessTokenSeconds() : properties.getAccessExpire(); + } + + private long refreshSecondsOf(IamClient client) { + return client.getRefreshTokenSeconds() != null && client.getRefreshTokenSeconds() > 0 + ? client.getRefreshTokenSeconds() : properties.getRefreshExpire(); + } + + private long remainingSeconds(Jwt jwt) { + if (jwt.getExpiresAt() == null) { + return 0; + } + return Math.max(0, jwt.getExpiresAt().getEpochSecond() - Instant.now().getEpochSecond()); + } +} diff --git a/iam-api/src/main/resources/application-demo.yml b/iam-api/src/main/resources/application-demo.yml new file mode 100644 index 0000000..c06a315 --- /dev/null +++ b/iam-api/src/main/resources/application-demo.yml @@ -0,0 +1,30 @@ +# 演示/本地免依赖启动:H2 内存库 + 进程内缓存,不连 MySQL / Redis。 +# ./mvnw -Pdemo spring-boot:run -Dspring-boot.run.profiles=demo +spring: + datasource: + url: jdbc:h2:mem:iam;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE + username: sa + password: + driver-class-name: org.h2.Driver + druid: + validation-query: SELECT 1 + flyway: + enabled: true + # H2 下不需要 Druid 的 SQL 防火墙与监控页 + data: + redis: + host: 127.0.0.1 + port: 6379 + +iam: + cache: + type: memory + +management: + health: + redis: + enabled: false + +logging: + level: + com.gxwebsoft: info diff --git a/iam-api/src/main/resources/application-dev.yml b/iam-api/src/main/resources/application-dev.yml new file mode 100644 index 0000000..59ac6da --- /dev/null +++ b/iam-api/src/main/resources/application-dev.yml @@ -0,0 +1,37 @@ +# 开发环境:默认连本地 MySQL / Redis,按需用环境变量覆盖 +spring: + datasource: + url: jdbc:mysql://${DB_HOST:47.119.165.234}:${DB_PORT:13308}/${DB_NAME:db_ima}?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8&allowPublicKeyRetrieval=true&connectTimeout=8000&socketTimeout=60000 + username: ${DB_USERNAME:db_ima} + password: ${DB_PASSWORD:exeAbyjjp7WFHa7W} + driver-class-name: com.mysql.cj.jdbc.Driver + druid: + initial-size: 2 + min-idle: 2 + max-active: 20 + max-wait: 30000 + validation-query: SELECT 1 + test-while-idle: true + filters: stat,wall + stat-view-servlet: + enabled: true + url-pattern: /druid/* + login-username: ${DRUID_USERNAME:admin} + login-password: ${DRUID_PASSWORD:admin} + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + database: ${REDIS_DATABASE:0} + +# 开发环境 Redis 可能还没起,默认不纳入健康检查,接入后打开 +management: + health: + redis: + enabled: ${HEALTH_REDIS_ENABLED:false} + +logging: + level: + com.gxwebsoft: debug + com.baomidou.mybatisplus: debug diff --git a/iam-api/src/main/resources/application-prod.yml b/iam-api/src/main/resources/application-prod.yml new file mode 100644 index 0000000..8cfe313 --- /dev/null +++ b/iam-api/src/main/resources/application-prod.yml @@ -0,0 +1,82 @@ +# 生产环境关闭 Boot 的 debug 模式(条件评估报告、框架 DEBUG 日志都不会输出)。 +debug: false + +spring: + datasource: + # 1Panel 部署:iam-api 容器与 MySQL 容器在同一网络时,直接用容器名即可 + url: jdbc:mysql://${DB_HOST}:${DB_PORT:3306}/${DB_NAME}?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8&allowPublicKeyRetrieval=true&connectTimeout=8000&socketTimeout=60000&rewriteBatchedStatements=true + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + druid: + initial-size: 5 + min-idle: 5 + max-active: 50 + max-wait: 30000 + validation-query: SELECT 1 + test-while-idle: true + # 仅保留统计:wall(SQL 防火墙)会拦截部分语句,且 SQL 报错时 Druid 会作废该连接 + filters: stat + stat-view-servlet: + enabled: false + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + database: 0 + # Flyway 迁移专用连接账号(本项目采用方案 B:迁移账号与业务账号分离)。 + # + # 背景:Flyway 的 MySQL 支持在建立连接时固定要查 + # performance_schema.user_variables_by_thread(用于迁移前后还原 + # foreign_key_checks / sql_safe_updates)。业务账号权限被收敛后,启动会直接报: + # SELECT command denied to user 'xxx'@'...' for table 'user_variables_by_thread' + # 该探测只按数据库版本判断是否启用(MySQL ≥5.7 就开),不看权限,也没有开关可关。 + # + # 这里只配 user / password、不配 url:Spring Boot 会用主数据源的 URL 与驱动 + # 派生出一个只换账号密码的迁移数据源(已实测 url 与主库完全一致), + # 因此 JDBC URL 只有 datasource 一处,不需要两边同步维护。 + # + # 两个变量故意不给默认值:缺失时启动即失败,而不是静默回退到业务账号 + # (回退会再次撞上上面那个权限错误,报错信息离根因更远)。 + flyway: + user: ${FLYWAY_USER} + password: ${FLYWAY_PASSWORD} + +management: + endpoints: + web: + exposure: + include: health,info,metrics,conditions + endpoint: + health: + show-details: never + health: + redis: + # Redis 是硬依赖(验证码、发码、登录失败计数、令牌吊销),故障时直接 DOWN + enabled: ${HEALTH_REDIS_ENABLED:true} + +knife4j: + # 必须与 springdoc.api-docs.enabled 保持一致: + # 只关 springdoc 会让 knife4j 找不到 SpringDocConfigProperties,应用启动失败 + enable: ${API_DOCS_ENABLED:false} + production: ${KNIFE4J_PRODUCTION:true} + +springdoc: + api-docs: + enabled: ${API_DOCS_ENABLED:false} + swagger-ui: + enabled: ${API_DOCS_ENABLED:false} + +# 生产必须由环境变量提供:缺失时启动直接失败,避免回退到 application.yml 里的开发默认值 +config: + jwt: + secret: ${JWT_SECRET} + internal-key: ${INTERNAL_KEY} + +logging: + file: + name: ${LOG_FILE:logs/iam-api.log} + level: + root: warn + com.gxwebsoft: info diff --git a/iam-api/src/main/resources/application.yml b/iam-api/src/main/resources/application.yml new file mode 100644 index 0000000..aaf546c --- /dev/null +++ b/iam-api/src/main/resources/application.yml @@ -0,0 +1,141 @@ +server: + port: 8900 + shutdown: graceful + servlet: + encoding: + charset: UTF-8 + force: true + +spring: + application: + name: iam-api + # RS256 签名密钥放在项目外层的 config/iam-jwt.properties(不入库); + # 文件不存在时跳过,应用会退化为启动时临时生成密钥。 + config: + import: optional:file:./config/iam-jwt.properties + profiles: + active: dev + # 激活 local 时自动叠加 dev,local 只放个人覆盖项 + group: + local: dev + lifecycle: + timeout-per-shutdown-phase: 30s + # Java 21+ 虚拟线程:IO 密集型接口吞吐更好,压测确认后再在生产开启 + threads: + virtual: + enabled: true + jackson: + time-zone: GMT+8 + date-format: yyyy-MM-dd HH:mm:ss + + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: true + validate-on-migrate: true + +# MyBatis-Plus +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + cache-enabled: true + global-config: + banner: false + db-config: + id-type: auto + logic-delete-value: 1 + logic-not-delete-value: 0 + +# 接口文档 +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html + default-produces-media-type: application/json + +knife4j: + enable: true + setting: + language: zh_cn + +# 监控 +management: + endpoints: + web: + base-path: /actuator + exposure: + include: health,info,metrics + endpoint: + health: + show-details: when-authorized + +# 缓存实现:redis(生产)/ memory(演示、测试) +iam: + cache: + type: redis + +# 框架配置 +config: + swagger-title: iam-api 接口文档 + swagger-description: 统一身份中心:账号 / 租户 / 角色 / 权限 / 菜单 / 令牌 + swagger-version: 0.1.0 + swagger-base-packages: + - com.gxwebsoft + server-url: ${SERVER_URL:http://localhost:8900} + # 内网自证密钥:introspect / revoke 的调用方用它自证,生产必须覆盖 + internal-key: ${INTERNAL_KEY:change-me} + # 跨域来源白名单(逗号分隔,支持通配)。禁止改成 "*": + # 接口开启了 allowCredentials,任意来源都能带凭证调用,等于把接口开放给所有网站。 + cors-allowed-origin-patterns: ${CORS_ALLOWED_ORIGINS:https://websoft.top,https://*.websoft.top,http://localhost:[*],http://127.0.0.1:[*]} + + # 控制台会话令牌(HS256):只服务于控制台自身 + jwt: + secret: ${JWT_SECRET:d2Vic29mdC1kZXYtb25seS1zZWNyZXQta2V5LTMyLWJ5dGVzIQ==} + expire: 86400 + refresh-threshold: 1800 + + # 统一令牌(RS256):面向 open-api / dp-api 等资源服务 + # private-key / public-key 由 config/iam-jwt.properties 或环境变量提供 + iam-jwt: + issuer: ${IAM_JWT_ISSUER:https://iam.websoft.top} + audience: ${IAM_JWT_AUDIENCE:websoft-platform} + key-id: ${IAM_JWT_KEY_ID:iam-1} + access-expire: 7200 + refresh-expire: 2592000 + code-expire: 300 + + # 登录与验证码策略 + auth: + # 登录强制图形验证码;本地联调可临时设为 false + captcha-enabled: ${AUTH_CAPTCHA_ENABLED:true} + captcha-expire: 300 + # 连续失败 5 次锁定账号 10 分钟 + login-fail-threshold: 5 + login-lock-seconds: 600 + email-captcha-enabled: ${AUTH_EMAIL_ENABLED:true} + # 是否开放开发者自助注册(当前未提供注册接口,账号由平台运营开通) + register-enabled: ${AUTH_REGISTER_ENABLED:false} + # 发码接口的 IP 维度限流:短间隔 5 秒 + 每小时 30 次 + code-send-ip-hourly-limit: ${AUTH_CODE_IP_HOURLY_LIMIT:30} + code-send-ip-interval-seconds: ${AUTH_CODE_IP_INTERVAL:5} + + # 短信验证码:默认只写日志,接入真实通道后置 true + sms: + enabled: ${SMS_ENABLED:false} + interval-seconds: 60 + daily-limit: 10 + code-expire: 300 + sign-name: ${ALIYUN_SMS_SIGN_NAME:} + template-code: ${ALIYUN_SMS_TEMPLATE_CODE:} + + # 邮件验证码:未配置 spring.mail.host 时只写日志 + mail: + enabled: ${MAIL_ENABLED:true} + from: ${MAIL_FROM:} + subject: ${MAIL_SUBJECT:云枢开放平台验证码} + +logging: + level: + root: info + com.gxwebsoft: debug diff --git a/iam-api/src/main/resources/db/migration/V1__init_iam_schema.sql b/iam-api/src/main/resources/db/migration/V1__init_iam_schema.sql new file mode 100644 index 0000000..b777e3c --- /dev/null +++ b/iam-api/src/main/resources/db/migration/V1__init_iam_schema.sql @@ -0,0 +1,165 @@ +-- ===================================================================== +-- 统一身份中心(iam-api)核心表结构 V1 +-- +-- 统一约定: +-- * 主键统一 BIGINT 自增,字段名下划线命名 +-- * del_flag:0 正常 / 1 已删除(逻辑删除,由 MyBatis-Plus 自动过滤) +-- * status:0 停用 / 1 启用 +-- * create_time / update_time 由应用层统一填充(见 MybatisMetaObjectHandler) +-- * 兼容 MySQL 与 H2(MODE=MySQL),因此不写内联 COMMENT,注释统一用 -- +-- ===================================================================== + +-- 租户 +CREATE TABLE sys_tenant +( + tenant_id BIGINT NOT NULL AUTO_INCREMENT, + tenant_name VARCHAR(64) NOT NULL, + tenant_code VARCHAR(64), + contact_name VARCHAR(32), + contact_phone VARCHAR(20), + status TINYINT NOT NULL DEFAULT 1, + expire_time DATETIME, + del_flag TINYINT NOT NULL DEFAULT 0, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + PRIMARY KEY (tenant_id) +); + +-- 用户 +CREATE TABLE sys_user +( + user_id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + username VARCHAR(64) NOT NULL, + password VARCHAR(100) NOT NULL, -- BCrypt + nickname VARCHAR(64), + phone VARCHAR(20), + email VARCHAR(100), + avatar VARCHAR(255), + status TINYINT NOT NULL DEFAULT 1, + last_login_time DATETIME, + del_flag TINYINT NOT NULL DEFAULT 0, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + PRIMARY KEY (user_id) +); +CREATE INDEX idx_sys_user_tenant ON sys_user (tenant_id, del_flag); +CREATE INDEX idx_sys_user_username ON sys_user (username); +CREATE INDEX idx_sys_user_email ON sys_user (email); +CREATE INDEX idx_sys_user_phone ON sys_user (phone); + +-- 角色 +CREATE TABLE sys_role +( + role_id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + role_name VARCHAR(64) NOT NULL, + role_code VARCHAR(64) NOT NULL, + sort INT NOT NULL DEFAULT 0, + remark VARCHAR(255), + status TINYINT NOT NULL DEFAULT 1, + del_flag TINYINT NOT NULL DEFAULT 0, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + PRIMARY KEY (role_id) +); +CREATE INDEX idx_sys_role_tenant ON sys_role (tenant_id, del_flag); + +-- 用户角色关联 +CREATE TABLE sys_user_role +( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_sys_user_role UNIQUE (user_id, role_id) +); + +-- 菜单 / 权限(平台级元数据,无 tenant_id) +CREATE TABLE sys_menu +( + menu_id BIGINT NOT NULL AUTO_INCREMENT, + parent_id BIGINT NOT NULL DEFAULT 0, + menu_name VARCHAR(64) NOT NULL, + menu_type TINYINT NOT NULL DEFAULT 2, -- 1目录 2菜单 3按钮 + path VARCHAR(200), + component VARCHAR(200), + permission VARCHAR(100), + icon VARCHAR(64), + sort INT NOT NULL DEFAULT 0, + visible TINYINT NOT NULL DEFAULT 1, + status TINYINT NOT NULL DEFAULT 1, + del_flag TINYINT NOT NULL DEFAULT 0, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + PRIMARY KEY (menu_id) +); +CREATE INDEX idx_sys_menu_parent ON sys_menu (parent_id); + +-- 角色菜单关联 +CREATE TABLE sys_role_menu +( + id BIGINT NOT NULL AUTO_INCREMENT, + role_id BIGINT NOT NULL, + menu_id BIGINT NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_sys_role_menu UNIQUE (role_id, menu_id) +); + +-- 登录审计流水 +CREATE TABLE sys_login_record +( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT, + user_id BIGINT, + username VARCHAR(64), + type TINYINT NOT NULL DEFAULT 1, -- 1登录 2登出 3续签 4绑定手机号 + status TINYINT NOT NULL DEFAULT 1, -- 0失败 1成功 + ip VARCHAR(64), + user_agent VARCHAR(500), + message VARCHAR(255), + create_time DATETIME NOT NULL, + PRIMARY KEY (id) +); +CREATE INDEX idx_sys_login_record_user ON sys_login_record (tenant_id, user_id); + +-- 接入方(令牌消费方):open-api / dp-api / 第三方应用 +CREATE TABLE iam_client +( + id BIGINT NOT NULL AUTO_INCREMENT, + client_id VARCHAR(64) NOT NULL, + client_secret VARCHAR(100) NOT NULL, -- BCrypt + client_name VARCHAR(64) NOT NULL, + audience VARCHAR(64), -- 资源服务名,如 dp-api + scopes VARCHAR(500), -- 逗号分隔 + grant_types VARCHAR(200), -- 逗号分隔 + access_token_seconds BIGINT, + refresh_token_seconds BIGINT, + redirect_uris VARCHAR(500), -- 逗号分隔,为空表示不限制 + status TINYINT NOT NULL DEFAULT 1, + del_flag TINYINT NOT NULL DEFAULT 0, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + PRIMARY KEY (id) +); +CREATE INDEX idx_iam_client_client_id ON iam_client (client_id); + +-- OAuth2 授权码(一次性、短有效期) +CREATE TABLE iam_auth_code +( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(64) NOT NULL, + client_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + username VARCHAR(64), + redirect_uri VARCHAR(500), + scopes VARCHAR(500), + code_challenge VARCHAR(200), + code_challenge_method VARCHAR(16), + used TINYINT NOT NULL DEFAULT 0, + expire_time DATETIME NOT NULL, + create_time DATETIME NOT NULL, + PRIMARY KEY (id) +); +CREATE INDEX idx_iam_auth_code_code ON iam_auth_code (code); diff --git a/iam-api/src/main/resources/db/migration/V2__seed_base_data.sql b/iam-api/src/main/resources/db/migration/V2__seed_base_data.sql new file mode 100644 index 0000000..f75fd71 --- /dev/null +++ b/iam-api/src/main/resources/db/migration/V2__seed_base_data.sql @@ -0,0 +1,77 @@ +-- ===================================================================== +-- 基础数据:默认租户、平台超管、iam 权限菜单、内置接入方 +-- +-- 初始账号:admin / admin123(首次登录后请立即修改密码) +-- 内置接入方密钥:open-api / dp-api 各一条,密钥为演示值 +-- —— 上线前必须在「接入方管理」里重置为随机密钥并妥善保存。 +-- ===================================================================== + +INSERT INTO sys_tenant (tenant_id, tenant_name, tenant_code, contact_name, contact_phone, + status, create_time, update_time) +VALUES (1, '平台运营租户', 'platform', '管理员', '13800000000', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +INSERT INTO sys_role (role_id, tenant_id, role_name, role_code, sort, remark, status, create_time, update_time) +VALUES (1, 1, '平台超级管理员', 'admin', 1, '拥有全部权限', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (2, 1, '平台运营', 'platform-ops', 2, '租户开通与账号管理', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +INSERT INTO sys_user (user_id, tenant_id, username, password, nickname, email, status, create_time, update_time) +VALUES (1, 1, 'admin', '$2a$10$KgzmtYYvqkEOAIbvneWv/evy/pfWpSQx3gfj67eq3m5x1ApENNjIG', '超级管理员', + 'admin@websoft.top', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +INSERT INTO sys_user_role (user_id, role_id) VALUES (1, 1); + +-- 权限菜单:iam 模块(目录 1000) +INSERT INTO sys_menu (menu_id, parent_id, menu_name, menu_type, path, component, permission, icon, sort, + visible, status, create_time, update_time) +VALUES (1000, 0, '身份管理', 1, '/iam', NULL, NULL, 'team', 1, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1010, 1000, '租户管理', 2, '/iam/tenants', 'iam/tenants/index', 'iam:tenant:list', 'shop', 1, 1, 1, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1011, 1010, '租户新增', 3, NULL, NULL, 'iam:tenant:add', NULL, 1, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1012, 1010, '租户修改', 3, NULL, NULL, 'iam:tenant:edit', NULL, 2, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1013, 1010, '租户删除', 3, NULL, NULL, 'iam:tenant:remove', NULL, 3, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1020, 1000, '用户管理', 2, '/iam/users', 'iam/users/index', 'iam:user:list', 'user', 2, 1, 1, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1021, 1020, '用户新增', 3, NULL, NULL, 'iam:user:add', NULL, 1, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1022, 1020, '用户修改', 3, NULL, NULL, 'iam:user:edit', NULL, 2, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1023, 1020, '用户删除', 3, NULL, NULL, 'iam:user:remove', NULL, 3, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1024, 1020, '重置密码', 3, NULL, NULL, 'iam:user:resetPwd', NULL, 4, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1030, 1000, '角色管理', 2, '/iam/roles', 'iam/roles/index', 'iam:role:list', 'crown', 3, 1, 1, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1031, 1030, '角色新增', 3, NULL, NULL, 'iam:role:add', NULL, 1, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1032, 1030, '角色修改', 3, NULL, NULL, 'iam:role:edit', NULL, 2, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1033, 1030, '角色删除', 3, NULL, NULL, 'iam:role:remove', NULL, 3, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1040, 1000, '菜单管理', 2, '/iam/menus', 'iam/menus/index', 'iam:menu:list', 'menu', 4, 1, 1, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1041, 1040, '菜单新增', 3, NULL, NULL, 'iam:menu:add', NULL, 1, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1042, 1040, '菜单修改', 3, NULL, NULL, 'iam:menu:edit', NULL, 2, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1043, 1040, '菜单删除', 3, NULL, NULL, 'iam:menu:remove', NULL, 3, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1050, 1000, '接入方管理', 2, '/iam/clients', 'iam/clients/index', 'iam:client:list', 'api', 5, 1, 1, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1051, 1050, '接入方新增', 3, NULL, NULL, 'iam:client:add', NULL, 1, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1052, 1050, '接入方修改', 3, NULL, NULL, 'iam:client:edit', NULL, 2, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1053, 1050, '接入方删除', 3, NULL, NULL, 'iam:client:remove', NULL, 3, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +-- 平台超管:全部权限 +INSERT INTO sys_role_menu (role_id, menu_id) +VALUES (1, 1000), (1, 1010), (1, 1011), (1, 1012), (1, 1013), + (1, 1020), (1, 1021), (1, 1022), (1, 1023), (1, 1024), + (1, 1030), (1, 1031), (1, 1032), (1, 1033), + (1, 1040), (1, 1041), (1, 1042), (1, 1043), + (1, 1050), (1, 1051), (1, 1052), (1, 1053); + +-- 平台运营:只给租户与账号相关权限 +INSERT INTO sys_role_menu (role_id, menu_id) +VALUES (2, 1000), (2, 1010), (2, 1011), (2, 1012), + (2, 1020), (2, 1021), (2, 1022), (2, 1024); + +-- 内置接入方:密钥为 BCrypt 哈希,对应明文见 README(上线前必须重置) +INSERT INTO iam_client (id, client_id, client_secret, client_name, audience, scopes, grant_types, + access_token_seconds, refresh_token_seconds, redirect_uris, status, + create_time, update_time) +VALUES (1, 'open-api', '$2a$10$KgzmtYYvqkEOAIbvneWv/evy/pfWpSQx3gfj67eq3m5x1ApENNjIG', '开放平台控制台', + 'open-api', 'iam:user:read,open:app:read', 'client_credentials,authorization_code,refresh_token', + 7200, 2592000, 'https://open.websoft.top/oauth/callback', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (2, 'dp-api', '$2a$10$KgzmtYYvqkEOAIbvneWv/evy/pfWpSQx3gfj67eq3m5x1ApENNjIG', '数据中台', + 'dp-api', 'iam:user:read,dp:customer:read,dp:order:read,dp:contract:read,dp:invoice:read,dp:payment:read', + 'client_credentials,authorization_code,refresh_token', + 7200, 2592000, 'https://dp.websoft.top/oauth/callback', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); diff --git a/iam-api/src/main/resources/logback-spring.xml b/iam-api/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..443c2ca --- /dev/null +++ b/iam-api/src/main/resources/logback-spring.xml @@ -0,0 +1,26 @@ + + + + + + + + + + ${LOG_FILE:-logs/iam-api.log} + + ${LOG_FILE:-logs/iam-api.log}.%d{yyyy-MM-dd}.%i.gz + 50MB + 30 + 2GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + diff --git a/iam-api/src/test/java/com/gxwebsoft/iam/IamApiIntegrationTest.java b/iam-api/src/test/java/com/gxwebsoft/iam/IamApiIntegrationTest.java new file mode 100644 index 0000000..8d62712 --- /dev/null +++ b/iam-api/src/test/java/com/gxwebsoft/iam/IamApiIntegrationTest.java @@ -0,0 +1,215 @@ +package com.gxwebsoft.iam; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.IamApiApplication; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * iam-api 端到端冒烟:登录 → 当前用户 → 换令牌 → 自省 → JWKS。 + * + *

用 H2 + 内存缓存跑真实 HTTP 链路(含 Spring Security 过滤器链), + * 验证「账号体系」和「统一令牌」两块核心能力确实可用。

+ * + * @author WebSoft + */ +@SpringBootTest(classes = IamApiApplication.class) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class IamApiIntegrationTest { + + private static final String INTERNAL_KEY = "test-internal-key"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("账号密码登录后可用会话令牌访问 /api/me") + void loginThenLoadProfile() throws Exception { + String token = login(); + assertThat(token).isNotBlank(); + + String body = mockMvc.perform(get("/api/me").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode node = objectMapper.readTree(body); + assertThat(node.path("code").asInt()).isZero(); + assertThat(node.path("data").path("username").asText()).isEqualTo("admin"); + assertThat(node.path("data").path("permissions").isArray()).isTrue(); + assertThat(node.path("data").path("permissions").toString()).contains("iam:user:list"); + } + + @Test + @DisplayName("未携带令牌访问受保护接口返回未认证") + void anonymousIsRejected() throws Exception { + String body = mockMvc.perform(get("/api/me")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + assertThat(objectMapper.readTree(body).path("code").asInt()).isEqualTo(401); + } + + @Test + @DisplayName("错误密码连续失败达到阈值后账号被锁定") + void loginFailuresLockAccount() throws Exception { + // 用独立账号做锁定探针,避免把 admin 锁住影响其它用例 + String probe = "lock-probe-user"; + // 阈值 3(见 application-test.yml),失败 3 次后第 4 次应提示锁定 + for (int i = 0; i < 3; i++) { + String body = loginRaw(probe, "wrong-password-" + i); + assertThat(objectMapper.readTree(body).path("code").asInt()).isNotZero(); + } + String locked = loginRaw(probe, "admin123"); + assertThat(objectMapper.readTree(locked).path("message").asText()).contains("锁定"); + } + + @Test + @DisplayName("client_credentials 换取 RS256 令牌,可自省且能通过 JWKS 验签") + void clientCredentialsTokenFlow() throws Exception { + String tokenResponse = mockMvc.perform(post("/api/v1/oauth/token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new TokenRequest( + "client_credentials", "dp-api", "admin123", "dp:customer:read", null, null, null)))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode tokenNode = objectMapper.readTree(tokenResponse); + assertThat(tokenNode.path("code").asInt()).isZero(); + String accessToken = tokenNode.path("data").path("accessToken").asText(); + assertThat(accessToken).isNotBlank(); + assertThat(tokenNode.path("data").path("scope").asText()).isEqualTo("dp:customer:read"); + + // JWKS:资源服务据此验签 + String jwks = mockMvc.perform(get("/api/v1/oauth/jwks")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + assertThat(jwks).contains("\"keys\""); + assertThat(jwks).doesNotContain("PRIVATE"); + + // 自省:需要内网密钥自证 + String introspect = mockMvc.perform(post("/api/v1/oauth/introspect") + .header("X-Internal-Key", INTERNAL_KEY) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new TokenBody(accessToken)))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + JsonNode introspectNode = objectMapper.readTree(introspect); + assertThat(introspectNode.path("data").path("active").asBoolean()).isTrue(); + assertThat(introspectNode.path("data").path("clientId").asText()).isEqualTo("dp-api"); + assertThat(introspectNode.path("data").path("tokenType").asText()).isEqualTo("access"); + + // 自省未带内网密钥:必须拒绝 + String denied = mockMvc.perform(post("/api/v1/oauth/introspect") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new TokenBody(accessToken)))) + .andReturn().getResponse().getContentAsString(); + assertThat(objectMapper.readTree(denied).path("code").asInt()).isEqualTo(403); + } + + @Test + @DisplayName("客户端密钥错误时换令牌失败") + void wrongClientSecretRejected() throws Exception { + String body = mockMvc.perform(post("/api/v1/oauth/token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new TokenRequest( + "client_credentials", "dp-api", "not-the-secret", null, null, null, null)))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + assertThat(objectMapper.readTree(body).path("code").asInt()).isNotZero(); + } + + @Test + @DisplayName("授权码模式:申请授权码后换取 access + refresh 令牌") + void authorizationCodeFlow() throws Exception { + String codeBody = mockMvc.perform(post("/api/v1/oauth/authorize") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new AuthorizeRequest( + 1L, "admin", "admin123", "dp-api", "https://dp.websoft.top/oauth/callback", + "dp:customer:read")))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + JsonNode codeNode = objectMapper.readTree(codeBody); + assertThat(codeNode.path("code").asInt()).isZero(); + String authorizeCode = codeNode.path("data").path("code").asText(); + assertThat(authorizeCode).isNotBlank(); + + String tokenBody = mockMvc.perform(post("/api/v1/oauth/token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new TokenRequest("authorization_code", "dp-api", + "admin123", null, authorizeCode, "https://dp.websoft.top/oauth/callback", null)))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + JsonNode tokenNode = objectMapper.readTree(tokenBody); + assertThat(tokenNode.path("code").asInt()).isZero(); + assertThat(tokenNode.path("data").path("accessToken").asText()).isNotBlank(); + String refreshToken = tokenNode.path("data").path("refreshToken").asText(); + assertThat(refreshToken).isNotBlank(); + + // 授权码一次性:重复使用必须失败 + String reuse = mockMvc.perform(post("/api/v1/oauth/token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new TokenRequest("authorization_code", "dp-api", + "admin123", null, authorizeCode, "https://dp.websoft.top/oauth/callback", null)))) + .andReturn().getResponse().getContentAsString(); + assertThat(objectMapper.readTree(reuse).path("code").asInt()).isNotZero(); + + // 刷新即轮换:新 refresh token 与旧的不同 + String refreshed = mockMvc.perform(post("/api/v1/oauth/token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new TokenRequest("refresh_token", "dp-api", + "admin123", null, null, null, refreshToken)))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + JsonNode refreshedNode = objectMapper.readTree(refreshed); + assertThat(refreshedNode.path("code").asInt()).isZero(); + assertThat(refreshedNode.path("data").path("refreshToken").asText()).isNotEqualTo(refreshToken); + } + + private String login() throws Exception { + String body = loginRaw("admin", "admin123"); + JsonNode node = objectMapper.readTree(body); + assertThat(node.path("code").asInt()).isZero(); + return node.path("data").path("token").asText(); + } + + private String loginRaw(String username, String password) throws Exception { + return mockMvc.perform(post("/api/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new LoginRequest(1L, username, password)))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + } + + /** 登录请求体 */ + private record LoginRequest(Long tenantId, String username, String password) { + } + + /** 换令牌请求体 */ + private record TokenRequest(String grantType, String clientId, String clientSecret, String scope, + String code, String redirectUri, String refreshToken) { + } + + /** 令牌请求体 */ + private record TokenBody(String token) { + } + + /** 授权码申请请求体 */ + private record AuthorizeRequest(Long tenantId, String username, String password, String clientId, + String redirectUri, String scope) { + } +} diff --git a/iam-api/src/test/resources/application-test.yml b/iam-api/src/test/resources/application-test.yml new file mode 100644 index 0000000..8ac5407 --- /dev/null +++ b/iam-api/src/test/resources/application-test.yml @@ -0,0 +1,42 @@ +# 测试环境:H2 内存库 + 进程内缓存,不依赖 MySQL / Redis +spring: + datasource: + url: jdbc:h2:mem:iam_test;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE + username: sa + password: + driver-class-name: org.h2.Driver + # 不写 validation-query 时 Druid 会以 ERROR 级别打日志,测试输出会很吵 + druid: + validation-query: SELECT 1 + flyway: + enabled: true + clean-disabled: false + data: + redis: + host: 127.0.0.1 + port: 6379 + +iam: + cache: + type: memory + +management: + health: + redis: + enabled: false + +config: + internal-key: test-internal-key + jwt: + secret: dGVzdC1pYW0tYXBpLXNlc3Npb24tc2VjcmV0LWtleS0zMi1ieXRlcyE= + auth: + # 测试里不生成 / 校验图形验证码,避免依赖图片识别 + captcha-enabled: false + login-fail-threshold: 3 + iam-jwt: + issuer: https://iam.test + audience: websoft-platform + key-id: iam-test + access-expire: 600 + refresh-expire: 3600 + code-expire: 120