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:
@@ -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 白名单(不校验 secret,secret 仅存 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user