feat(iam): 登录终端主数据与社交登录对齐密码路径

新增 Client find-all 主数据接口;社交登录统一 doSaLogin 与 2FA 挑战,校验身份域归属与状态;补充商户回调地址清单与端点缺失提示。
This commit is contained in:
DaxPay Dev
2026-07-13 14:06:36 +08:00
parent b36fa4bcdd
commit 67655f857a
16 changed files with 359 additions and 72 deletions

View File

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

View File

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

View File

@@ -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": "当前登录用户不属于该终端,无法完成绑定"
}
}

View File

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

View File

@@ -10,6 +10,7 @@
"applicationNotEnable": "指定应用已被停用",
"clientNotFound": "未找到对应的终端",
"clientNotEnable": "指定终端方式已被停用",
"clientMismatch": "该第三方账号绑定的用户不属于当前登录终端",
"userStatusError": "用户状态异常",
"privateKeyNotConfigured": "平台私钥未配置,无法解密密码",
"passwordDecryptFailed": "密码解密失败,请检查密码传输方式",

View File

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

View File

@@ -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<List<ClientResult>> findAll() {
return Res.ok(clientQueryService.findAll());
}
}

View File

@@ -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<List<SocialCallbackUrlResult>> 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")

View File

@@ -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<Qu
/// 用户ID
private Long userId;
/// 终端编码(WEB / MOBILE), PC 与移动分开管理
/// 身份域终端编码(admin / merchant / gateway), 非 WEB/MOBILE 壳
private String clientCode;
/// 已选快捷入口有序序列(纯 key 数组), 如 ["merchant","notify","app"]

View File

@@ -27,7 +27,7 @@ public class Role extends MpBaseEntity implements ToResult<RoleResult> {
/// 英文名称
private String nameEn;
/// 终端编码: ADMIN/ISV/AGENT/MCH
/// 身份域终端编码: admin / merchant / gateway
private String clientCode;
/// 是否系统内置 不能修改

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<UserInfoStatusCheck> userInfoStatusChecks;
/// 查询已启用的第三方登录平台(登录页公开接口)
/// 仅返回平台编码列表, 不含任何敏感字段, 供登录页动态渲染第三方登录按钮.
public List<SocialEnabledPlatformResult> enabledList() {
return socialLoginConfigService.findEnabledList();
}
/// 列出应在第三方开放平台登记的回调地址(运营/商户 × 全平台 × 登录/绑定)
///
/// 完整 URL 形态: {baseUrl}/auth/oauth-callback/{source} 与 bind 路径.
public List<SocialCallbackUrlResult> listCallbackUrls() {
PlatformUrlConfig urlConfig = platformUrlConfigService.getUrlConfig();
List<SocialCallbackUrlResult> 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