From f09b8a4d6c0dca148dd9111f32b675ace6b4d2bd Mon Sep 17 00:00:00 2001 From: DaxPay Dev Date: Fri, 26 Jun 2026 19:03:59 +0800 Subject: [PATCH] =?UTF-8?q?refactor(auth):=20=E9=87=8D=E6=9E=84=20capabili?= =?UTF-8?q?ty-auth=20=E4=B8=8E=20service-iam=20=E6=A8=A1=E5=9D=97=E8=BE=B9?= =?UTF-8?q?=E7=95=8C,=20=E4=BF=AE=E5=A4=8D=20i18n=20=E4=B8=8D=E8=A7=84?= =?UTF-8?q?=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 认证 SPI: AbstractAuthentication → Authenticator 接口 + AuthenticationTemplate 编排 - 路由鉴权: 新增 AccessPolicy SPI, PasswordStatusCheck 改实现 AccessPolicy - 认证挑战: 新增 PostAuthenticationChallenge SPI, 2FA 逻辑解耦到 TwoFactorAuthenticationChallenge - 登录路由: adaptation 双键匹配(clientCode+loginType), 抽取 AbstractPasswordLoginHandler 消除重复 - 异常下沉: 6 个 IAM 业务异常从 capability-auth 移至 service-iam, 处理迁移到 IamAuthExceptionHandler - 死代码清理: 删除 SecurityConfigService 及 3 个零引用异常 - i18n 修复: NotLoginException 双重前缀 / RouterCheckException+LoginRetryService 直传中文 / LoginFailureException 补 args 构造器 --- .../auth/MchPasswordLoginHandler.java | 138 ++---------------- .../AbstractAuthentication.java | 56 ------- .../AuthenticationChallengeException.java | 16 ++ .../AuthenticationTemplate.java | 37 +++++ .../auth/authentication/Authenticator.java | 36 +++++ .../PostAuthenticationChallenge.java | 22 +++ .../ApplicationNotEnableException.java | 19 --- .../exception/ClientNotEnableException.java | 19 --- .../exception/ClientNotFoundException.java | 19 --- .../auth/exception/LoginFailureException.java | 7 + .../auth/exception/NotLoginException.java | 2 +- .../auth/exception/RouterCheckException.java | 2 +- .../auth/handler/SaRouteHandler.java | 22 ++- .../capability/auth/service/AccessPolicy.java | 19 +++ .../main/resources/i18n/en-US/error/auth.json | 4 +- .../main/resources/i18n/zh-CN/error/auth.json | 4 +- .../login/AbstractPasswordLoginHandler.java | 135 +++++++++++++++++ .../iam/auth/login/PasswordLoginHandler.java | 134 ++--------------- .../iam/auth/service/CaptchaService.java | 4 +- .../iam/auth/service/LoginContentService.java | 11 +- .../iam/auth/service/LoginRetryService.java | 4 +- .../iam/auth/service/SecondCheckService.java | 2 +- .../auth/service/SecurityConfigService.java | 47 ------ .../service}/TokenService.java | 99 ++++++------- .../TwoFactorAuthenticationChallenge.java | 60 ++++++++ .../platform/iam/endpoint/TokenEndpoint.java | 1 + .../auth}/ApplicationNotFoundException.java | 4 +- .../auth}/CaptchaErrorException.java | 5 +- .../auth}/CaptchaRequiredException.java | 4 +- .../auth}/PasswordExpiredAccessException.java | 9 +- .../auth}/TwoFactorRequiredException.java | 5 +- .../auth}/UserNotFoundException.java | 8 +- .../iam/handler/PasswordStatusCheck.java | 42 ++---- .../exception/IamAuthExceptionHandler.java | 32 ++++ .../auth}/TwoFactorChallengeResult.java | 2 +- .../iam/service/user/UserQueryService.java | 2 +- .../exception/RestExceptionHandler.java | 14 -- 37 files changed, 501 insertions(+), 545 deletions(-) delete mode 100644 daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/authentication/AbstractAuthentication.java create mode 100644 daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/authentication/AuthenticationChallengeException.java create mode 100644 daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/authentication/AuthenticationTemplate.java create mode 100644 daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/authentication/Authenticator.java create mode 100644 daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/authentication/PostAuthenticationChallenge.java delete mode 100644 daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/ApplicationNotEnableException.java delete mode 100644 daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/ClientNotEnableException.java delete mode 100644 daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/ClientNotFoundException.java create mode 100644 daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/service/AccessPolicy.java create mode 100644 daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/login/AbstractPasswordLoginHandler.java delete mode 100644 daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/SecurityConfigService.java rename daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/{endpoint => auth/service}/TokenService.java (76%) create mode 100644 daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/twofactor/TwoFactorAuthenticationChallenge.java rename daxpay-platform/{daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception => daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth}/ApplicationNotFoundException.java (77%) rename daxpay-platform/{daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception => daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth}/CaptchaErrorException.java (68%) rename daxpay-platform/{daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception => daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth}/CaptchaRequiredException.java (82%) rename daxpay-platform/{daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception => daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth}/PasswordExpiredAccessException.java (56%) rename daxpay-platform/{daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception => daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth}/TwoFactorRequiredException.java (78%) rename daxpay-platform/{daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception => daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth}/UserNotFoundException.java (63%) create mode 100644 daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/handler/exception/IamAuthExceptionHandler.java rename daxpay-platform/{daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/entity => daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/result/auth}/TwoFactorChallengeResult.java (93%) diff --git a/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/auth/MchPasswordLoginHandler.java b/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/auth/MchPasswordLoginHandler.java index 2d2875693..e61ef8408 100644 --- a/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/auth/MchPasswordLoginHandler.java +++ b/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/auth/MchPasswordLoginHandler.java @@ -1,143 +1,31 @@ package cn.daxpay.open.payment.merchant.auth; -import cn.daxpay.open.platform.core.entity.UserDetail; import cn.daxpay.open.platform.core.enums.client.ClientEnum; +import cn.daxpay.open.platform.iam.auth.login.AbstractPasswordLoginHandler; import cn.daxpay.open.platform.iam.auth.service.CaptchaService; import cn.daxpay.open.platform.iam.auth.service.IamSecurityConfigService; import cn.daxpay.open.platform.iam.auth.service.LoginRetryService; import cn.daxpay.open.platform.iam.auth.service.PasswordDecryptService; -import cn.daxpay.open.platform.iam.entity.user.UserInfo; -import cn.daxpay.open.platform.iam.result.user.UserInfoResult; -import cn.daxpay.open.platform.iam.service.user.UserQueryService; -import cn.daxpay.open.platform.capability.auth.authentication.AbstractAuthentication; -import cn.daxpay.open.platform.capability.auth.code.AuthLoginTypeCode; -import cn.daxpay.open.platform.capability.auth.entity.AuthInfoResult; -import cn.daxpay.open.platform.capability.auth.entity.LoginAuthContext; -import cn.daxpay.open.platform.capability.auth.exception.LoginFailureException; -import cn.daxpay.open.platform.capability.auth.exception.UserNotFoundException; -import cn.daxpay.open.payment.common.context.PaymentContext; -import cn.daxpay.open.platform.system.entity.config.platform.security.PlatformLoginSecurityConfig; -import cn.hutool.core.util.StrUtil; -import cn.hutool.crypto.digest.BCrypt; -import jakarta.annotation.Nullable; -import jakarta.annotation.Resource; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.constraints.NotNull; -import lombok.Getter; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import java.util.Objects; - -/// # 商户端密码登录处理器 +/// # 商户端账号密码登录 +/// +/// 仅声明终端编码(merchant), 通用流程继承 [AbstractPasswordLoginHandler]。 /// @Slf4j @Component -@RequiredArgsConstructor -public class MchPasswordLoginHandler implements AbstractAuthentication { - - @Getter - private final String ACCOUNT_PARAMETER = "account"; - - @Getter - private final String PASSWORD_PARAMETER = "password"; - - @Getter - private final String CAPTCHA_KEY_PARAMETER = "captchaKey"; - - @Getter - private final String CAPTCHA_CODE_PARAMETER = "captchaCode"; - - @Resource - @Getter - private UserQueryService userQueryService; - - private final LoginRetryService loginRetryService; - - private final CaptchaService captchaService; - - private final IamSecurityConfigService iamSecurityConfigService; - - private final PasswordDecryptService passwordDecryptService; - - private final PaymentContext apiContext; +public class MchPasswordLoginHandler extends AbstractPasswordLoginHandler { + public MchPasswordLoginHandler(LoginRetryService loginRetryService, CaptchaService captchaService, + IamSecurityConfigService iamSecurityConfigService, + PasswordDecryptService passwordDecryptService) { + super(loginRetryService, captchaService, iamSecurityConfigService, passwordDecryptService); + } + /// 商户端终端编码 @Override - public String getLoginType() { - return AuthLoginTypeCode.PASSWORD; - } - - @Override - public @NotNull AuthInfoResult attemptAuthentication(LoginAuthContext context) { - String account = this.obtainAccount(context.getRequest()); - String password = this.obtainPassword(context.getRequest()); - String captchaKey = this.obtainCaptchaKey(context.getRequest()); - String captchaCode = this.obtainCaptchaCode(context.getRequest()); - String clientCode = context.getClientCode(); - - UserInfoResult userInfoResult = this.loadUserByClientCodeAndAccount(clientCode, account); - UserDetail userDetail = userInfoResult.toUserDetail(); - - // 检查验证码(如果需要) - this.checkCaptcha(userDetail.getId(), captchaKey, captchaCode); - loginRetryService.checkBeforeLogin(userDetail); - - // 比对密码未通过 - if (!BCrypt.checkpw(password, userInfoResult.getPassword())) { - // 必须携带 userId, 否则 LoginRetryService.onLoginFailure 因 userId 为空直接跳过, 失败计数始终为 0 - throw new LoginFailureException(userDetail.getId(), userDetail.getAccount(), "账号或密码不正确"); - } - - // 设置密码状态到 UserDetail(超级管理员不设置密码状态限制) - if (!userDetail.isAdmin()) { - loginRetryService.setPasswordStatusToUserDetail(userDetail); - } - - return new AuthInfoResult().setId(userDetail.getId()).setUserDetail(userDetail); - } - - /// 检查验证码 - private void checkCaptcha(Long userId, String captchaKey, String captchaCode) { - PlatformLoginSecurityConfig config = iamSecurityConfigService.getLoginSecurity(); - if (!Boolean.TRUE.equals(config.getCaptchaEnabled())) { - return; - } - int triggerAttempts = config.getCaptchaTriggerAttempts() == null ? 3 : config.getCaptchaTriggerAttempts(); - int errorCount = loginRetryService.getErrorCount(userId); - captchaService.checkOrValidateCaptcha(errorCount, triggerAttempts, captchaKey, captchaCode); - } - - /// 根据终端编码+账号加载用户 - public UserInfoResult loadUserByClientCodeAndAccount(String clientCode, String account) throws UserNotFoundException { - // 回退到默认查找方式(终端维度) - UserInfoResult userInfoResult = userQueryService.findByClientCodeAndAccount(clientCode, account); - if (Objects.isNull(userInfoResult)) { - throw new UserNotFoundException(account); - } - return userInfoResult; - } - - @Nullable - protected String obtainPassword(HttpServletRequest request) { - String password = request.getParameter(this.PASSWORD_PARAMETER); - return passwordDecryptService.decryptPassword(password); - } - - @Nullable - protected String obtainAccount(HttpServletRequest request) { - return request.getParameter(this.ACCOUNT_PARAMETER); - } - - @Nullable - protected String obtainCaptchaKey(HttpServletRequest request) { - return request.getParameter(this.CAPTCHA_KEY_PARAMETER); - } - - @Nullable - protected String obtainCaptchaCode(HttpServletRequest request) { - return request.getParameter(this.CAPTCHA_CODE_PARAMETER); + public String getClientCode() { + return ClientEnum.MERCHANT.getCode(); } } - diff --git a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/authentication/AbstractAuthentication.java b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/authentication/AbstractAuthentication.java deleted file mode 100644 index b93e2b379..000000000 --- a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/authentication/AbstractAuthentication.java +++ /dev/null @@ -1,56 +0,0 @@ -package cn.daxpay.open.platform.capability.auth.authentication; - -import cn.daxpay.open.platform.capability.auth.entity.AuthInfoResult; -import cn.daxpay.open.platform.capability.auth.entity.LoginAuthContext; -import cn.hutool.extra.spring.SpringUtil; -import jakarta.validation.constraints.NotNull; - -import java.util.List; -import java.util.Objects; - -/// # 抽象认证器 -/// -public interface AbstractAuthentication { - - /// 获取终端编码 - String getLoginType(); - - /// 获取用户状态检查接口的实现类 - default List getUserInfoStatusCheck() { - return SpringUtil.getBeansOfType(UserInfoStatusCheck.class).values().stream().toList(); - } - - /// 登录类型是否匹配 - default boolean adaptation(String loginType) { - return Objects.equals(getLoginType(), loginType); - } - - /// 认证前操作 - default void authenticationBefore(LoginAuthContext context) { - - } - - /// 尝试认证, 必须重写 - @NotNull - AuthInfoResult attemptAuthentication(LoginAuthContext context); - - /// 认证后处理 - default void authenticationAfter(AuthInfoResult authInfoResult, LoginAuthContext context) { - } - - /// 认证流程 - default AuthInfoResult authentication(LoginAuthContext context) { - this.authenticationBefore(context); - // 认证逻辑 - AuthInfoResult authInfoResult = this.attemptAuthentication(context); - // 添加用户信息到上下文中 - context.setUserDetail(authInfoResult.getUserDetail()); - // 检查用户信息和状态 - for (var userInfoStatusCheck : this.getUserInfoStatusCheck()) { - userInfoStatusCheck.check(authInfoResult, context); - } - // 认证后处理 - this.authenticationAfter(authInfoResult, context); - return authInfoResult; - } -} diff --git a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/authentication/AuthenticationChallengeException.java b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/authentication/AuthenticationChallengeException.java new file mode 100644 index 000000000..77fa74307 --- /dev/null +++ b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/authentication/AuthenticationChallengeException.java @@ -0,0 +1,16 @@ +package cn.daxpay.open.platform.capability.auth.authentication; + +import cn.daxpay.open.platform.capability.auth.exception.LoginFailureException; + +/// # 认证挑战异常基类 +/// +/// 认证通过后, 但需要额外挑战(如双因素认证)时抛出。 +/// 继承 [LoginFailureException] 以复用其 userId / account 字段, 但语义上属"非失败"流程, +/// 不应触发登录失败计数与失败日志。 +/// +public class AuthenticationChallengeException extends LoginFailureException { + + public AuthenticationChallengeException(Long userId, String account, String messageKey) { + super(userId, account, messageKey); + } +} diff --git a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/authentication/AuthenticationTemplate.java b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/authentication/AuthenticationTemplate.java new file mode 100644 index 000000000..6bd666b2f --- /dev/null +++ b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/authentication/AuthenticationTemplate.java @@ -0,0 +1,37 @@ +package cn.daxpay.open.platform.capability.auth.authentication; + +import cn.daxpay.open.platform.capability.auth.entity.AuthInfoResult; +import cn.daxpay.open.platform.capability.auth.entity.LoginAuthContext; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +/// # 认证流程编排模板 +/// +/// 将"认证前 -> 尝试认证 -> 用户状态校验 -> 认证后"的模板流程从认证器接口中抽离, 统一在此编排。 +/// 通过构造注入一次性解析所有 [UserInfoStatusCheck] (替代原 SpringUtil.getBeansOfType 的运行时反复查询)。 +/// +@Component +@RequiredArgsConstructor +public class AuthenticationTemplate { + + private final List userInfoStatusChecks; + + /// 执行完整认证流程 + public AuthInfoResult authenticate(Authenticator authenticator, LoginAuthContext context) { + // 认证前 + authenticator.authenticationBefore(context); + // 尝试认证 + AuthInfoResult authInfoResult = authenticator.attemptAuthentication(context); + // 添加用户信息到上下文中 + context.setUserDetail(authInfoResult.getUserDetail()); + // 检查用户信息和状态 + for (UserInfoStatusCheck check : userInfoStatusChecks) { + check.check(authInfoResult, context); + } + // 认证后 + authenticator.authenticationAfter(authInfoResult, context); + return authInfoResult; + } +} diff --git a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/authentication/Authenticator.java b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/authentication/Authenticator.java new file mode 100644 index 000000000..d5e2e0f3b --- /dev/null +++ b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/authentication/Authenticator.java @@ -0,0 +1,36 @@ +package cn.daxpay.open.platform.capability.auth.authentication; + +import cn.daxpay.open.platform.capability.auth.entity.AuthInfoResult; +import cn.daxpay.open.platform.capability.auth.entity.LoginAuthContext; +import jakarta.validation.constraints.NotNull; + +/// # 认证器 +/// +/// 认证流程编排( before -> attempt -> 用户状态校验 -> after )由 [AuthenticationTemplate] 统一执行, +/// 本接口仅声明终端、登录方式与各步骤钩子。每个"终端( clientCode ) + 登录方式( loginType )"组合 +/// 由一个具体认证器承载, [AuthenticationTemplate] 据双键路由到唯一实现。 +public interface Authenticator { + + /// 终端编码(对应 [cn.daxpay.open.platform.core.enums.client.ClientEnum] 的 code) + String getClientCode(); + + /// 获取登录类型 + String getLoginType(); + + /// 是否匹配终端与登录方式(双键路由) + default boolean adaptation(String clientCode, String loginType) { + return getClientCode().equals(clientCode) && getLoginType().equals(loginType); + } + + /// 认证前操作 + default void authenticationBefore(LoginAuthContext context) { + } + + /// 尝试认证, 必须重写 + @NotNull + AuthInfoResult attemptAuthentication(LoginAuthContext context); + + /// 认证后处理 + default void authenticationAfter(AuthInfoResult authInfoResult, LoginAuthContext context) { + } +} diff --git a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/authentication/PostAuthenticationChallenge.java b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/authentication/PostAuthenticationChallenge.java new file mode 100644 index 000000000..6a3fe218d --- /dev/null +++ b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/authentication/PostAuthenticationChallenge.java @@ -0,0 +1,22 @@ +package cn.daxpay.open.platform.capability.auth.authentication; + +import cn.daxpay.open.platform.capability.auth.entity.AuthInfoResult; +import cn.daxpay.open.platform.capability.auth.entity.LoginAuthContext; + +/// # 认证后挑战 SPI +/// +/// 认证(凭证校验 + 用户状态校验)通过后、建立会话前, 判断是否需要额外挑战(如双因素认证 / 设备验证 / 风控)。 +/// 需要挑战时由 [createChallenge] 颁发挑战凭证并返回挑战异常, 由全局处理器返回前端。 +/// 挑战不属于登录失败, 不触发失败计数与失败日志。 +/// +public interface PostAuthenticationChallenge { + + /// 是否需要挑战 + /// @param context 认证上下文 + /// @param authInfoResult 认证结果 + boolean required(LoginAuthContext context, AuthInfoResult authInfoResult); + + /// 颁发挑战凭证并返回挑战异常 + /// @return 挑战异常, 由调用方抛出 + AuthenticationChallengeException createChallenge(LoginAuthContext context, AuthInfoResult authInfoResult); +} diff --git a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/ApplicationNotEnableException.java b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/ApplicationNotEnableException.java deleted file mode 100644 index cdd854267..000000000 --- a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/ApplicationNotEnableException.java +++ /dev/null @@ -1,19 +0,0 @@ -package cn.daxpay.open.platform.capability.auth.exception; - -import cn.daxpay.open.platform.core.exception.BizException; - -/// # 应用被停用 -/// -public class ApplicationNotEnableException extends BizException { - - public ApplicationNotEnableException() { - // 指定应用已被停用 - super("指定应用已被停用"); - initMessageKey("error.auth.auth.applicationNotEnable"); - } - - - public ApplicationNotEnableException(int code, String messageKey, Object... args) { - super(code, messageKey, args); - } -} diff --git a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/ClientNotEnableException.java b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/ClientNotEnableException.java deleted file mode 100644 index a66185496..000000000 --- a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/ClientNotEnableException.java +++ /dev/null @@ -1,19 +0,0 @@ -package cn.daxpay.open.platform.capability.auth.exception; - -import cn.daxpay.open.platform.core.exception.BizException; - -/// # 终端方式被停用 -/// -public class ClientNotEnableException extends BizException { - - public ClientNotEnableException() { - // 指定终端方式已被停用 - super("指定终端方式已被停用"); - initMessageKey("error.auth.auth.clientNotEnable"); - } - - - public ClientNotEnableException(int code, String messageKey, Object... args) { - super(code, messageKey, args); - } -} diff --git a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/ClientNotFoundException.java b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/ClientNotFoundException.java deleted file mode 100644 index d78445f00..000000000 --- a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/ClientNotFoundException.java +++ /dev/null @@ -1,19 +0,0 @@ -package cn.daxpay.open.platform.capability.auth.exception; - -import cn.daxpay.open.platform.core.exception.BizException; - -/// # 终端不存在 -/// -public class ClientNotFoundException extends BizException { - - public ClientNotFoundException() { - // 未找到对应的终端 - super("未找到对应的终端"); - initMessageKey("error.auth.auth.clientNotFound"); - } - - - public ClientNotFoundException(int code, String messageKey, Object... args) { - super(code, messageKey, args); - } -} diff --git a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/LoginFailureException.java b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/LoginFailureException.java index 5c3fe873e..c3f1b83f8 100644 --- a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/LoginFailureException.java +++ b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/LoginFailureException.java @@ -1,5 +1,6 @@ package cn.daxpay.open.platform.capability.auth.exception; +import cn.daxpay.open.platform.core.code.CommonCode; import cn.daxpay.open.platform.core.exception.BizInfoException; import lombok.Getter; @@ -31,6 +32,12 @@ public class LoginFailureException extends BizInfoException { this.userId = userId; } + /// 使用 i18n messageKey 与占位符参数(携带账号) + public LoginFailureException(String account, String messageKey, Object... args) { + super(CommonCode.FAIL_CODE, messageKey, args); + this.account = account; + this.userId = null; + } public LoginFailureException(int code, String messageKey, Object... args) { super(code, messageKey, args); diff --git a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/NotLoginException.java b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/NotLoginException.java index 5058040b3..1c22e83f8 100644 --- a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/NotLoginException.java +++ b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/NotLoginException.java @@ -14,7 +14,7 @@ public class NotLoginException extends BizException { public NotLoginException() { super(AUTHENTICATION_FAIL, "用户未登录"); - initMessageKey("error.auth.auth.notLogin"); + initMessageKey("error.auth.notLogin"); } diff --git a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/RouterCheckException.java b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/RouterCheckException.java index 54d3c1da5..23a51aa10 100644 --- a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/RouterCheckException.java +++ b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/RouterCheckException.java @@ -7,7 +7,7 @@ import cn.daxpay.open.platform.core.exception.BizInfoException; public class RouterCheckException extends BizInfoException { public RouterCheckException() { - super("没有对应请求路径的权限"); + super("error.auth.routerCheck"); } public RouterCheckException(String message) { super(message); diff --git a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/handler/SaRouteHandler.java b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/handler/SaRouteHandler.java index 64f7f4867..6e306adce 100644 --- a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/handler/SaRouteHandler.java +++ b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/handler/SaRouteHandler.java @@ -2,6 +2,7 @@ package cn.daxpay.open.platform.capability.auth.handler; import cn.daxpay.open.platform.common.spring.util.WebServletUtil; import cn.daxpay.open.platform.capability.auth.exception.RouterCheckException; +import cn.daxpay.open.platform.capability.auth.service.AccessPolicy; import cn.daxpay.open.platform.capability.auth.service.RouterCheck; import cn.daxpay.open.platform.capability.auth.util.SecurityUtil; import cn.dev33.satoken.fun.SaFunction; @@ -18,16 +19,19 @@ import java.util.List; /// /// 【执行语义 - 统一为"命中放行,未命中拒绝"】 /// - 所有实现 RouterCheck SPI 的 Bean 在启动时按 sortNo 升序排列。 -/// - 请求进入时遍历 RouterCheck 列表,任意一个返回 true 即调用 SaRouter.stop() 放行。 -/// - 全部未命中: -/// - 未登录 → SecurityUtil.getUserId() 抛 NotLoginException -/// - 已登录 → 记录 WARN 日志并抛出 RouterCheckException(403 无权限) +/// - 请求进入时: +/// 1. 已登录用户先依次执行 [AccessPolicy] (如密码过期强制改密), 不通过即抛异常阻断; +/// 2. 再遍历 RouterCheck 列表, 任意一个返回 true 即调用 SaRouter.stop() 放行。 +/// - RouterCheck 全部未命中: +/// - 未登录 → SecurityUtil.getUserId() 抛 NotLoginException +/// - 已登录 → 记录 WARN 日志并抛出 RouterCheckException(403 无权限) /// /// /// 【stop 条件语义】:SaRouter.stop() 会终止后续拦截器执行,但不终止整个过滤器链, /// 仅结束 Sa-Token 鉴权阶段的处理。 /// /// @see cn.daxpay.open.platform.capability.auth.service.RouterCheck +/// @see cn.daxpay.open.platform.capability.auth.service.AccessPolicy @Slf4j @Component @RequiredArgsConstructor @@ -35,6 +39,8 @@ public class SaRouteHandler implements InitializingBean { private final List routerChecks; + private final List accessPolicies; + @Override public void afterPropertiesSet() { // 排序 @@ -47,6 +53,12 @@ public class SaRouteHandler implements InitializingBean { public SaFunction check(Object handler) { return () -> { String path = WebServletUtil.getPath(); + // 已登录用户: 先执行访问策略(如密码过期强制改密), 不通过则抛异常阻断 + SecurityUtil.getCurrentUser().ifPresent(user -> { + for (AccessPolicy policy : accessPolicies) { + policy.check(WebServletUtil.getRequest(), user); + } + }); // 遍历所有 RouterCheck,命中即放行(按 sortNo 顺序执行) for (RouterCheck routerCheck : routerChecks) { if (routerCheck.check(handler)) { @@ -67,5 +79,3 @@ public class SaRouteHandler implements InitializingBean { } } - - diff --git a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/service/AccessPolicy.java b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/service/AccessPolicy.java new file mode 100644 index 000000000..5ec44b7c6 --- /dev/null +++ b/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/service/AccessPolicy.java @@ -0,0 +1,19 @@ +package cn.daxpay.open.platform.capability.auth.service; + +import cn.daxpay.open.platform.core.entity.UserDetail; +import jakarta.servlet.http.HttpServletRequest; + +/// # 已认证请求访问策略 SPI +/// +/// 在路由鉴权通过、对已登录用户执行的访问策略, 允许抛异常阻断请求(如密码过期强制改密)。 +/// 与 [RouterCheck] 的区别: RouterCheck 仅做"是否放行"的布尔判断且不应抛异常; +/// AccessPolicy 专用于"已认证但需限制访问"的场景, 允许抛业务异常。 +/// 实现由 [cn.daxpay.open.platform.capability.auth.handler.SaRouteHandler] 在鉴权链最前面对已登录用户依次调用。 +/// +public interface AccessPolicy { + + /// 检查访问策略, 不通过则抛异常阻断请求 + /// @param request 当前请求 + /// @param userDetail 当前登录用户(非空) + void check(HttpServletRequest request, UserDetail userDetail); +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/auth.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/auth.json index 35f76917e..5cc983c93 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/auth.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/auth.json @@ -1,4 +1,5 @@ { + "accountOrPasswordError": "Incorrect account or password", "notLogin": "User not logged in", "passwordExpired": "Password has expired, please change it first", "captchaError": "Captcha error", @@ -18,5 +19,6 @@ "userDisabled": "User has been disabled", "twoFactorRequired": "Two-factor authentication required", "twoFactorCodeError": "Incorrect verification code or backup code", - "twoFactorPreAuthExpired": "Pre-auth token is invalid or expired, please log in again" + "twoFactorPreAuthExpired": "Pre-auth token is invalid or expired, please log in again", + "loginRetryLock": "Too many failed login attempts, please try again in {0} minutes" } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/auth.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/auth.json index 292243cda..648e62812 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/auth.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/auth.json @@ -1,4 +1,5 @@ { + "accountOrPasswordError": "账号或密码不正确", "notLogin": "用户未登录", "passwordExpired": "密码已过期,请先修改密码", "captchaError": "验证码错误", @@ -18,5 +19,6 @@ "userDisabled": "该用户已被禁用", "twoFactorRequired": "需要双因素认证", "twoFactorCodeError": "动态码或备用码错误", - "twoFactorPreAuthExpired": "预认证令牌无效或已过期,请重新登录" + "twoFactorPreAuthExpired": "预认证令牌无效或已过期,请重新登录", + "loginRetryLock": "登录失败次数过多,请{0}分钟后重试" } diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/login/AbstractPasswordLoginHandler.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/login/AbstractPasswordLoginHandler.java new file mode 100644 index 000000000..80a564163 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/login/AbstractPasswordLoginHandler.java @@ -0,0 +1,135 @@ +package cn.daxpay.open.platform.iam.auth.login; + +import cn.daxpay.open.platform.capability.auth.authentication.Authenticator; +import cn.daxpay.open.platform.capability.auth.code.AuthLoginTypeCode; +import cn.daxpay.open.platform.capability.auth.entity.AuthInfoResult; +import cn.daxpay.open.platform.capability.auth.entity.LoginAuthContext; +import cn.daxpay.open.platform.capability.auth.exception.LoginFailureException; +import cn.daxpay.open.platform.core.entity.UserDetail; +import cn.daxpay.open.platform.iam.auth.service.CaptchaService; +import cn.daxpay.open.platform.iam.auth.service.IamSecurityConfigService; +import cn.daxpay.open.platform.iam.auth.service.LoginRetryService; +import cn.daxpay.open.platform.iam.auth.service.PasswordDecryptService; +import cn.daxpay.open.platform.iam.exception.auth.UserNotFoundException; +import cn.daxpay.open.platform.iam.result.user.UserInfoResult; +import cn.daxpay.open.platform.iam.service.user.UserQueryService; +import cn.daxpay.open.platform.system.entity.config.platform.security.PlatformLoginSecurityConfig; +import cn.hutool.crypto.digest.BCrypt; +import jakarta.annotation.Nullable; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.Objects; + +/// # 账号密码登录基类 +/// +/// 承载密码登录的通用流程(用户定位、验证码校验、密码比对、登录重试状态注入)。 +/// 子类只需声明终端编码 [getClientCode], 由认证框架按"终端 + 登录方式"双键路由, +/// 从而消除多终端同登录方式(如平台端/商户端均为 password)的 findFirst 歧义与重复代码。 +/// +@Slf4j +@RequiredArgsConstructor +public abstract class AbstractPasswordLoginHandler implements Authenticator { + + protected static final String ACCOUNT_PARAMETER = "account"; + + protected static final String PASSWORD_PARAMETER = "password"; + + protected static final String CAPTCHA_KEY_PARAMETER = "captchaKey"; + + protected static final String CAPTCHA_CODE_PARAMETER = "captchaCode"; + + @Resource + protected UserQueryService userQueryService; + + protected final LoginRetryService loginRetryService; + + protected final CaptchaService captchaService; + + protected final IamSecurityConfigService iamSecurityConfigService; + + protected final PasswordDecryptService passwordDecryptService; + + /// 登录方式固定为账号密码 + @Override + public final String getLoginType() { + return AuthLoginTypeCode.PASSWORD; + } + + /// 认证 + @Override + public @NotNull AuthInfoResult attemptAuthentication(LoginAuthContext context) { + HttpServletRequest request = context.getRequest(); + String account = this.obtainAccount(request); + String password = this.obtainPassword(request); + String captchaKey = this.obtainCaptchaKey(request); + String captchaCode = this.obtainCaptchaCode(request); + // 从请求上下文获取终端编码,按终端+账号查询用户 + String clientCode = context.getClientCode(); + + UserInfoResult userInfoResult = this.loadUserByClientCodeAndAccount(clientCode, account); + UserDetail userDetail = userInfoResult.toUserDetail(); + + // 检查验证码(如果需要) + this.checkCaptcha(userDetail.getId(), captchaKey, captchaCode); + loginRetryService.checkBeforeLogin(userDetail); + + // 比对密码未通过 + if (!BCrypt.checkpw(password, userInfoResult.getPassword())) { + // 必须携带 userId, 否则 LoginRetryService.onLoginFailure 因 userId 为空直接跳过, 失败计数始终为 0 + throw new LoginFailureException(userDetail.getId(), userDetail.getAccount(), "error.auth.accountOrPasswordError"); + } + + // 设置密码状态到 UserDetail(超级管理员不设置密码状态限制) + if (!userDetail.isAdmin()) { + loginRetryService.setPasswordStatusToUserDetail(userDetail); + } + + return new AuthInfoResult().setId(userDetail.getId()).setUserDetail(userDetail); + } + + /// 检查验证码 + protected void checkCaptcha(Long userId, String captchaKey, String captchaCode) { + PlatformLoginSecurityConfig config = iamSecurityConfigService.getLoginSecurity(); + if (!Boolean.TRUE.equals(config.getCaptchaEnabled())) { + return; + } + int triggerAttempts = config.getCaptchaTriggerAttempts() == null ? 3 : config.getCaptchaTriggerAttempts(); + int errorCount = loginRetryService.getErrorCount(userId); + captchaService.checkOrValidateCaptcha(errorCount, triggerAttempts, captchaKey, captchaCode); + } + + /// 根据终端编码+账号加载用户(终端维度用户定位) + protected UserInfoResult loadUserByClientCodeAndAccount(String clientCode, String account) { + // 按终端+账号查询用户,跨终端同名账号不会命中 + UserInfoResult userInfoResult = userQueryService.findByClientCodeAndAccount(clientCode, account); + if (Objects.isNull(userInfoResult)) { + throw new UserNotFoundException(account); + } + return userInfoResult; + } + + @Nullable + protected String obtainPassword(HttpServletRequest request) { + String password = request.getParameter(PASSWORD_PARAMETER); + return passwordDecryptService.decryptPassword(password); + } + + @Nullable + protected String obtainAccount(HttpServletRequest request) { + return request.getParameter(ACCOUNT_PARAMETER); + } + + @Nullable + protected String obtainCaptchaKey(HttpServletRequest request) { + return request.getParameter(CAPTCHA_KEY_PARAMETER); + } + + @Nullable + protected String obtainCaptchaCode(HttpServletRequest request) { + return request.getParameter(CAPTCHA_CODE_PARAMETER); + } +} diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/login/PasswordLoginHandler.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/login/PasswordLoginHandler.java index ea9a66703..b113fdbd5 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/login/PasswordLoginHandler.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/login/PasswordLoginHandler.java @@ -1,140 +1,28 @@ package cn.daxpay.open.platform.iam.auth.login; -import cn.daxpay.open.platform.core.entity.UserDetail; +import cn.daxpay.open.platform.core.enums.client.ClientEnum; import cn.daxpay.open.platform.iam.auth.service.CaptchaService; import cn.daxpay.open.platform.iam.auth.service.IamSecurityConfigService; import cn.daxpay.open.platform.iam.auth.service.LoginRetryService; import cn.daxpay.open.platform.iam.auth.service.PasswordDecryptService; -import cn.daxpay.open.platform.iam.result.user.UserInfoResult; -import cn.daxpay.open.platform.iam.service.user.UserQueryService; -import cn.daxpay.open.platform.capability.auth.authentication.AbstractAuthentication; -import cn.daxpay.open.platform.capability.auth.code.AuthLoginTypeCode; -import cn.daxpay.open.platform.capability.auth.entity.AuthInfoResult; -import cn.daxpay.open.platform.capability.auth.entity.LoginAuthContext; -import cn.daxpay.open.platform.capability.auth.exception.LoginFailureException; -import cn.daxpay.open.platform.capability.auth.exception.UserNotFoundException; -import cn.daxpay.open.platform.system.entity.config.platform.security.PlatformLoginSecurityConfig; -import cn.hutool.crypto.digest.BCrypt; -import jakarta.annotation.Resource; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.constraints.NotNull; -import lombok.Getter; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import jakarta.annotation.Nullable; import org.springframework.stereotype.Component; -import java.util.Objects; - -/// # 账号密码登陆方式实现 +/// # 平台运营端账号密码登录 /// @Slf4j @Component -@RequiredArgsConstructor -@SuppressWarnings("FieldCanBeLocal") -public class PasswordLoginHandler implements AbstractAuthentication { +public class PasswordLoginHandler extends AbstractPasswordLoginHandler { - @Getter - private final String ACCOUNT_PARAMETER = "account"; + public PasswordLoginHandler(LoginRetryService loginRetryService, CaptchaService captchaService, + IamSecurityConfigService iamSecurityConfigService, + PasswordDecryptService passwordDecryptService) { + super(loginRetryService, captchaService, iamSecurityConfigService, passwordDecryptService); + } - @Getter - private final String PASSWORD_PARAMETER = "password"; - - @Getter - private final String CAPTCHA_KEY_PARAMETER = "captchaKey"; - - @Getter - private final String CAPTCHA_CODE_PARAMETER = "captchaCode"; - - @Resource - @Getter - private UserQueryService userQueryService; - - private final LoginRetryService loginRetryService; - - private final CaptchaService captchaService; - - private final IamSecurityConfigService iamSecurityConfigService; - - private final PasswordDecryptService passwordDecryptService; - - /// 获取终端编码 + /// 平台运营端终端编码 @Override - public String getLoginType() { - return AuthLoginTypeCode.PASSWORD; - } - - /// 认证 - @Override - public @NotNull AuthInfoResult attemptAuthentication(LoginAuthContext context) { - String account = this.obtainAccount(context.getRequest()); - String password = this.obtainPassword(context.getRequest()); - String captchaKey = this.obtainCaptchaKey(context.getRequest()); - String captchaCode = this.obtainCaptchaCode(context.getRequest()); - // 从请求上下文获取终端编码,按终端+账号查询用户 - String clientCode = context.getClientCode(); - - UserInfoResult userInfoResult = this.loadUserByClientCodeAndAccount(clientCode, account); - UserDetail userDetail = userInfoResult.toUserDetail(); - - // 检查验证码(如果需要) - this.checkCaptcha(userDetail.getId(), captchaKey, captchaCode); - loginRetryService.checkBeforeLogin(userDetail); - - // 比对密码未通过 - if (!BCrypt.checkpw(password, userInfoResult.getPassword())) { - // 非系统管理员进行错误处理, 包括记录错误次数和账号锁定等操作 - // 必须携带 userId, 否则 LoginRetryService.onLoginFailure 因 userId 为空直接跳过, 失败计数始终为 0 - throw new LoginFailureException(userDetail.getId(), userDetail.getAccount(), "账号或密码不正确"); - } - - // 设置密码状态到 UserDetail(超级管理员不设置密码状态限制) - if (!userDetail.isAdmin()) { - loginRetryService.setPasswordStatusToUserDetail(userDetail); - } - - return new AuthInfoResult().setId(userDetail.getId()).setUserDetail(userDetail); - } - - /// 检查验证码 - private void checkCaptcha(Long userId, String captchaKey, String captchaCode) { - PlatformLoginSecurityConfig config = iamSecurityConfigService.getLoginSecurity(); - if (!Boolean.TRUE.equals(config.getCaptchaEnabled())) { - return; - } - int triggerAttempts = config.getCaptchaTriggerAttempts() == null ? 3 : config.getCaptchaTriggerAttempts(); - int errorCount = loginRetryService.getErrorCount(userId); - captchaService.checkOrValidateCaptcha(errorCount, triggerAttempts, captchaKey, captchaCode); - } - - /// 根据终端编码+账号加载用户(终端维度用户定位) - public UserInfoResult loadUserByClientCodeAndAccount(String clientCode, String account) throws UserNotFoundException { - // 按终端+账号查询用户,跨终端同名账号不会命中 - UserInfoResult userInfoResult = userQueryService.findByClientCodeAndAccount(clientCode, account); - if (Objects.isNull(userInfoResult)) { - throw new UserNotFoundException(account); - } - return userInfoResult; - } - - @Nullable - protected String obtainPassword(HttpServletRequest request) { - String password = request.getParameter(this.PASSWORD_PARAMETER); - return passwordDecryptService.decryptPassword(password); - } - - @Nullable - protected String obtainAccount(HttpServletRequest request) { - return request.getParameter(this.ACCOUNT_PARAMETER); - } - - @Nullable - protected String obtainCaptchaKey(HttpServletRequest request) { - return request.getParameter(this.CAPTCHA_KEY_PARAMETER); - } - - @Nullable - protected String obtainCaptchaCode(HttpServletRequest request) { - return request.getParameter(this.CAPTCHA_CODE_PARAMETER); + public String getClientCode() { + return ClientEnum.ADMIN.getCode(); } } diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/CaptchaService.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/CaptchaService.java index 4d60d0ae5..18a9f6c53 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/CaptchaService.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/CaptchaService.java @@ -1,7 +1,7 @@ package cn.daxpay.open.platform.iam.auth.service; -import cn.daxpay.open.platform.capability.auth.exception.CaptchaErrorException; -import cn.daxpay.open.platform.capability.auth.exception.CaptchaRequiredException; +import cn.daxpay.open.platform.iam.exception.auth.CaptchaErrorException; +import cn.daxpay.open.platform.iam.exception.auth.CaptchaRequiredException; import cn.daxpay.open.platform.iam.result.captcha.CaptchaDataResult; import cn.hutool.captcha.CaptchaUtil; import cn.hutool.captcha.LineCaptcha; diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/LoginContentService.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/LoginContentService.java index 1165e5f23..93cd25a3e 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/LoginContentService.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/LoginContentService.java @@ -1,10 +1,10 @@ package cn.daxpay.open.platform.iam.auth.service; +import cn.daxpay.open.platform.capability.auth.authentication.Authenticator; import cn.daxpay.open.platform.core.enums.client.ClientEnum; +import cn.daxpay.open.platform.iam.exception.auth.ApplicationNotFoundException; import cn.daxpay.open.platform.iam.param.auth.LoginContentParam; import cn.daxpay.open.platform.iam.result.auth.LoginContentResult; -import cn.daxpay.open.platform.capability.auth.authentication.AbstractAuthentication; -import cn.daxpay.open.platform.capability.auth.exception.ApplicationNotFoundException; import cn.daxpay.open.platform.system.entity.config.platform.security.PlatformLoginSecurityConfig; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -17,7 +17,7 @@ import java.util.List; @RequiredArgsConstructor public class LoginContentService { - private final List abstractAuthentications; + private final List authenticators; private final IamSecurityConfigService iamSecurityConfigService; @@ -29,8 +29,8 @@ public class LoginContentService { } PlatformLoginSecurityConfig loginSecurity = iamSecurityConfigService.getLoginSecurity(); return new LoginContentResult() - .setLoginTypes(abstractAuthentications.stream() - .map(AbstractAuthentication::getLoginType) + .setLoginTypes(authenticators.stream() + .map(Authenticator::getLoginType) .distinct() .toList()) // 是否启用验证码触发(登录失败达阈值后要求输入验证码) @@ -39,4 +39,3 @@ public class LoginContentService { } } - diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/LoginRetryService.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/LoginRetryService.java index a22ad8b29..76c0b30e3 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/LoginRetryService.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/LoginRetryService.java @@ -32,7 +32,7 @@ public class LoginRetryService { /// 登录前检查 public void checkBeforeLogin(UserDetail userDetail) { if (!UserStatusEnum.NORMAL.getCode().equals(userDetail.getStatus())) { - throw new LoginFailureException(userDetail.getAccount(), "用户状态异常"); + throw new LoginFailureException(userDetail.getAccount(), "error.auth.userStatusError"); } LoginRetryPolicyConfig config = this.getPolicyConfig(); @@ -62,7 +62,7 @@ public class LoginRetryService { } long remainingMinutes = Math.max(1, java.time.Duration.between(now, lockTime).toMinutes() + 1); - throw new LoginFailureException(userDetail.getAccount(), "登录失败次数过多,请" + remainingMinutes + "分钟后重试"); + throw new LoginFailureException(userDetail.getAccount(), "error.auth.loginRetryLock", remainingMinutes); } /// 检查并重置失败计数 diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/SecondCheckService.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/SecondCheckService.java index 5c42f45d2..cc97aad3d 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/SecondCheckService.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/SecondCheckService.java @@ -8,7 +8,7 @@ import org.springframework.stereotype.Service; /// # 二次校验信息服务 /// /// 供登录页预知当前是否启用了双因素认证(平台级开关), 前端据此决定是否展示相关提示。 -/// 具体某用户是否需要二次验证, 在密码校验通过后由 [cn.daxpay.open.platform.iam.endpoint.TokenService] 判定。 +/// 具体某用户是否需要二次验证, 在密码校验通过后由 [TokenService] 判定。 /// @Service @RequiredArgsConstructor diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/SecurityConfigService.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/SecurityConfigService.java deleted file mode 100644 index 82d7b74c0..000000000 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/SecurityConfigService.java +++ /dev/null @@ -1,47 +0,0 @@ -package cn.daxpay.open.platform.iam.auth.service; - -import cn.daxpay.open.platform.system.entity.config.platform.security.PlatformLoginSecurityConfig; -import cn.daxpay.open.platform.system.entity.config.platform.security.PlatformPasswordPolicyConfig; -import cn.daxpay.open.platform.system.entity.config.platform.security.PlatformSessionManagementConfig; -import cn.daxpay.open.platform.system.enums.PlatformConfigTypeEnum; -import cn.daxpay.open.platform.system.service.config.SystemPlatformConfigService; -import cn.hutool.core.util.StrUtil; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -/// # 安全策略配置服务 -/// -/// 读取平台安全配置 -@Slf4j -@Service -@RequiredArgsConstructor -public class SecurityConfigService { - - private final SystemPlatformConfigService systemPlatformConfigService; - - /// 获取平台密码策略配置 - public PlatformPasswordPolicyConfig getPlatformPasswordPolicy() { - return systemPlatformConfigService.getOrCreateConfig( - PlatformConfigTypeEnum.SECURITY_PASSWORD_POLICY, - PlatformPasswordPolicyConfig.class, - new PlatformPasswordPolicyConfig()); - } - - /// 获取平台登录安全配置 - public PlatformLoginSecurityConfig getPlatformLoginSecurity() { - return systemPlatformConfigService.getOrCreateConfig( - PlatformConfigTypeEnum.SECURITY_LOGIN, - PlatformLoginSecurityConfig.class, - new PlatformLoginSecurityConfig()); - } - - /// 获取平台会话管理配置 - public PlatformSessionManagementConfig getPlatformSessionManagement() { - return systemPlatformConfigService.getOrCreateConfig( - PlatformConfigTypeEnum.SECURITY_SESSION, - PlatformSessionManagementConfig.class, - new PlatformSessionManagementConfig()); - } -} - diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/endpoint/TokenService.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/TokenService.java similarity index 76% rename from daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/endpoint/TokenService.java rename to daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/TokenService.java index ab24ac474..6abdc28b5 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/endpoint/TokenService.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/TokenService.java @@ -1,19 +1,21 @@ -package cn.daxpay.open.platform.iam.endpoint; +package cn.daxpay.open.platform.iam.auth.service; -import cn.daxpay.open.platform.core.code.CommonCode; -import cn.daxpay.open.platform.core.entity.UserDetail; -import cn.daxpay.open.platform.core.enums.client.ClientEnum; -import cn.daxpay.open.platform.capability.auth.authentication.AbstractAuthentication; -import cn.daxpay.open.platform.common.config.properties.PlatformStarterProperties; -import cn.daxpay.open.platform.capability.auth.entity.AuthInfoResult; -import cn.daxpay.open.platform.capability.auth.entity.LoginAuthContext; -import cn.daxpay.open.platform.capability.auth.exception.ApplicationNotFoundException; -import cn.daxpay.open.platform.capability.auth.exception.LoginFailureException; -import cn.daxpay.open.platform.capability.auth.exception.TwoFactorRequiredException; +import cn.daxpay.open.platform.capability.auth.authentication.AuthenticationChallengeException; +import cn.daxpay.open.platform.capability.auth.authentication.AuthenticationTemplate; +import cn.daxpay.open.platform.capability.auth.authentication.Authenticator; +import cn.daxpay.open.platform.capability.auth.authentication.PostAuthenticationChallenge; import cn.daxpay.open.platform.capability.auth.handler.LoginFailureHandler; import cn.daxpay.open.platform.capability.auth.handler.LoginSuccessHandler; import cn.daxpay.open.platform.capability.auth.util.SecurityUtil; +import cn.daxpay.open.platform.common.config.properties.PlatformStarterProperties; +import cn.daxpay.open.platform.core.code.CommonCode; +import cn.daxpay.open.platform.core.entity.UserDetail; +import cn.daxpay.open.platform.core.enums.client.ClientEnum; +import cn.daxpay.open.platform.capability.auth.entity.AuthInfoResult; +import cn.daxpay.open.platform.capability.auth.entity.LoginAuthContext; +import cn.daxpay.open.platform.capability.auth.exception.LoginFailureException; import cn.daxpay.open.platform.iam.auth.service.twofactor.TwoFactorPreAuthService; +import cn.daxpay.open.platform.iam.exception.auth.ApplicationNotFoundException; import cn.daxpay.open.platform.iam.result.user.UserInfoResult; import cn.daxpay.open.platform.iam.service.twofactor.UserTwoFactorService; import cn.daxpay.open.platform.iam.service.user.UserQueryService; @@ -38,7 +40,11 @@ public class TokenService { private final PlatformStarterProperties platformStarterProperties; - private final List abstractAuthentications; + private final List authenticators; + + private final AuthenticationTemplate authenticationTemplate; + + private final List postAuthenticationChallenges; private final List loginSuccessHandlers; @@ -61,22 +67,17 @@ public class TokenService { .setAuthProperties(platformStarterProperties.getAuth()) .setAuthLoginType(loginType) .setClientCode(clientCode); - // 校验登录终端 - this.validateClientCode(loginAuthContext); + // 校验该终端是否支持此种登录方式(按 clientCode + loginType 双键匹配) + this.validateClient(loginAuthContext); // 认证并获取结果 authInfoResult = this.authentication(loginAuthContext); - // 双因素认证: 平台已开启且用户已绑定则颁发预认证令牌, 抛出挑战异常(不计入登录失败) - Long userId = toLong(authInfoResult.getId()); - if (userId != null && userTwoFactorService.isTwoFactorRequired(userId)) { - String preAuthToken = twoFactorPreAuthService.create(userId, clientCode, loginType); - String account = authInfoResult.getUserDetail() == null ? null : authInfoResult.getUserDetail().getAccount(); - throw new TwoFactorRequiredException(userId, account, preAuthToken); - } + // 认证后挑战(双因素/设备验证等), 任一需要则抛挑战异常(不计入登录失败) + this.applyChallenges(loginAuthContext, authInfoResult); // 登录处理 this.doSaLogin(authInfoResult, clientCode, loginType); } - catch (TwoFactorRequiredException e) { - // 双因素认证挑战: 不触发失败回调, 交由全局处理器返回预认证令牌 + catch (AuthenticationChallengeException e) { + // 挑战流程: 不触发失败回调, 交由全局处理器返回挑战结果 throw e; } catch (LoginFailureException e) { @@ -124,6 +125,15 @@ public class TokenService { return StpUtil.getTokenValue(); } + /// 认证后挑战: 任一挑战需要则抛出挑战异常(不计入登录失败) + private void applyChallenges(LoginAuthContext context, AuthInfoResult authInfoResult) { + for (PostAuthenticationChallenge challenge : postAuthenticationChallenges) { + if (challenge.required(context, authInfoResult)) { + throw challenge.createChallenge(context, authInfoResult); + } + } + } + /// 成功处理 private void loginSuccessHandler(HttpServletRequest request, HttpServletResponse response, AuthInfoResult authInfoResult) { @@ -150,7 +160,7 @@ public class TokenService { } } - /// 获取终端编码 + /// 获取并校验终端编码 private String getClientCode(HttpServletRequest request) { String clientCode = SecurityUtil.getClient(request); ClientEnum.findByCode(clientCode) @@ -158,24 +168,26 @@ public class TokenService { return clientCode; } - /// 校验该终端是否支持此种登录方式 - private void validateClientCode(LoginAuthContext loginAuthContext) { + /// 校验该终端是否支持此种登录方式(双键匹配) + private void validateClient(LoginAuthContext loginAuthContext) { + String clientCode = loginAuthContext.getClientCode(); String loginType = loginAuthContext.getAuthLoginType(); - boolean supported = abstractAuthentications.stream() - .anyMatch(authentication -> authentication.adaptation(loginType)); + boolean supported = authenticators.stream() + .anyMatch(auth -> auth.adaptation(clientCode, loginType)); if (!supported) { // 认证: 当前终端不支持该登录方式 throw new LoginFailureException("error.auth.loginMethodNotSupported"); } } - /// 认证 + /// 认证(双键路由到唯一认证器, 由模板执行流程) private @NotNull AuthInfoResult authentication(LoginAuthContext context) { + String clientCode = context.getClientCode(); String loginType = context.getAuthLoginType(); - return abstractAuthentications.stream() - .filter(o -> o.adaptation(loginType)) + return authenticators.stream() + .filter(auth -> auth.adaptation(clientCode, loginType)) .findFirst() - .map(o -> o.authentication(context)) + .map(auth -> authenticationTemplate.authenticate(auth, context)) // 认证: 未找到对应的登录认证器 .orElseThrow(() -> new LoginFailureException("error.auth.loginAuthenticatorNotFound")); } @@ -194,32 +206,9 @@ public class TokenService { session.set(CommonCode.USER, userDetail); } - /// 认证结果 id(Object) 转 Long, 无法转换返回 null - private Long toLong(Object id) { - if (id == null) { - return null; - } - if (id instanceof Long l) { - return l; - } - if (id instanceof Number n) { - return n.longValue(); - } - try { - return Long.valueOf(id.toString()); - } - catch (NumberFormatException e) { - return null; - } - } - /// 退出 public void logout() { StpUtil.logout(); } } - - - - diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/twofactor/TwoFactorAuthenticationChallenge.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/twofactor/TwoFactorAuthenticationChallenge.java new file mode 100644 index 000000000..f0bbb8047 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/twofactor/TwoFactorAuthenticationChallenge.java @@ -0,0 +1,60 @@ +package cn.daxpay.open.platform.iam.auth.service.twofactor; + +import cn.daxpay.open.platform.capability.auth.authentication.AuthenticationChallengeException; +import cn.daxpay.open.platform.capability.auth.authentication.PostAuthenticationChallenge; +import cn.daxpay.open.platform.capability.auth.entity.AuthInfoResult; +import cn.daxpay.open.platform.capability.auth.entity.LoginAuthContext; +import cn.daxpay.open.platform.iam.exception.auth.TwoFactorRequiredException; +import cn.daxpay.open.platform.iam.service.twofactor.UserTwoFactorService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/// # 双因素认证挑战 +/// +/// 认证通过后, 若用户已启用 TOTP 双因素认证, 颁发一次性预认证令牌并返回挑战异常。 +/// 令牌由 [TwoFactorPreAuthService] 存入 Redis, 5 分钟过期, 二次验证通过后单次消费。 +/// +@Slf4j +@Component +@RequiredArgsConstructor +public class TwoFactorAuthenticationChallenge implements PostAuthenticationChallenge { + + private final UserTwoFactorService userTwoFactorService; + + private final TwoFactorPreAuthService twoFactorPreAuthService; + + @Override + public boolean required(LoginAuthContext context, AuthInfoResult authInfoResult) { + Long userId = toLong(authInfoResult.getId()); + // 平台已开启且用户已绑定 2FA 时需要挑战 + return userId != null && userTwoFactorService.isTwoFactorRequired(userId); + } + + @Override + public AuthenticationChallengeException createChallenge(LoginAuthContext context, AuthInfoResult authInfoResult) { + Long userId = toLong(authInfoResult.getId()); + String account = authInfoResult.getUserDetail() == null ? null : authInfoResult.getUserDetail().getAccount(); + String preAuthToken = twoFactorPreAuthService.create(userId, context.getClientCode(), context.getAuthLoginType()); + return new TwoFactorRequiredException(userId, account, preAuthToken); + } + + /// 认证结果 id(Object) 转 Long, 无法转换返回 null + private Long toLong(Object id) { + if (id == null) { + return null; + } + if (id instanceof Long l) { + return l; + } + if (id instanceof Number n) { + return n.longValue(); + } + try { + return Long.valueOf(id.toString()); + } + catch (NumberFormatException e) { + return null; + } + } +} diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/endpoint/TokenEndpoint.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/endpoint/TokenEndpoint.java index 977893a96..09d880c88 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/endpoint/TokenEndpoint.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/endpoint/TokenEndpoint.java @@ -11,6 +11,7 @@ import cn.daxpay.open.platform.iam.param.auth.LoginContentParam; import cn.daxpay.open.platform.iam.param.auth.SecondVerifyParam; import cn.daxpay.open.platform.iam.result.auth.LoginContentResult; import cn.daxpay.open.platform.iam.result.auth.SecondCheckResult; +import cn.daxpay.open.platform.iam.auth.service.TokenService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; diff --git a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/ApplicationNotFoundException.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth/ApplicationNotFoundException.java similarity index 77% rename from daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/ApplicationNotFoundException.java rename to daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth/ApplicationNotFoundException.java index 4cb88ad38..79cfe5eb3 100644 --- a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/ApplicationNotFoundException.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth/ApplicationNotFoundException.java @@ -1,4 +1,4 @@ -package cn.daxpay.open.platform.capability.auth.exception; +package cn.daxpay.open.platform.iam.exception.auth; import cn.daxpay.open.platform.core.exception.BizInfoException; @@ -7,7 +7,7 @@ import cn.daxpay.open.platform.core.exception.BizInfoException; public class ApplicationNotFoundException extends BizInfoException { public ApplicationNotFoundException() { - super("未找到对应的应用"); + super("error.auth.applicationNotFound"); } diff --git a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/CaptchaErrorException.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth/CaptchaErrorException.java similarity index 68% rename from daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/CaptchaErrorException.java rename to daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth/CaptchaErrorException.java index 59aa43df4..a049f4ff8 100644 --- a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/CaptchaErrorException.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth/CaptchaErrorException.java @@ -1,4 +1,4 @@ -package cn.daxpay.open.platform.capability.auth.exception; +package cn.daxpay.open.platform.iam.exception.auth; import cn.daxpay.open.platform.core.exception.BizInfoException; @@ -7,8 +7,7 @@ import cn.daxpay.open.platform.core.exception.BizInfoException; public class CaptchaErrorException extends BizInfoException { public CaptchaErrorException() { - super(40002, "验证码错误"); - initMessageKey("error.auth.auth.captchaError"); + super(40002, "error.auth.captchaError"); } public CaptchaErrorException(int code, String messageKey, Object... args) { diff --git a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/CaptchaRequiredException.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth/CaptchaRequiredException.java similarity index 82% rename from daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/CaptchaRequiredException.java rename to daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth/CaptchaRequiredException.java index 98cdc5a63..fbe6e6bf2 100644 --- a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/CaptchaRequiredException.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth/CaptchaRequiredException.java @@ -1,4 +1,4 @@ -package cn.daxpay.open.platform.capability.auth.exception; +package cn.daxpay.open.platform.iam.exception.auth; import cn.daxpay.open.platform.core.exception.BizInfoException; @@ -9,7 +9,7 @@ public class CaptchaRequiredException extends BizInfoException { private final String captchaKey; public CaptchaRequiredException(String captchaKey) { - super(40001, "error.auth.auth.captchaRequired"); + super(40001, "error.auth.captchaRequired"); this.captchaKey = captchaKey; } diff --git a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/PasswordExpiredAccessException.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth/PasswordExpiredAccessException.java similarity index 56% rename from daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/PasswordExpiredAccessException.java rename to daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth/PasswordExpiredAccessException.java index 15b2f15e2..2a1fa02b9 100644 --- a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/PasswordExpiredAccessException.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth/PasswordExpiredAccessException.java @@ -1,4 +1,4 @@ -package cn.daxpay.open.platform.capability.auth.exception; +package cn.daxpay.open.platform.iam.exception.auth; import cn.daxpay.open.platform.core.exception.BizInfoException; @@ -7,12 +7,7 @@ import cn.daxpay.open.platform.core.exception.BizInfoException; public class PasswordExpiredAccessException extends BizInfoException { public PasswordExpiredAccessException() { - super(40301, "密码已过期,请先修改密码"); - initMessageKey("error.auth.auth.passwordExpired"); - } - - public PasswordExpiredAccessException(String message) { - super(40301, message); + super(40301, "error.auth.passwordExpired"); } public PasswordExpiredAccessException(int code, String messageKey, Object... args) { diff --git a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/TwoFactorRequiredException.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth/TwoFactorRequiredException.java similarity index 78% rename from daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/TwoFactorRequiredException.java rename to daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth/TwoFactorRequiredException.java index 7347719c5..3151b4da9 100644 --- a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/TwoFactorRequiredException.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth/TwoFactorRequiredException.java @@ -1,5 +1,6 @@ -package cn.daxpay.open.platform.capability.auth.exception; +package cn.daxpay.open.platform.iam.exception.auth; +import cn.daxpay.open.platform.capability.auth.authentication.AuthenticationChallengeException; import lombok.Getter; /// # 需要双因素认证异常 @@ -8,7 +9,7 @@ import lombok.Getter; /// 该异常不属于登录失败, 不应触发失败计数与失败日志, 由全局处理器返回挑战结果给前端。 /// @Getter -public class TwoFactorRequiredException extends LoginFailureException { +public class TwoFactorRequiredException extends AuthenticationChallengeException { /// 需要双因素认证的响应码(前端据此识别并切换到二次验证界面) public static final int CODE = 40101; diff --git a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/UserNotFoundException.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth/UserNotFoundException.java similarity index 63% rename from daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/UserNotFoundException.java rename to daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth/UserNotFoundException.java index 7069b9797..2880fa5b0 100644 --- a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/exception/UserNotFoundException.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/exception/auth/UserNotFoundException.java @@ -1,4 +1,6 @@ -package cn.daxpay.open.platform.capability.auth.exception; +package cn.daxpay.open.platform.iam.exception.auth; + +import cn.daxpay.open.platform.capability.auth.exception.LoginFailureException; /// # 用户未找到异常 /// @@ -6,12 +8,12 @@ public class UserNotFoundException extends LoginFailureException { public UserNotFoundException(String account) { super(account, "用户未找到"); - initMessageKey("error.auth.auth.userNotFound"); + initMessageKey("error.auth.userNotFound"); } public UserNotFoundException() { super("用户未找到"); - initMessageKey("error.auth.auth.userNotFound"); + initMessageKey("error.auth.userNotFound"); } diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/handler/PasswordStatusCheck.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/handler/PasswordStatusCheck.java index 5395cdeb0..3cdc88b8f 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/handler/PasswordStatusCheck.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/handler/PasswordStatusCheck.java @@ -1,19 +1,22 @@ package cn.daxpay.open.platform.iam.handler; -import cn.daxpay.open.platform.capability.auth.exception.PasswordExpiredAccessException; -import cn.daxpay.open.platform.capability.auth.service.RouterCheck; +import cn.daxpay.open.platform.capability.auth.service.AccessPolicy; import cn.daxpay.open.platform.capability.auth.util.SecurityUtil; -import cn.daxpay.open.platform.core.entity.UserDetail; import cn.daxpay.open.platform.common.spring.util.WebServletUtil; +import cn.daxpay.open.platform.core.entity.UserDetail; +import cn.daxpay.open.platform.iam.exception.auth.PasswordExpiredAccessException; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.stereotype.Component; import java.util.List; -/// # 密码状态检查 +/// # 密码状态访问策略 +/// +/// 检查已登录用户密码是否过期或是否为初始密码,若是则限制接口访问(仅放行改密等白名单路径)。 +/// 实现 [AccessPolicy] (而非 RouterCheck), 允许抛 [PasswordExpiredAccessException] 阻断请求。 /// -/// 检查用户密码是否过期或是否为初始密码,如果是则限制接口访问 @Component -public class PasswordStatusCheck implements RouterCheck { +public class PasswordStatusCheck implements AccessPolicy { private static final List ALLOWED_PATHS = List.of( "/user/auth/update-password", @@ -23,34 +26,21 @@ public class PasswordStatusCheck implements RouterCheck { ); @Override - public int sortNo() { - return 100; - } - - @Override - public boolean check(Object handler) { - if (SecurityUtil.notLogin()) { - return false; - } - - UserDetail user = SecurityUtil.getUser(); - + public void check(HttpServletRequest request, UserDetail userDetail) { // 超级管理员跳过密码状态检查 - if (user.isAdmin()) { - return false; + if (userDetail.isAdmin()) { + return; } - - if (!user.needChangePassword()) { - return false; + if (!userDetail.needChangePassword()) { + return; } - String path = WebServletUtil.getPath(); for (String allowedPath : ALLOWED_PATHS) { if (path.startsWith(allowedPath)) { - return true; + return; } } - + // 密码已过期或为初始密码, 强制改密 throw new PasswordExpiredAccessException(); } } diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/handler/exception/IamAuthExceptionHandler.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/handler/exception/IamAuthExceptionHandler.java new file mode 100644 index 000000000..2eedb72e3 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/handler/exception/IamAuthExceptionHandler.java @@ -0,0 +1,32 @@ +package cn.daxpay.open.platform.iam.handler.exception; + +import cn.daxpay.open.platform.common.i18n.util.I18nUtil; +import cn.daxpay.open.platform.core.rest.Res; +import cn.daxpay.open.platform.core.rest.result.Result; +import cn.daxpay.open.platform.iam.exception.auth.TwoFactorRequiredException; +import cn.daxpay.open.platform.iam.result.auth.TwoFactorChallengeResult; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/// # IAM 认证异常处理 +/// +/// 集中处理双因素认证挑战等 IAM 认证流程异常, 返回前端可识别的结构化结果。 +/// 2FA 属 IAM 业务, 其异常处理内聚于本模块, 避免下层 service-system 反向依赖 service-iam。 +/// +@Slf4j +@Order(Ordered.HIGHEST_PRECEDENCE) +@RestControllerAdvice +public class IamAuthExceptionHandler { + + /// 双因素认证挑战: 密码通过但需二次验证, 返回预认证令牌, 不计入登录失败 + @ExceptionHandler(TwoFactorRequiredException.class) + public Result handleTwoFactorRequired(TwoFactorRequiredException ex) { + log.info(ex.getMessage()); + String message = I18nUtil.get(ex.getMessageKey(), ex.getArgs()); + return Res.response(TwoFactorRequiredException.CODE, message, new TwoFactorChallengeResult(ex.getPreAuthToken())); + } + +} diff --git a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/entity/TwoFactorChallengeResult.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/result/auth/TwoFactorChallengeResult.java similarity index 93% rename from daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/entity/TwoFactorChallengeResult.java rename to daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/result/auth/TwoFactorChallengeResult.java index 90924b6f3..f4d0ff8e5 100644 --- a/daxpay-platform/daxpay-platform-capability/capability-auth/src/main/java/cn/daxpay/open/platform/capability/auth/entity/TwoFactorChallengeResult.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/result/auth/TwoFactorChallengeResult.java @@ -1,4 +1,4 @@ -package cn.daxpay.open.platform.capability.auth.entity; +package cn.daxpay.open.platform.iam.result.auth; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/user/UserQueryService.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/user/UserQueryService.java index 3122533b0..13dd0af0f 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/user/UserQueryService.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/user/UserQueryService.java @@ -3,7 +3,7 @@ package cn.daxpay.open.platform.iam.service.user; import cn.daxpay.open.platform.iam.dao.user.UserInfoManager; import cn.daxpay.open.platform.iam.entity.user.UserInfo; import cn.daxpay.open.platform.iam.result.user.UserInfoResult; -import cn.daxpay.open.platform.capability.auth.exception.UserNotFoundException; +import cn.daxpay.open.platform.iam.exception.auth.UserNotFoundException; import cn.hutool.core.util.StrUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/handler/exception/RestExceptionHandler.java b/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/handler/exception/RestExceptionHandler.java index fdca73836..fa560f097 100644 --- a/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/handler/exception/RestExceptionHandler.java +++ b/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/handler/exception/RestExceptionHandler.java @@ -10,8 +10,6 @@ import cn.daxpay.open.platform.core.exception.BizInfoException; import cn.daxpay.open.platform.core.exception.BizWarnException; import cn.daxpay.open.platform.core.rest.Res; import cn.daxpay.open.platform.core.rest.result.Result; -import cn.daxpay.open.platform.capability.auth.entity.TwoFactorChallengeResult; -import cn.daxpay.open.platform.capability.auth.exception.TwoFactorRequiredException; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; @@ -84,18 +82,6 @@ public class RestExceptionHandler { return Res.response(ex.getCode(), message, MDC.get(CommonCode.TRACE_ID)); } - /// 双因素认证挑战: 密码通过但需二次验证, 返回预认证令牌, 不计入登录失败 - @ExceptionHandler(TwoFactorRequiredException.class) - public Object handleTwoFactorRequiredException(TwoFactorRequiredException ex, HttpServletResponse response) { - if (isSseStream(response)) { - logSse(ex); - return ResponseEntity.ok().build(); - } - log.info(ex.getMessage()); - String message = getMessage(ex); - return Res.response(TwoFactorRequiredException.CODE, message, new TwoFactorChallengeResult(ex.getPreAuthToken())); - } - /// 警告业务异常, 如果量多需要关注 @ExceptionHandler(BizWarnException.class) public Object handleBizWarnException(BizWarnException ex, HttpServletResponse response) {