diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/sso/SsoCallbackController.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/sso/SsoCallbackController.java new file mode 100644 index 00000000..24bf0f4e --- /dev/null +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/sso/SsoCallbackController.java @@ -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 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); + } + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/sso/vo/SsoCallbackReqVO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/sso/vo/SsoCallbackReqVO.java new file mode 100644 index 00000000..f4497c60 --- /dev/null +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/sso/vo/SsoCallbackReqVO.java @@ -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; + +}