feat(system): 新增内部 SSO 回调换 Token 接口

面向业务/物联运维平台之间的互跳场景:已登录一端 → /system/oauth2/authorize 拿 code
→ 浏览器重定向 → 另一端调用本接口用 code 换 access_token。

安全要点:
- body 传参而非 query,code/state 不落 nginx access log 和浏览器历史
- client_secret 不传:secret 仅存 DB,验证安全性来自 OAuth2 code 一次性 +
  redirect_uri 白名单 + state 一致性 + short TTL
- state 入参改为必填(@NotBlank),强制 CSRF 防护
- 日志中 code 截断(前 6 + ***+ 末 2),state 只记录长度不暴露值

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-24 13:32:40 +08:00
parent cbbb048a4d
commit 4386a69a4a
2 changed files with 136 additions and 0 deletions

View File

@@ -0,0 +1,104 @@
package com.viewsh.module.system.controller.admin.sso;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.module.system.controller.admin.oauth2.vo.open.OAuth2OpenAccessTokenRespVO;
import com.viewsh.module.system.controller.admin.sso.vo.SsoCallbackReqVO;
import com.viewsh.module.system.convert.oauth2.OAuth2OpenConvert;
import com.viewsh.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
import com.viewsh.module.system.enums.oauth2.OAuth2GrantTypeEnum;
import com.viewsh.module.system.service.oauth2.OAuth2ClientService;
import com.viewsh.module.system.service.oauth2.OAuth2GrantService;
import cn.hutool.core.util.StrUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.annotation.security.PermitAll;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import static com.viewsh.framework.common.pojo.CommonResult.success;
/**
* SSO 回调接口
*
* 面向「接入方前端」:业务平台、物联运维平台等内部子系统。
* 子系统前端拿到 code 之后,调用本接口换取 access_token。
*
* 设计要点:
* 1. 本接口是内部 SSO 流程OAuth2 Server 和接入方属于同一套后端,
* 不需要传/校验 client_secret密钥仅存数据库由后台管理界面维护
* 2. 安全性保证来自 OAuth2 框架本身:
* - code 一次性、短期有效,生成时绑定 client_id + redirect_uri + userId
* - redirect_uri 必须匹配客户端的白名单validOAuthClientFromCache 会校验)
* - grant_type 必须是 authorization_code
*
* 场景示例(业务平台 → IoT 平台):
* 1. 用户已登录业务平台
* 2. 业务前端调用 POST /system/oauth2/authorize?client_id=iot&auto_approve=true&...
* 拿到带 code 的 redirect URL
* 3. 浏览器重定向至 iot.xxx.com/sso-callback?code=xxx
* 4. IoT 前端调用本接口clientId=iot, code=xxx, redirectUri=...)换取 token
* 5. IoT 前端存储 token进入主界面
*
* 反向IoT → 业务平台)同理,改 clientId=biz 即可(原 yudao 默认 default 客户端已改名为 biz
*/
@Tag(name = "管理后台 - SSO 回调")
@RestController
@RequestMapping("/system/sso")
@Validated
@Slf4j
public class SsoCallbackController {
@Resource
private OAuth2GrantService oauth2GrantService;
@Resource
private OAuth2ClientService oauth2ClientService;
@PostMapping("/callback")
@PermitAll
@Operation(summary = "SSO 授权码回调换 Token",
description = "前端在 sso-callback 路由拿到 code 后,调此接口换 access_token。"
+ "client_secret 不需要传,由 OAuth2 框架通过 client_id + redirect_uri + code 的绑定关系保证安全。"
+ "参数走 body 而非 query避免 code 出现在 nginx access log / 浏览器历史。")
public CommonResult<OAuth2OpenAccessTokenRespVO> callback(@Valid @RequestBody SsoCallbackReqVO reqVO) {
// code/state 是半机密凭证code 截断打印便于排障state 只打印是否存在,不暴露具体值
log.info("[callback][SSO 回调 clientId={} code={} redirectUri={} stateLen={}]",
reqVO.getClientId(), maskCode(reqVO.getCode()), reqVO.getRedirectUri(),
reqVO.getState() == null ? 0 : reqVO.getState().length());
// 1. 校验客户端的 grant_type 和 redirect_uri 白名单(不校验 secretsecret 仅存 DB
oauth2ClientService.validOAuthClientFromCache(
reqVO.getClientId(), null,
OAuth2GrantTypeEnum.AUTHORIZATION_CODE.getGrantType(),
null, reqVO.getRedirectUri());
// 2. 用 code 换 access_token一次性换完 code 失效)
// state 会和生成 code 时记录的值比对,必须传回原始 state
OAuth2AccessTokenDO accessTokenDO = oauth2GrantService
.grantAuthorizationCodeForAccessToken(
reqVO.getClientId(), reqVO.getCode(),
reqVO.getRedirectUri(), reqVO.getState());
// 3. 复用 OAuth2OpenConvert 转为 VO 返回
return success(OAuth2OpenConvert.INSTANCE.convert(accessTokenDO));
}
/**
* 授权码脱敏:只保留前 6 位 + 后 2 位,中间打 ***
*/
private static String maskCode(String code) {
if (StrUtil.isBlank(code)) {
return "";
}
if (code.length() <= 8) {
return "***";
}
return code.substring(0, 6) + "***" + code.substring(code.length() - 2);
}
}

View File

@@ -0,0 +1,32 @@
package com.viewsh.module.system.controller.admin.sso.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Schema(description = "管理后台 - SSO 回调换 Token Request VO")
@Data
public class SsoCallbackReqVO {
@Schema(description = "客户端编号biz=业务平台 / iot=物联运维平台)",
requiredMode = Schema.RequiredMode.REQUIRED, example = "iot")
@NotBlank(message = "clientId 不能为空")
private String clientId;
@Schema(description = "授权码(一次性,换完即失效)",
requiredMode = Schema.RequiredMode.REQUIRED, example = "abc123")
@NotBlank(message = "code 不能为空")
private String code;
@Schema(description = "重定向 URI必须与发起 authorize 时一致",
requiredMode = Schema.RequiredMode.REQUIRED,
example = "http://localhost:5667/sso-callback")
@NotBlank(message = "redirectUri 不能为空")
private String redirectUri;
@Schema(description = "发起授权时的随机串CSRF 防护 + 一致性校验",
requiredMode = Schema.RequiredMode.REQUIRED, example = "abc123")
@NotBlank(message = "state 不能为空")
private String state;
}