diff --git a/daxpay-payment/daxpay-payment-common/src/main/java/cn/daxpay/open/payment/common/handler/DaxUserInfoStatusCheck.java b/daxpay-payment/daxpay-payment-common/src/main/java/cn/daxpay/open/payment/common/handler/DaxUserInfoStatusCheck.java index 2507e21b3..ab6904087 100644 --- a/daxpay-payment/daxpay-payment-common/src/main/java/cn/daxpay/open/payment/common/handler/DaxUserInfoStatusCheck.java +++ b/daxpay-payment/daxpay-payment-common/src/main/java/cn/daxpay/open/payment/common/handler/DaxUserInfoStatusCheck.java @@ -1,5 +1,6 @@ package cn.daxpay.open.payment.common.handler; +import cn.daxpay.open.platform.core.enums.client.ClientEnum; import cn.daxpay.open.platform.core.enums.merchant.MerchantStatusEnum; import cn.daxpay.open.platform.iam.service.client.ClientCodeService; import cn.daxpay.open.platform.capability.auth.authentication.UserInfoStatusCheck; @@ -9,6 +10,7 @@ import cn.daxpay.open.platform.capability.auth.exception.LoginFailureException; import cn.daxpay.open.platform.core.code.CommonCode; import cn.daxpay.open.payment.common.service.query.MerchantAccessQueryService; import cn.daxpay.open.payment.merchant.service.user.MerchantUserService; +import cn.hutool.core.util.StrUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -18,6 +20,8 @@ import java.util.Optional; /// # DaxPay登录验证处理(开源版) /// +/// 优先使用登录上下文中的 clientCode(密码/社交参数), 避免仅依赖请求头导致旁路漏检. +/// @Slf4j @Component @RequiredArgsConstructor @@ -32,10 +36,10 @@ public class DaxUserInfoStatusCheck implements UserInfoStatusCheck { /// @param context 登录认证上下文 @Override public void check(AuthInfoResult authInfoResult, LoginAuthContext context) { - // 判断终端 Long userId = authInfoResult.getUserDetail().getId(); + String clientCode = resolveClientCode(context); // 商户端 - if (Objects.equals(clientCodeService.getClientCode(), cn.daxpay.open.platform.core.enums.client.ClientEnum.MERCHANT.getCode())) { + if (Objects.equals(clientCode, ClientEnum.MERCHANT.getCode())) { String merchantNo = Optional.ofNullable(merchantUserService.findMchNoByUserId(userId)) // 登录: 您没有商户端的登录权限 .orElseThrow(() -> new LoginFailureException(CommonCode.FAIL_CODE, "error.payment.login.noMerchantPerm")); @@ -44,15 +48,25 @@ public class DaxUserInfoStatusCheck implements UserInfoStatusCheck { .orElseThrow(() -> new LoginFailureException(CommonCode.FAIL_CODE, "error.payment.login.noMerchantPerm")); if (Objects.equals(merchant.getStatus(), MerchantStatusEnum.DISABLED.getCode())) { // 登录: 该商户已禁用 - throw new LoginFailureException(CommonCode.FAIL_CODE, "error.payment.login.mchDisabled"); + throw new LoginFailureException(CommonCode.FAIL_CODE, "error.payment.login.mchDisabled"); } - } else { - // 运营端 + return; + } + // 运营端(及其他非商户端): 商户用户不得登录运营身份域 + if (Objects.equals(clientCode, ClientEnum.ADMIN.getCode())) { String merchant = merchantUserService.findMchNoByUserId(userId); if (merchant != null) { // 登录: 您没有运营端的权限,请使用商户端登录 - throw new LoginFailureException(CommonCode.FAIL_CODE, "error.payment.login.noAdminPermUseMerchant"); + throw new LoginFailureException(CommonCode.FAIL_CODE, "error.payment.login.noAdminPermUseMerchant"); } } } + + /// 解析当前登录终端: 优先上下文, 其次请求头 + private String resolveClientCode(LoginAuthContext context) { + if (context != null && StrUtil.isNotBlank(context.getClientCode())) { + return context.getClientCode(); + } + return clientCodeService.getClientCode(); + } } diff --git a/daxpay-platform/daxpay-platform-capability/capability-social/src/main/resources/i18n/en-US/error/social.json b/daxpay-platform/daxpay-platform-capability/capability-social/src/main/resources/i18n/en-US/error/social.json index d2116ddae..63ba05ea6 100644 --- a/daxpay-platform/daxpay-platform-capability/capability-social/src/main/resources/i18n/en-US/error/social.json +++ b/daxpay-platform/daxpay-platform-capability/capability-social/src/main/resources/i18n/en-US/error/social.json @@ -1,9 +1,13 @@ { "configNotExist": "Platform configuration does not exist or is not enabled", "unsupportedSource": "Unsupported platform", + "unsupportedClient": "Social login only supports admin and merchant clients", "configDuplicated": "The platform configuration already exists", "notConfigured": "The platform is not configured and cannot be toggled", "authCodeBlank": "Authorization code cannot be empty", + "endpointNotConfigured": "Frontend base URL is missing in endpoint configuration, cannot build callback URL", + "adminEndpointNotConfigured": "Please set Admin Base URL (adminBaseUrl) in endpoint configuration first", + "merchantEndpointNotConfigured": "Please set Merchant Base URL (merchantBaseUrl) in endpoint configuration first; merchant social login needs it for OAuth callbacks", "alipayNotConfigured": "Platform-level Alipay configuration is incomplete, please configure it in 'Third-party Platform Management' first", "wechatMpNotConfigured": "Platform-level WeChat Official Account configuration is incomplete, please configure it in 'Third-party Platform Management' first", "douyinH5NotConfigured": "Platform-level Douyin H5 app configuration is incomplete, please configure it in 'Third-party Platform Management' first", @@ -12,6 +16,8 @@ }, "bind": { "alreadyBoundByOther": "The third-party account has been bound by another user", - "alreadyBoundPlatform": "You have already bound this platform, please unbind first" + "alreadyBoundPlatform": "You have already bound this platform, please unbind first", + "clientMismatch": "Current user does not belong to this client, binding is not allowed" } } + diff --git a/daxpay-platform/daxpay-platform-capability/capability-social/src/main/resources/i18n/zh-CN/error/social.json b/daxpay-platform/daxpay-platform-capability/capability-social/src/main/resources/i18n/zh-CN/error/social.json index 91ae5bbc4..3f7695457 100644 --- a/daxpay-platform/daxpay-platform-capability/capability-social/src/main/resources/i18n/zh-CN/error/social.json +++ b/daxpay-platform/daxpay-platform-capability/capability-social/src/main/resources/i18n/zh-CN/error/social.json @@ -1,9 +1,13 @@ { "configNotExist": "平台配置不存在或未启用", "unsupportedSource": "不支持的平台", + "unsupportedClient": "社交登录仅支持运营端(admin)与商户端(merchant)", "configDuplicated": "该平台配置已存在", "notConfigured": "平台尚未配置,无法启停", "authCodeBlank": "授权码不能为空", + "endpointNotConfigured": "端点配置中缺少对应前端访问地址,无法生成回调 URL", + "adminEndpointNotConfigured": "请先在端点配置中设置「管理端访问地址」(adminBaseUrl)", + "merchantEndpointNotConfigured": "请先在端点配置中设置「商户端访问地址」(merchantBaseUrl),商户社交登录依赖该地址生成回调", "alipayNotConfigured": "平台级支付宝配置不完整,请先在「三方平台管理」中配置", "wechatMpNotConfigured": "平台级微信公众号配置不完整,请先在「三方平台管理」中配置", "douyinH5NotConfigured": "平台级抖音 H5 应用配置不完整,请先在「三方平台管理」中配置", @@ -12,6 +16,8 @@ }, "bind": { "alreadyBoundByOther": "该第三方账号已被其他用户绑定", - "alreadyBoundPlatform": "您已绑定该平台,请先解绑后再绑定新账号" + "alreadyBoundPlatform": "您已绑定该平台,请先解绑后再绑定新账号", + "clientMismatch": "当前登录用户不属于该终端,无法完成绑定" } } + 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 ad198d8b2..888be2fbd 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 @@ -10,6 +10,7 @@ "applicationNotEnable": "Application has been disabled", "clientNotFound": "Client not found", "clientNotEnable": "Client method has been disabled", + "clientMismatch": "The third-party account is bound to a user that does not belong to this login client", "userStatusError": "User status is abnormal", "privateKeyNotConfigured": "Platform private key is not configured, cannot decrypt password", "passwordDecryptFailed": "Password decryption failed, please check the password transmission method", 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 bb0f58af8..1d7fb2145 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 @@ -10,6 +10,7 @@ "applicationNotEnable": "指定应用已被停用", "clientNotFound": "未找到对应的终端", "clientNotEnable": "指定终端方式已被停用", + "clientMismatch": "该第三方账号绑定的用户不属于当前登录终端", "userStatusError": "用户状态异常", "privateKeyNotConfigured": "平台私钥未配置,无法解密密码", "passwordDecryptFailed": "密码解密失败,请检查密码传输方式", diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/TokenService.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/TokenService.java index e431c19d2..9ecce5acf 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/TokenService.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/TokenService.java @@ -62,7 +62,6 @@ public class TokenService { /// 登录 public String login(HttpServletRequest request, HttpServletResponse response) { - AuthInfoResult authInfoResult; String clientCode = this.getClientCode(request); String loginType = SecurityUtil.getLoginType(request); try { @@ -74,11 +73,9 @@ public class TokenService { // 校验该终端是否支持此种登录方式(按 clientCode + loginType 双键匹配) this.validateClient(loginAuthContext); // 认证并获取结果 - authInfoResult = this.authentication(loginAuthContext); - // 认证后挑战(双因素/设备验证等), 任一需要则抛挑战异常(不计入登录失败) - this.applyChallenges(loginAuthContext, authInfoResult); - // 登录处理 - this.doSaLogin(authInfoResult, clientCode, loginType); + AuthInfoResult authInfoResult = this.authentication(loginAuthContext); + // 挑战 → 建会话 → 成功回调(与社交登录共用) + return this.completeAuthenticatedLogin(authInfoResult, loginAuthContext); } catch (AuthenticationChallengeException e) { // 挑战流程: 不触发失败回调, 交由全局处理器返回挑战结果 @@ -89,8 +86,19 @@ public class TokenService { this.loginFailureHandler(request, response, e); throw e; } + } + + /// 认证已通过后的统一收尾: 2FA 等挑战 → doSaLogin → 成功回调 + /// + /// 密码与社交登录共用, 保证会话超时/并发/deviceType 与 2FA 行为一致. + /// 若需 2FA 则抛 [AuthenticationChallengeException], 不建立最终会话. + public String completeAuthenticatedLogin(AuthInfoResult authInfoResult, LoginAuthContext context) { + // 认证后挑战(双因素等) + this.applyChallenges(context, authInfoResult); + // 建立会话 + this.doSaLogin(authInfoResult, context.getClientCode(), context.getAuthLoginType()); // 登录成功回调 - this.loginSuccessHandler(request, response, authInfoResult); + this.loginSuccessHandler(context.getRequest(), context.getResponse(), authInfoResult); return StpUtil.getTokenValue(); } diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/controller/client/ClientController.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/controller/client/ClientController.java new file mode 100644 index 000000000..41e76ad97 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/controller/client/ClientController.java @@ -0,0 +1,38 @@ +package cn.daxpay.open.platform.iam.controller.client; + +import cn.daxpay.open.platform.core.annotation.IgnoreAuth; +import cn.daxpay.open.platform.core.rest.Res; +import cn.daxpay.open.platform.core.rest.result.Result; +import cn.daxpay.open.platform.iam.result.client.ClientResult; +import cn.daxpay.open.platform.iam.service.client.ClientQueryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/// # 登录终端主数据 +/// +/// 只读列表, 供用户/角色/菜单等页下拉使用. 无启停、无 CRUD. +/// +@Validated +@Tag(name = "登录终端") +@RestController +@RequestMapping("/client") +@RequiredArgsConstructor +public class ClientController { + + private final ClientQueryService clientQueryService; + + /// 查询全部登录终端(需登录) + @IgnoreAuth(login = true) + @Operation(summary = "查询全部登录终端") + @GetMapping("/find-all") + public Result> findAll() { + return Res.ok(clientQueryService.findAll()); + } +} diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/controller/social/SocialLoginConfigController.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/controller/social/SocialLoginConfigController.java index 7ff82012c..4b69c677d 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/controller/social/SocialLoginConfigController.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/controller/social/SocialLoginConfigController.java @@ -1,8 +1,10 @@ package cn.daxpay.open.platform.iam.controller.social; import cn.daxpay.open.platform.iam.param.social.SocialLoginConfigParam; +import cn.daxpay.open.platform.iam.result.social.SocialCallbackUrlResult; import cn.daxpay.open.platform.iam.result.social.SocialLoginConfigResult; import cn.daxpay.open.platform.iam.service.social.SocialLoginConfigService; +import cn.daxpay.open.platform.iam.service.social.SocialLoginService; import cn.daxpay.open.platform.core.annotation.PermCode; import cn.daxpay.open.platform.core.code.PermCodes; import cn.daxpay.open.platform.core.rest.Res; @@ -33,6 +35,8 @@ public class SocialLoginConfigController { private final SocialLoginConfigService socialLoginConfigService; + private final SocialLoginService socialLoginService; + @PermCode(code = PermCodes.Action.VIEW, nameCn = PermCodes.Iam.Social.VIEW_NAME_CN, nameEn = PermCodes.Iam.Social.VIEW_NAME_EN) @Operation(summary = "全量查询平台配置(枚举驱动, 读时初始化缺失平台)") @GetMapping("/find-all") @@ -40,6 +44,14 @@ public class SocialLoginConfigController { return Res.ok(socialLoginConfigService.findAll()); } + /// 列出应在第三方平台登记的 OAuth 回调地址(运营/商户 × 平台 × 登录/绑定) + @PermCode(code = PermCodes.Action.VIEW, nameCn = PermCodes.Iam.Social.VIEW_NAME_CN, nameEn = PermCodes.Iam.Social.VIEW_NAME_EN) + @Operation(summary = "查询应登记的社交回调地址清单") + @GetMapping("/callback-urls") + public Result> callbackUrls() { + return Res.ok(socialLoginService.listCallbackUrls()); + } + @PermCode(code = PermCodes.Action.VIEW, nameCn = PermCodes.Iam.Social.VIEW_NAME_CN, nameEn = PermCodes.Iam.Social.VIEW_NAME_EN) @Operation(summary = "根据平台编码查询(不存在则初始化占位记录)") @GetMapping("/get-by-source") diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/entity/dashboardpreference/UserDashboardPreference.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/entity/dashboardpreference/UserDashboardPreference.java index 34bee246e..9db2e4a6e 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/entity/dashboardpreference/UserDashboardPreference.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/entity/dashboardpreference/UserDashboardPreference.java @@ -15,9 +15,9 @@ import java.util.List; /// # 用户工作台快捷入口偏好 /// -/// 按用户 + 终端(clientCode)维度存储用户在工作台自定义的快捷入口序列. +/// 按用户 + 身份域终端(clientCode: admin/merchant/gateway)存储工作台快捷入口序列. /// 仅存已选入口的 key 有序列表, 入口元信息(图标/标题/路由)由前端各端自行维护, 后端不感知. -/// PC 与移动端通过 clientCode 区分, 互不影响. +/// 一期 Web 与 App 共用同一 clientCode(如均为 admin), 会话/偏好分池见 channel 二期. @EqualsAndHashCode(callSuper = true) @Data @Accessors(chain = true) @@ -27,7 +27,7 @@ public class UserDashboardPreference extends MpBaseEntity implements ToResult { /// 英文名称 private String nameEn; - /// 终端编码: ADMIN/ISV/AGENT/MCH + /// 身份域终端编码: admin / merchant / gateway private String clientCode; /// 是否系统内置 不能修改 diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/enums/SocialClientEnum.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/enums/SocialClientEnum.java index d05d26d5d..77683e034 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/enums/SocialClientEnum.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/enums/SocialClientEnum.java @@ -1,6 +1,7 @@ package cn.daxpay.open.platform.iam.enums; import java.util.Arrays; +import java.util.Optional; import cn.daxpay.open.platform.system.entity.config.platform.infra.PlatformUrlConfig; import lombok.AllArgsConstructor; @@ -9,7 +10,7 @@ import lombok.Getter; /// # 社交登录终端编码 /// /// 用于按 client(admin/merchant) 解析端点配置中对应的前端 baseUrl. -/// 默认值 ADMIN: client 参数无法识别时回退到管理端配置. +/// 仅支持 admin / merchant, 不识别时由业务层拒绝(不再静默回退 admin). /// @Getter @AllArgsConstructor @@ -37,11 +38,18 @@ public enum SocialClientEnum { /// 从端点配置中解析当前终端对应的 baseUrl public abstract String resolveBaseUrl(PlatformUrlConfig config); - /// 根据编码查找枚举, 无法识别时回退到 ADMIN(容错) - public static SocialClientEnum of(String code) { + /// 根据编码查找, 无法识别返回 empty + public static Optional findByCode(String code) { + if (code == null || code.isBlank()) { + return Optional.empty(); + } return Arrays.stream(values()) .filter(e -> e.code.equalsIgnoreCase(code)) - .findFirst() - .orElse(ADMIN); + .findFirst(); + } + + /// 根据编码查找枚举, 无法识别时回退到 ADMIN(仅兼容旧调用) + public static SocialClientEnum of(String code) { + return findByCode(code).orElse(ADMIN); } } diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/result/client/ClientResult.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/result/client/ClientResult.java new file mode 100644 index 000000000..73e86f6f5 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/result/client/ClientResult.java @@ -0,0 +1,21 @@ +package cn.daxpay.open.platform.iam.result.client; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +/// # 登录终端(身份域)主数据 +/// +@Data +@Accessors(chain = true) +@Schema(title = "登录终端主数据") +public class ClientResult { + + /// 终端编码(admin/merchant/gateway) + @Schema(description = "终端编码") + private String code; + + /// 展示名(当前语言) + @Schema(description = "展示名") + private String name; +} diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/result/social/SocialCallbackUrlResult.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/result/social/SocialCallbackUrlResult.java new file mode 100644 index 000000000..72d27782e --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/result/social/SocialCallbackUrlResult.java @@ -0,0 +1,35 @@ +package cn.daxpay.open.platform.iam.result.social; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +/// # 社交 OAuth 应登记的回调地址 +/// +/// 供运营在第三方开放平台配置 redirect_uri 白名单时复制. +/// +@Data +@Accessors(chain = true) +@Schema(title = "社交回调地址") +public class SocialCallbackUrlResult { + + /// 身份域终端 admin / merchant + @Schema(description = "终端编码") + private String clientCode; + + /// 平台编码(source) + @Schema(description = "平台编码") + private String source; + + /// 场景: LOGIN / BIND + @Schema(description = "场景 LOGIN/BIND") + private String mode; + + /// 完整回调 URL(含 /{source}) + @Schema(description = "完整回调地址") + private String url; + + /// 对应端 baseUrl 是否已配置 + @Schema(description = "端点 baseUrl 是否已配置") + private boolean baseUrlConfigured; +} diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/client/ClientQueryService.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/client/ClientQueryService.java new file mode 100644 index 000000000..f45339ffa --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/client/ClientQueryService.java @@ -0,0 +1,32 @@ +package cn.daxpay.open.platform.iam.service.client; + +import cn.daxpay.open.platform.common.i18n.util.I18nUtil; +import cn.daxpay.open.platform.core.enums.client.ClientEnum; +import cn.daxpay.open.platform.iam.result.client.ClientResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.List; + +/// # 登录终端主数据查询 +/// +/// 固定三端身份域, 数据源为 [ClientEnum] + i18n, 不落库、无启停. +/// +@Service +@RequiredArgsConstructor +public class ClientQueryService { + + /// 全部终端(admin / merchant / gateway) + public List findAll() { + return Arrays.stream(ClientEnum.values()) + .map(this::toResult) + .toList(); + } + + private ClientResult toResult(ClientEnum client) { + return new ClientResult() + .setCode(client.getCode()) + .setName(I18nUtil.getEnumName(client)); + } +} diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/social/IamSocialLoginHandler.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/social/IamSocialLoginHandler.java index b8ceab083..ebb8cc749 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/social/IamSocialLoginHandler.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/social/IamSocialLoginHandler.java @@ -1,16 +1,12 @@ package cn.daxpay.open.platform.iam.service.social; -import java.util.List; - -import cn.daxpay.open.platform.core.code.CommonCode; -import cn.daxpay.open.platform.core.entity.UserDetail; import cn.daxpay.open.platform.capability.auth.entity.AuthInfoResult; -import cn.daxpay.open.platform.capability.auth.handler.LoginSuccessHandler; +import cn.daxpay.open.platform.capability.auth.entity.LoginAuthContext; +import cn.daxpay.open.platform.common.config.properties.PlatformStarterProperties; +import cn.daxpay.open.platform.core.entity.UserDetail; +import cn.daxpay.open.platform.iam.auth.service.TokenService; import cn.daxpay.open.platform.iam.result.user.UserInfoResult; import cn.daxpay.open.platform.iam.service.user.UserQueryService; -import cn.dev33.satoken.session.SaSession; -import cn.dev33.satoken.stp.StpUtil; -import cn.dev33.satoken.stp.parameter.SaLoginParameter; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -19,8 +15,8 @@ import org.springframework.stereotype.Service; /// # 社交登录处理器 /// -/// 在 LOGIN 场景下, 通过绑定关系确认用户身份后, 完成本地 Sa-Token 登录签发与 session 填充, -/// 并触发 [LoginSuccessHandler] 链以记录登录日志(IP/UA/地域/登录方式等). +/// 在 LOGIN 场景下, 绑定关系与状态检查已通过后, 走 [TokenService.completeAuthenticatedLogin] +/// 统一完成 2FA 挑战、会话建立(超时/并发)与成功回调. /// @Slf4j @Service @@ -29,39 +25,32 @@ public class IamSocialLoginHandler { private final UserQueryService userQueryService; - private final List loginSuccessHandlers; + private final TokenService tokenService; - /// 使用已确认身份的 userId 完成登录(含 session 填充), 返回 token + private final PlatformStarterProperties platformStarterProperties; + + /// 使用已确认身份的 userId 完成登录, 返回 token + /// /// @param userId 本地用户ID /// @param clientCode 终端编码 /// @param source 三方平台编码(作为 loginType 记录到登录日志, 如 gitee/feishu) public String login(Long userId, String clientCode, String source, HttpServletRequest request, HttpServletResponse response) { - // 加载用户信息并构建会话对象 UserInfoResult userInfo = userQueryService.findById(userId); UserDetail userDetail = userInfo.toUserDetail(); - // 签发 Sa-Token - var saLoginModel = new SaLoginParameter() - .setDeviceType(clientCode) - .setIsLastingCookie(true); - StpUtil.login(userId, saLoginModel); - // 填充 session - SaSession session = StpUtil.getSession(); - session.set(CommonCode.USER, userDetail); - // 构建认证结果, loginType 使用三方平台编码 AuthInfoResult authInfoResult = new AuthInfoResult() - .setId(userId) - .setClient(clientCode) - .setLoginType(source) - .setUserDetail(userDetail); - // 触发登录成功处理器链(记录登录日志等) - for (LoginSuccessHandler handler : loginSuccessHandlers) { - try { - handler.onLoginSuccess(request, response, authInfoResult); - } catch (Exception e) { - log.error("社交登录成功处理出现异常: {}", e.getMessage(), e); - } - } - return StpUtil.getTokenValue(); + .setId(userId) + .setClient(clientCode) + .setLoginType(source) + .setUserDetail(userDetail); + LoginAuthContext context = new LoginAuthContext() + .setRequest(request) + .setResponse(response) + .setClientCode(clientCode) + .setAuthLoginType(source) + .setAuthProperties(platformStarterProperties.getAuth()) + .setUserDetail(userDetail); + // 2FA 挑战 / 统一 doSaLogin / 成功日志 + return tokenService.completeAuthenticatedLogin(authInfoResult, context); } } diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/social/SocialLoginService.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/social/SocialLoginService.java index 65bf7b80c..1a92b41eb 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/social/SocialLoginService.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/social/SocialLoginService.java @@ -1,7 +1,13 @@ package cn.daxpay.open.platform.iam.service.social; +import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import cn.daxpay.open.platform.capability.auth.authentication.UserInfoStatusCheck; +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.util.SecurityUtil; import cn.daxpay.open.platform.capability.social.auth.SocialAuthRequestFactory; import cn.daxpay.open.platform.capability.social.justauth.SocialAuthConfig; @@ -9,15 +15,20 @@ import cn.daxpay.open.platform.capability.social.justauth.SocialSourceEnum; import cn.daxpay.open.platform.capability.social.justauth.model.AuthCallback; import cn.daxpay.open.platform.capability.social.justauth.model.AuthUser; import cn.daxpay.open.platform.capability.social.justauth.request.SocialAuthRequest; +import cn.daxpay.open.platform.common.config.properties.PlatformStarterProperties; +import cn.daxpay.open.platform.core.entity.UserDetail; import cn.daxpay.open.platform.core.exception.operation.OperationFailException; import cn.daxpay.open.platform.iam.entity.social.SocialLoginConfig; +import cn.daxpay.open.platform.iam.enums.SocialAuthMode; import cn.daxpay.open.platform.iam.enums.SocialClientEnum; import cn.daxpay.open.platform.iam.result.social.SocialBindResult; +import cn.daxpay.open.platform.iam.result.social.SocialCallbackUrlResult; import cn.daxpay.open.platform.iam.result.social.SocialEnabledPlatformResult; import cn.daxpay.open.platform.iam.result.social.SocialExchangeResult; -import cn.daxpay.open.platform.iam.enums.SocialAuthMode; +import cn.daxpay.open.platform.iam.result.user.UserInfoResult; import cn.daxpay.open.platform.iam.service.social.other.AlipaySocialAuthRequest; import cn.daxpay.open.platform.iam.service.social.other.AlipaySocialAuthRequestFactory; +import cn.daxpay.open.platform.iam.service.user.UserQueryService; import cn.daxpay.open.platform.system.entity.config.platform.infra.PlatformUrlConfig; import cn.daxpay.open.platform.system.service.config.infra.PlatformUrlConfigService; import cn.hutool.core.util.IdUtil; @@ -60,17 +71,56 @@ public class SocialLoginService { private final PlatformUrlConfigService platformUrlConfigService; + private final UserQueryService userQueryService; + + private final PlatformStarterProperties platformStarterProperties; + + private final List userInfoStatusChecks; + /// 查询已启用的第三方登录平台(登录页公开接口) /// 仅返回平台编码列表, 不含任何敏感字段, 供登录页动态渲染第三方登录按钮. public List enabledList() { return socialLoginConfigService.findEnabledList(); } + /// 列出应在第三方开放平台登记的回调地址(运营/商户 × 全平台 × 登录/绑定) + /// + /// 完整 URL 形态: {baseUrl}/auth/oauth-callback/{source} 与 bind 路径. + public List listCallbackUrls() { + PlatformUrlConfig urlConfig = platformUrlConfigService.getUrlConfig(); + List list = new ArrayList<>(); + for (SocialClientEnum client : SocialClientEnum.values()) { + String baseUrl = client.resolveBaseUrl(urlConfig); + boolean configured = StrUtil.isNotBlank(baseUrl); + String base = configured ? StrUtil.removeSuffix(baseUrl, "/") : ""; + for (SocialSourceEnum source : SocialSourceEnum.values()) { + String code = source.getCode(); + list.add(buildCallbackItem(client.getCode(), code, SocialAuthMode.LOGIN, base, configured)); + list.add(buildCallbackItem(client.getCode(), code, SocialAuthMode.BIND, base, configured)); + } + } + return list; + } + + private SocialCallbackUrlResult buildCallbackItem(String clientCode, String source, + SocialAuthMode mode, String base, boolean configured) { + String path = mode == SocialAuthMode.BIND ? BIND_CALLBACK_PATH : LOGIN_CALLBACK_PATH; + String url = configured ? base + path + "/" + source : ""; + return new SocialCallbackUrlResult() + .setClientCode(clientCode) + .setSource(source) + .setMode(mode.name()) + .setUrl(url) + .setBaseUrlConfigured(configured); + } + /// 生成授权地址 /// @param source 平台来源 /// @param client 终端编码(admin/merchant), 用于解析端点配置中的 baseUrl /// @param mode 授权场景(不传则按登录态判断: 已登录=绑定, 未登录=登录) public String generateAuthorizeUrl(String source, String client, String mode) { + // 仅 admin/merchant + SocialClientEnum socialClient = this.requireSocialClient(client); // 加载平台配置(全局唯一) SocialLoginConfig config = this.loadEnabledConfig(source); SocialSourceEnum socialSource = SocialSourceEnum.of(source); @@ -79,10 +129,9 @@ public class SocialLoginService { throw new OperationFailException("error.social.unsupportedSource"); } // 按 client 解析前端 baseUrl - String baseUrl = this.resolveBaseUrl(client); + String baseUrl = this.resolveBaseUrl(socialClient); if (StrUtil.isBlank(baseUrl)) { - // 社交登录: 端点配置缺失 - throw new OperationFailException("error.social.endpointNotConfigured"); + throw new OperationFailException(this.endpointMissingKey(socialClient)); } SocialAuthMode authMode = this.resolveMode(mode); // 根据场景拼接回调基础地址 @@ -102,7 +151,8 @@ public class SocialLoginService { String source, String clientCode, HttpServletRequest request, HttpServletResponse response) { try { - String baseUrl = this.resolveBaseUrl(clientCode); + SocialClientEnum socialClient = this.requireSocialClient(clientCode); + String baseUrl = this.requireBaseUrl(socialClient); String redirectUri = this.buildRedirectUri(baseUrl, SocialAuthMode.LOGIN); AuthUser authUser = this.doExchange(code, state, source, redirectUri); // 查绑定关系 @@ -111,28 +161,72 @@ public class SocialLoginService { // 未绑定 return new SocialExchangeResult().setError("unbind"); } + // 身份域 + 用户状态检查(对齐密码路径 UserInfoStatusCheck 链) + this.validateSocialLoginUser(userId, clientCode, source, request, response); + // 统一建会话 + 2FA 挑战(TokenService.completeAuthenticatedLogin) String token = socialLoginHandler.login(userId, clientCode, source, request, response); return new SocialExchangeResult().setToken(token); - } catch (Exception e) { + } + catch (LoginFailureException e) { + // 业务拒绝 / 2FA 挑战(子类 AuthenticationChallengeException): 交给全局异常 + throw e; + } + catch (Exception e) { log.error("社交登录兑换失败: source={}, msg={}", source, e.getMessage(), e); return new SocialExchangeResult().setError("oauth_failed"); } } + /// 社交登录用户校验: client 归属 + 状态检查链 + private void validateSocialLoginUser(Long userId, String clientCode, String source, + HttpServletRequest request, HttpServletResponse response) { + UserInfoResult userInfo = userQueryService.findById(userId); + // 绑定用户必须属于当前登录终端, 防止运营/商户串号 + if (!Objects.equals(clientCode, userInfo.getClientCode())) { + throw new LoginFailureException(userId, userInfo.getAccount(), "error.auth.clientMismatch"); + } + UserDetail userDetail = userInfo.toUserDetail(); + AuthInfoResult authInfoResult = new AuthInfoResult() + .setId(userId) + .setUserDetail(userDetail) + .setClient(clientCode) + .setLoginType(source); + LoginAuthContext context = new LoginAuthContext() + .setRequest(request) + .setResponse(response) + .setClientCode(clientCode) + .setAuthLoginType(source) + .setAuthProperties(platformStarterProperties.getAuth()) + .setUserDetail(userDetail); + for (UserInfoStatusCheck check : userInfoStatusChecks) { + check.check(authInfoResult, context); + } + } + /// OAuth 授权码兑换 - 绑定场景(需登录) /// 前端绑定回调页收到第三方平台的 code+state 后调用此方法, /// 后端完成 code 换 token, 保存绑定关系到当前登录用户. public SocialExchangeResult exchangeForBind(String code, String state, String source, String clientCode) { try { + SocialClientEnum socialClient = this.requireSocialClient(clientCode); // 绑定场景必须已登录 Long userId = SecurityUtil.getUserId(); - String baseUrl = this.resolveBaseUrl(clientCode); + // 当前用户必须属于该身份域, 防止跨端绑定 + UserInfoResult userInfo = userQueryService.findById(userId); + if (!Objects.equals(clientCode, userInfo.getClientCode())) { + throw new OperationFailException("error.social.bind.clientMismatch"); + } + String baseUrl = this.requireBaseUrl(socialClient); String redirectUri = this.buildRedirectUri(baseUrl, SocialAuthMode.BIND); AuthUser authUser = this.doExchange(code, state, source, redirectUri); socialBindStore.saveBind(userId, clientCode, authUser); return new SocialExchangeResult().setResult("bind_success"); - } catch (Exception e) { + } + catch (OperationFailException e) { + throw e; + } + catch (Exception e) { log.error("社交绑定失败: source={}, msg={}", source, e.getMessage(), e); return new SocialExchangeResult().setError("oauth_failed"); } @@ -187,13 +281,35 @@ public class SocialLoginService { return login ? SocialAuthMode.BIND : SocialAuthMode.LOGIN; } - /// 按 client 解析前端 baseUrl(用于 redirectUri 拼接) - private String resolveBaseUrl(String clientCode) { - PlatformUrlConfig urlConfig = platformUrlConfigService.getUrlConfig(); - return SocialClientEnum.of(clientCode).resolveBaseUrl(urlConfig); + /// 校验并解析社交登录终端(仅 admin/merchant) + private SocialClientEnum requireSocialClient(String clientCode) { + return SocialClientEnum.findByCode(clientCode) + .orElseThrow(() -> new OperationFailException("error.social.unsupportedClient")); } - /// 构建回调基础地址 + /// 解析并校验 baseUrl 非空 + private String requireBaseUrl(SocialClientEnum socialClient) { + String baseUrl = this.resolveBaseUrl(socialClient); + if (StrUtil.isBlank(baseUrl)) { + throw new OperationFailException(this.endpointMissingKey(socialClient)); + } + return baseUrl; + } + + /// 按 client 解析前端 baseUrl + private String resolveBaseUrl(SocialClientEnum socialClient) { + PlatformUrlConfig urlConfig = platformUrlConfigService.getUrlConfig(); + return socialClient.resolveBaseUrl(urlConfig); + } + + /// 端点缺失时的 i18n key(区分运营/商户便于配置) + private String endpointMissingKey(SocialClientEnum socialClient) { + return socialClient == SocialClientEnum.MERCHANT + ? "error.social.merchantEndpointNotConfigured" + : "error.social.adminEndpointNotConfigured"; + } + + /// 构建回调基础地址(不含 source 后缀, JustAuth 层再拼 /{source}) private String buildRedirectUri(String baseUrl, SocialAuthMode mode) { String base = StrUtil.removeSuffix(baseUrl, "/"); String callbackPath = mode == SocialAuthMode.BIND