mirror of
https://gitee.com/dromara/dax-pay
synced 2026-08-12 15:35:38 +08:00
refactor: 社交登录/绑定回调分离, 移除 Redis 状态缓存
- SocialLoginService 拆分为 exchangeForLogin/exchangeForBind, 提取 doExchange 公共逻辑 - SocialEndpoint: exchange-login(公开) 和 exchange-bind(需登录) 独立端点 - SocialConfigService.buildAuthConfig 接受完整 redirectUri 参数 - 移除 RedisSocialStateCache 和 SocialAuthContext (state 不再携带业务上下文)
This commit is contained in:
@@ -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<String> 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<SocialExchangeResult> 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<SocialExchangeResult> 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<SocialExchangeResult> 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));
|
||||
}
|
||||
|
||||
/// 查询当前登录用户已绑定的第三方账号
|
||||
|
||||
@@ -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())) {
|
||||
|
||||
@@ -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<SocialBindResult> 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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user