refactor: 社交登录/绑定回调分离, 移除 Redis 状态缓存

- SocialLoginService 拆分为 exchangeForLogin/exchangeForBind, 提取 doExchange 公共逻辑
- SocialEndpoint: exchange-login(公开) 和 exchange-bind(需登录) 独立端点
- SocialConfigService.buildAuthConfig 接受完整 redirectUri 参数
- 移除 RedisSocialStateCache 和 SocialAuthContext (state 不再携带业务上下文)
This commit is contained in:
DaxPay Dev
2026-06-22 14:20:16 +08:00
parent 5dcf106518
commit 41ef4c423e
5 changed files with 111 additions and 170 deletions

View File

@@ -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));
}
/// 查询当前登录用户已绑定的第三方账号

View File

@@ -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())) {

View File

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

View File

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

View File

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