diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/endpoint/SocialEndpoint.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/endpoint/SocialEndpoint.java index 0d4a83e9f..948b5337f 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/endpoint/SocialEndpoint.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/endpoint/SocialEndpoint.java @@ -26,7 +26,7 @@ import org.springframework.web.bind.annotation.RestController; /// /// 瘦控制器, 仅负责 HTTP 参数解包与结果包装, 业务编排全部委托 [SocialLoginService]. /// 采用前端回调模式: 第三方平台直接重定向到前端回调页, 前端拿到 code+state 后 -/// 调用 exchange 接口完成换 token, 后端不做 302 跳转. +/// 调用 exchange-login 或 exchange-bind 接口完成换 token, 后端不做 302 跳转. /// @IgnoreAuth @Tag(name = "第三方社交登录") @@ -49,26 +49,39 @@ public class SocialEndpoint { /// @param source 平台来源 /// @param client 终端编码(admin/merchant), 用于解析端点配置中的 baseUrl /// @param mode 授权场景(不传则按登录态判断: 已登录=绑定, 未登录=登录) - /// @param redirect 成功后前端跳转路径(可选) @Operation(summary = "生成授权地址") @GetMapping("/render/{source}") public Result render(@PathVariable String source, @RequestParam String client, - @RequestParam(required = false) String mode, - @RequestParam(required = false) String redirect) { - return Res.ok(socialLoginService.generateAuthorizeUrl(source, client, mode, redirect)); + @RequestParam(required = false) String mode) { + return Res.ok(socialLoginService.generateAuthorizeUrl(source, client, mode)); } - /// OAuth 授权码兑换(前端回调模式) - /// 前端回调页收到第三方平台的 code+state 后调用此接口, - /// 后端完成 code 换 token 并返回结果 JSON. - @Operation(summary = "授权码兑换") - @PostMapping("/exchange") - public Result exchange(@RequestParam("code") String code, - @RequestParam("state") String state, - HttpServletRequest request, - HttpServletResponse response) { - return Res.ok(socialLoginService.exchangeCode(code, state, request, response)); + /// OAuth 授权码兑换 - 登录(公开, 无需认证) + /// 前端登录回调页(/auth/oauth-callback/{source})收到第三方平台的 code+state 后调用, + /// 后端完成 code 换 token 并返回登录结果. + @Operation(summary = "授权码兑换-登录") + @PostMapping("/exchange-login") + public Result exchangeLogin(@RequestParam("code") String code, + @RequestParam("state") String state, + @RequestParam("source") String source, + @RequestParam("client") String client, + HttpServletRequest request, + HttpServletResponse response) { + return Res.ok(socialLoginService.exchangeForLogin(code, state, source, client, request, response)); + } + + /// OAuth 授权码兑换 - 绑定(需登录) + /// 前端绑定回调页(/auth/social-bind-callback/{source})收到第三方平台的 code+state 后调用, + /// 后端完成 code 换 token 并保存绑定关系到当前登录用户. + @IgnoreAuth(login = true) + @Operation(summary = "授权码兑换-绑定") + @PostMapping("/exchange-bind") + public Result exchangeBind(@RequestParam("code") String code, + @RequestParam("state") String state, + @RequestParam("source") String source, + @RequestParam("client") String client) { + return Res.ok(socialLoginService.exchangeForBind(code, state, source, client)); } /// 查询当前登录用户已绑定的第三方账号 diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/social/SocialConfigService.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/social/SocialConfigService.java index 90396c66c..0b1c51565 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/social/SocialConfigService.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/social/SocialConfigService.java @@ -114,15 +114,8 @@ public class SocialConfigService { } /// 将配置实体转换为授权配置 - /// @param baseUrl 端点解析出的前端基础地址, 用于 redirectUri 自动生成(exchange 阶段传 null 表示不需要 redirectUri) - public SocialAuthConfig buildAuthConfig(SocialConfig entity, String baseUrl) { - // 回调地址由端点配置自动生成: {baseUrl}/auth/oauth-callback(exchange 阶段 baseUrl 为 null 时跳过) - String redirectUri = null; - if (StrUtil.isNotBlank(baseUrl)) { - // 末尾斜杠归一化由 AbstractSocialAuthRequest.buildRedirectUri 处理 - String base = StrUtil.removeSuffix(baseUrl, "/"); - redirectUri = base + "/auth/oauth-callback"; - } + /// @param redirectUri 完整回调地址, 由调用方根据 mode 和 source 拼接(render 阶段传入, exchange 阶段可不传) + public SocialAuthConfig buildAuthConfig(SocialConfig entity, String redirectUri) { // 企业微信 agentId 等平台特有参数从 extra(jsonb) 读取 String agentId = null; if (StrUtil.isNotBlank(entity.getExtra())) { 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 5b6d64a0d..f0058116a 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 @@ -15,8 +15,6 @@ 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.SocialEnabledPlatformResult; import cn.daxpay.open.platform.iam.result.social.SocialExchangeResult; -import cn.daxpay.open.platform.iam.service.social.cache.RedisSocialStateCache; -import cn.daxpay.open.platform.iam.service.social.cache.SocialAuthContext; import cn.daxpay.open.platform.iam.service.social.cache.SocialAuthMode; import cn.daxpay.open.platform.system.entity.config.platform.PlatformUrlConfig; import cn.daxpay.open.platform.system.service.config.PlatformUrlConfigService; @@ -30,26 +28,25 @@ import org.springframework.stereotype.Service; /// # 第三方社交登录编排服务 /// -/// 编排 OAuth2 授权流程: 授权地址生成(render)、授权码兑换(exchange)、 -/// 绑定关系查询与解绑. 由 [SocialEndpoint] 作为瘦控制器入口调用, -/// 数据访问与配置管理分别委托 [IamUserSocialBindStore] / [SocialConfigService], -/// Sa-Token 签发委托 [IamSocialLoginHandler]. -/// 采用前端回调模式: 第三方平台直接重定向到前端回调页, 前端拿到 code+state 后 -/// 调用 [exchangeCode] 完成换 token, 后端不做 302 跳转. -/// state 超时使用系统默认常量. +/// 编排 OAuth2 授权流程: 授权地址生成(render)、授权码兑换(login/bind)、 +/// 绑定关系查询与解绑. 采用前端回调模式: 第三方平台直接重定向到前端回调页, +/// 前端拿到 code+state 后调用 exchange 接口完成换 token. +/// state 仅用于 OAuth2 合规(CSRF), 不携带业务上下文. +/// 登录和绑定的区分由回调页路由决定(两个不同的前端页面), exchange 时显式传 source + client. /// @Slf4j @Service @RequiredArgsConstructor public class SocialLoginService { - /// state 缓存超时时间(秒), 用户完成第三方授权的合理等待时长 - private static final long STATE_TIMEOUT_SECONDS = 300L; + /// 登录回调路径 + private static final String LOGIN_CALLBACK_PATH = "/auth/oauth-callback"; + + /// 绑定回调路径 + private static final String BIND_CALLBACK_PATH = "/auth/social-bind-callback"; private final SocialAuthRequestFactory socialAuthRequestFactory; - private final RedisSocialStateCache redisSocialStateCache; - private final IamUserSocialBindStore socialBindStore; private final IamSocialLoginHandler socialLoginHandler; @@ -64,12 +61,11 @@ public class SocialLoginService { return socialConfigService.findEnabledList(); } - /// 生成授权地址并缓存上下文(前端拿到后跳转) + /// 生成授权地址 /// @param source 平台来源 /// @param client 终端编码(admin/merchant), 用于解析端点配置中的 baseUrl /// @param mode 授权场景(不传则按登录态判断: 已登录=绑定, 未登录=登录) - /// @param redirect 成功后前端跳转路径(可选) - public String generateAuthorizeUrl(String source, String client, String mode, String redirect) { + public String generateAuthorizeUrl(String source, String client, String mode) { // 加载平台配置(全局唯一) SocialConfig config = this.loadEnabledConfig(source); SocialSourceEnum socialSource = SocialSourceEnum.of(source); @@ -77,77 +73,66 @@ public class SocialLoginService { // 社交登录: 不支持的平台 throw new OperationFailException("error.social.unsupportedSource"); } - // 按 client 解析前端 baseUrl(用于 redirectUri 自动生成) + // 按 client 解析前端 baseUrl String baseUrl = this.resolveBaseUrl(client); - // 回调地址由端点配置的 baseUrl 自动生成, 必须配置 baseUrl if (StrUtil.isBlank(baseUrl)) { // 社交登录: 端点配置缺失 throw new OperationFailException("error.social.endpointNotConfigured"); } SocialAuthMode authMode = this.resolveMode(mode); - // 构建 state 并缓存上下文(含平台来源, 供 exchange 阶段使用) - String state = IdUtil.fastSimpleUUID(); - SocialAuthContext context = new SocialAuthContext() - .setMode(authMode) - .setClientCode(client) - .setRedirect(redirect) - .setSource(source); - if (authMode == SocialAuthMode.BIND) { - // 绑定场景必须已登录, 用户ID从登录态获取 - context.setUserId(SecurityUtil.getUserId()); - } - redisSocialStateCache.cache(state, context, STATE_TIMEOUT_SECONDS); - // 构建授权请求并生成授权地址 - SocialAuthConfig authConfig = socialConfigService.buildAuthConfig(config, baseUrl); + // 根据场景拼接回调基础地址 + String redirectUri = this.buildRedirectUri(baseUrl, authMode); + // 构建授权请求 + SocialAuthConfig authConfig = socialConfigService.buildAuthConfig(config, redirectUri); SocialAuthRequest request = socialAuthRequestFactory.create(socialSource, authConfig); + // state 仅用于 OAuth2 合规, 不缓存业务上下文 + String state = IdUtil.fastSimpleUUID(); return request.authorize(state); } - /// OAuth 授权码兑换(前端回调模式) - /// 前端回调页收到第三方平台的 code+state 后调用此方法, - /// 后端完成 code 换 token 并返回结果 JSON. - public SocialExchangeResult exchangeCode(String code, String state, - HttpServletRequest request, HttpServletResponse response) { - // 校验 state 并恢复上下文 - SocialAuthContext context = redisSocialStateCache.getAndRemove(state); - if (context == null) { - // state 已过期或非法 - return new SocialExchangeResult().setError("state_invalid"); - } - String source = context.getSource(); - String clientCode = context.getClientCode(); + /// OAuth 授权码兑换 - 登录场景(公开, 无需认证) + /// 前端登录回调页收到第三方平台的 code+state 后调用此方法, + /// 后端完成 code 换 token, 查绑定关系, 签发登录令牌. + public SocialExchangeResult exchangeForLogin(String code, String state, + String source, String clientCode, + HttpServletRequest request, HttpServletResponse response) { try { - // 加载平台配置 + 创建对应 Request - SocialConfig config = socialConfigService.findEnabledBySource(source); - if (config == null) { - return new SocialExchangeResult().setError("oauth_failed"); - } - // exchange 阶段的 redirect_uri 必须与 authorize 阶段一致, 从端点配置按 client 解析 baseUrl String baseUrl = this.resolveBaseUrl(clientCode); - SocialAuthConfig authConfig = socialConfigService.buildAuthConfig(config, baseUrl); - SocialSourceEnum socialSource = SocialSourceEnum.of(source); - SocialAuthRequest authRequest = socialAuthRequestFactory.create(socialSource, authConfig); - AuthUser authUser = authRequest.login(AuthCallback.of(code, state)); - // 按场景处理 - if (context.getMode() == SocialAuthMode.BIND) { - socialBindStore.saveBind(context.getUserId(), clientCode, authUser); - return new SocialExchangeResult().setResult("bind_success"); - } else { - // LOGIN 场景: 仅已绑定的账号可直接登录 - Long userId = socialBindStore.findUserIdBySourceAndOpenId(source, authUser.getUuid()).orElse(null); - if (userId == null) { - // 未绑定 - return new SocialExchangeResult().setError("unbind"); - } - String token = socialLoginHandler.login(userId, clientCode, request, response); - return new SocialExchangeResult().setToken(token); + String redirectUri = this.buildRedirectUri(baseUrl, SocialAuthMode.LOGIN); + AuthUser authUser = this.doExchange(code, state, source, redirectUri); + // 查绑定关系 + Long userId = socialBindStore.findUserIdBySourceAndOpenId(source, authUser.getUuid()).orElse(null); + if (userId == null) { + // 未绑定 + return new SocialExchangeResult().setError("unbind"); } + String token = socialLoginHandler.login(userId, clientCode, request, response); + return new SocialExchangeResult().setToken(token); } catch (Exception e) { log.error("社交登录兑换失败: source={}, msg={}", source, e.getMessage(), e); return new SocialExchangeResult().setError("oauth_failed"); } } + /// OAuth 授权码兑换 - 绑定场景(需登录) + /// 前端绑定回调页收到第三方平台的 code+state 后调用此方法, + /// 后端完成 code 换 token, 保存绑定关系到当前登录用户. + public SocialExchangeResult exchangeForBind(String code, String state, + String source, String clientCode) { + try { + // 绑定场景必须已登录 + Long userId = SecurityUtil.getUserId(); + String baseUrl = this.resolveBaseUrl(clientCode); + 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) { + log.error("社交绑定失败: source={}, msg={}", source, e.getMessage(), e); + return new SocialExchangeResult().setError("oauth_failed"); + } + } + /// 查询指定用户已绑定的所有第三方账号 public List bindList(Long userId) { return socialBindStore.findBindsByUserId(userId); @@ -158,6 +143,24 @@ public class SocialLoginService { socialBindStore.removeBind(userId, source); } + // ==================== 内部方法 ==================== + + /// 共享: code 换 AuthUser(登录/绑定共用) + private AuthUser doExchange(String code, String state, String source, String redirectUri) { + SocialConfig config = socialConfigService.findEnabledBySource(source); + if (config == null) { + // 社交登录: 平台未配置或未启用 + throw new OperationFailException("error.social.configNotExist"); + } + SocialAuthConfig authConfig = socialConfigService.buildAuthConfig(config, redirectUri); + SocialSourceEnum socialSource = SocialSourceEnum.of(source); + if (socialSource == null) { + throw new OperationFailException("error.social.unsupportedSource"); + } + SocialAuthRequest authRequest = socialAuthRequestFactory.create(socialSource, authConfig); + return authRequest.login(AuthCallback.of(code, state)); + } + /// 解析授权场景(未传 mode 时按登录态判断) private SocialAuthMode resolveMode(String mode) { if (StrUtil.isNotBlank(mode)) { @@ -171,12 +174,21 @@ public class SocialLoginService { return login ? SocialAuthMode.BIND : SocialAuthMode.LOGIN; } - /// 按 client 解析前端 baseUrl(用于 redirectUri 自动生成) + /// 按 client 解析前端 baseUrl(用于 redirectUri 拼接) private String resolveBaseUrl(String clientCode) { PlatformUrlConfig urlConfig = platformUrlConfigService.getUrlConfig(); return SocialClientEnum.of(clientCode).resolveBaseUrl(urlConfig); } + /// 构建回调基础地址 + private String buildRedirectUri(String baseUrl, SocialAuthMode mode) { + String base = StrUtil.removeSuffix(baseUrl, "/"); + String callbackPath = mode == SocialAuthMode.BIND + ? BIND_CALLBACK_PATH + : LOGIN_CALLBACK_PATH; + return base + callbackPath; + } + /// 加载已启用的平台配置(不存在则抛业务异常) private SocialConfig loadEnabledConfig(String source) { SocialConfig config = socialConfigService.findEnabledBySource(source); diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/social/cache/RedisSocialStateCache.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/social/cache/RedisSocialStateCache.java deleted file mode 100644 index fb12a2a8e..000000000 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/social/cache/RedisSocialStateCache.java +++ /dev/null @@ -1,49 +0,0 @@ -package cn.daxpay.open.platform.iam.service.social.cache; - -import cn.hutool.core.util.StrUtil; -import cn.hutool.json.JSONUtil; -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Component; - -import java.util.concurrent.TimeUnit; - -/// # 社交登录 state 缓存(Redis 实现) -/// -/// 以 state 为键缓存授权上下文, 用于回调时校验 state 合法性(防 CSRF)并恢复授权场景. -/// state 超时时间由调用方(render 阶段)按平台配置传入, 不再依赖全局 yml 配置. -/// -@Component -@RequiredArgsConstructor -public class RedisSocialStateCache { - - private static final String KEY_PREFIX = "social:state:"; - - private final StringRedisTemplate stringRedisTemplate; - - /// 缓存授权上下文 - /// @param state 授权 state - /// @param context 授权上下文(含 stateTimeout 等) - /// @param stateTimeout state 缓存超时时间(秒), 来自平台配置 - public void cache(String state, SocialAuthContext context, long stateTimeout) { - String json = JSONUtil.toJsonStr(context); - stringRedisTemplate.opsForValue().set( - KEY_PREFIX + state, - json, - stateTimeout, - TimeUnit.SECONDS - ); - } - - /// 取出并删除上下文(回调校验用, 一次性) - public SocialAuthContext getAndRemove(String state) { - String key = KEY_PREFIX + state; - String json = stringRedisTemplate.opsForValue().get(key); - if (StrUtil.isBlank(json)) { - return null; - } - // 取出后立即删除, 防止重放 - stringRedisTemplate.delete(key); - return JSONUtil.toBean(json, SocialAuthContext.class); - } -} diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/social/cache/SocialAuthContext.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/social/cache/SocialAuthContext.java deleted file mode 100644 index 8e7bd4114..000000000 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/social/cache/SocialAuthContext.java +++ /dev/null @@ -1,28 +0,0 @@ -package cn.daxpay.open.platform.iam.service.social.cache; - -import lombok.Data; -import lombok.experimental.Accessors; - -/// # 社交登录授权上下文 -/// -/// 在 render 阶段生成并以 state 为键缓存, 在 exchange 阶段取出, 携带授权场景与用户信息 -/// -@Data -@Accessors(chain = true) -public class SocialAuthContext { - - /// 授权场景 - private SocialAuthMode mode; - - /// 终端编码 - private String clientCode; - - /// 本地用户ID(BIND 场景下为已登录用户) - private Long userId; - - /// 回调成功后前端跳转的相对路径(可选) - private String redirect; - - /// 平台来源(从 state 上下文恢复, 用于 exchange 时构建正确的 AuthRequest) - private String source; -}