mirror of
https://gitee.com/dromara/dax-pay
synced 2026-08-10 06:46:04 +08:00
feat(iam): 登录终端主数据与社交登录对齐密码路径
新增 Client find-all 主数据接口;社交登录统一 doSaLogin 与 2FA 挑战,校验身份域归属与状态;补充商户回调地址清单与端点缺失提示。
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "当前登录用户不属于该终端,无法完成绑定"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"applicationNotEnable": "指定应用已被停用",
|
||||
"clientNotFound": "未找到对应的终端",
|
||||
"clientNotEnable": "指定终端方式已被停用",
|
||||
"clientMismatch": "该第三方账号绑定的用户不属于当前登录终端",
|
||||
"userStatusError": "用户状态异常",
|
||||
"privateKeyNotConfigured": "平台私钥未配置,无法解密密码",
|
||||
"passwordDecryptFailed": "密码解密失败,请检查密码传输方式",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// 是否系统内置 不能修改
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user