refactor(auth): 重构 capability-auth 与 service-iam 模块边界, 修复 i18n 不规范

- 认证 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 构造器
This commit is contained in:
DaxPay Dev
2026-06-26 19:03:59 +08:00
parent d1c1b008d3
commit f09b8a4d6c
37 changed files with 501 additions and 545 deletions

View File

@@ -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();
}
}

View File

@@ -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<UserInfoStatusCheck> 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;
}
}

View File

@@ -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);
}
}

View File

@@ -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<UserInfoStatusCheck> 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;
}
}

View File

@@ -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) {
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -14,7 +14,7 @@ public class NotLoginException extends BizException {
public NotLoginException() {
super(AUTHENTICATION_FAIL, "用户未登录");
initMessageKey("error.auth.auth.notLogin");
initMessageKey("error.auth.notLogin");
}

View File

@@ -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);

View File

@@ -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 日志并抛出 RouterCheckException403 无权限)
/// - 请求进入时:
/// 1. 已登录用户先依次执行 [AccessPolicy] (如密码过期强制改密), 不通过即抛异常阻断;
/// 2. 再遍历 RouterCheck 列表, 任意一个返回 true 即调用 SaRouter.stop() 放行。
/// - RouterCheck 全部未命中:
/// - 未登录 → SecurityUtil.getUserId() 抛 NotLoginException
/// - 已登录 → 记录 WARN 日志并抛出 RouterCheckException403 无权限)
/// </li>
///
/// 【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<RouterCheck> routerChecks;
private final List<AccessPolicy> 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 {
}
}

View File

@@ -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);
}

View File

@@ -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"
}

View File

@@ -1,4 +1,5 @@
{
"accountOrPasswordError": "账号或密码不正确",
"notLogin": "用户未登录",
"passwordExpired": "密码已过期,请先修改密码",
"captchaError": "验证码错误",
@@ -18,5 +19,6 @@
"userDisabled": "该用户已被禁用",
"twoFactorRequired": "需要双因素认证",
"twoFactorCodeError": "动态码或备用码错误",
"twoFactorPreAuthExpired": "预认证令牌无效或已过期,请重新登录"
"twoFactorPreAuthExpired": "预认证令牌无效或已过期,请重新登录",
"loginRetryLock": "登录失败次数过多,请{0}分钟后重试"
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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;

View File

@@ -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<AbstractAuthentication> abstractAuthentications;
private final List<Authenticator> 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 {
}
}

View File

@@ -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);
}
/// 检查并重置失败计数

View File

@@ -8,7 +8,7 @@ import org.springframework.stereotype.Service;
/// # 二次校验信息服务
///
/// 供登录页预知当前是否启用了双因素认证(平台级开关), 前端据此决定是否展示相关提示。
/// 具体某用户是否需要二次验证, 在密码校验通过后由 [cn.daxpay.open.platform.iam.endpoint.TokenService] 判定。
/// 具体某用户是否需要二次验证, 在密码校验通过后由 [TokenService] 判定。
///
@Service
@RequiredArgsConstructor

View File

@@ -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());
}
}

View File

@@ -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<AbstractAuthentication> abstractAuthentications;
private final List<Authenticator> authenticators;
private final AuthenticationTemplate authenticationTemplate;
private final List<PostAuthenticationChallenge> postAuthenticationChallenges;
private final List<LoginSuccessHandler> 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();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -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");
}

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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");
}

View File

@@ -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<String> 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();
}
}

View File

@@ -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<TwoFactorChallengeResult> 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()));
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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) {