This commit is contained in:
even 2025-07-29 18:54:12 +08:00
commit 8e36c94130
20 changed files with 533 additions and 70 deletions

View File

@ -112,4 +112,15 @@ public interface OAuth2GrantService {
*/
boolean revokeToken(String clientId, String accessToken);
/**
* 生成 ID Token
*
* @param userId 用户ID
* @param userType 用户类型
* @param clientId 客户端ID
* @param scopes 授权范围
* @return ID Token
*/
String grantIDToken(Long userId, Integer userType, String clientId, List<String> scopes, String nonce);
}

View File

@ -1,20 +1,29 @@
package cd.casic.framework.tenant.core.service;
import cd.casic.framework.commons.enums.UserTypeEnum;
import cd.casic.framework.datapermission.service.user.AdminUserService;
import cd.casic.framework.datapermission.service.user.OAuth2TokenService;
import cd.casic.framework.security.dal.oauth2.OAuth2AccessTokenDO;
import cd.casic.framework.security.dal.oauth2.OAuth2CodeDO;
import cd.casic.framework.security.dal.user.AdminUserDO;
import cd.casic.framework.security.oauth2.OAuth2CodeService;
import cd.casic.module.system.enums.ErrorCodeConstants;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cd.casic.framework.commons.enums.UserTypeEnum;
import cd.casic.module.system.enums.ErrorCodeConstants;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.JWSSigner;
import com.nimbusds.jose.crypto.MACSigner;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import jakarta.annotation.Resource;
import java.util.Date;
import java.util.List;
import static cd.casic.framework.commons.exception.util.ServiceExceptionUtil.exception;
@ -24,6 +33,7 @@ import static cd.casic.framework.commons.exception.util.ServiceExceptionUtil.exc
*
* @author mianbin modified from yudao
*/
@Slf4j
@Service
public class OAuth2GrantServiceImpl implements OAuth2GrantService {
@ -33,6 +43,8 @@ public class OAuth2GrantServiceImpl implements OAuth2GrantService {
private OAuth2CodeService oauth2CodeService;
@Resource
private AdminAuthService adminAuthService;
@Resource
private AdminUserService adminUserService;
@Override
public OAuth2AccessTokenDO grantImplicit(Long userId, Integer userType,
@ -104,4 +116,61 @@ public class OAuth2GrantServiceImpl implements OAuth2GrantService {
return oauth2TokenService.removeAccessToken(accessToken) != null;
}
@Override
public String grantIDToken(Long userId, Integer userType, String clientId, List<String> scopes, String nonce) {
try {
// 获取用户信息
AdminUserDO user = adminUserService.getUser(userId);
if (user == null) {
throw new IllegalArgumentException("用户不存在");
}
// 使用 nimbus-jose-jwt 库生成 ID Token
// JWT 头部
JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.HS256)
.type(JOSEObjectType.JWT)
.build();
// JWT 载荷
JWTClaimsSet.Builder claimsBuilder = new JWTClaimsSet.Builder()
.issuer("http://localhost:48080") // iss: 发行者
.subject(String.valueOf(userId)) // sub: 主题
.audience(clientId) // aud: 受众
.expirationTime(new Date(System.currentTimeMillis() + 3600000)) // exp: 过期时间 (1小时)
.issueTime(new Date()) // iat: 签发时间
.claim("auth_time", new Date()); // auth_time: 认证时间
// 添加 nonce如果提供
if (StrUtil.isNotBlank(nonce)) {
claimsBuilder.claim("nonce", nonce);
}
// 添加 OIDC 标准声明
if (scopes.contains("profile")) {
claimsBuilder.claim("name", user.getNickname());
claimsBuilder.claim("preferred_username", user.getUsername());
}
if (scopes.contains("email")) {
claimsBuilder.claim("email", user.getEmail());
claimsBuilder.claim("email_verified", StrUtil.isNotBlank(user.getEmail()));
}
JWTClaimsSet claimsSet = claimsBuilder.build();
// 签名
SignedJWT signedJWT = new SignedJWT(header, claimsSet);
// 创建 HMAC signer (实际项目中应使用配置的密钥)
JWSSigner signer = new MACSigner("your-oidc-secret-key-min-32-chars"); // 密钥至少32字符
signedJWT.sign(signer);
return signedJWT.serialize();
} catch (Exception e) {
log.error("生成 ID Token 失败", e);
return "";
}
}
}

View File

@ -106,4 +106,14 @@ public class OAuth2ClientDO extends BaseDO {
*/
private String additionalInformation;
/**
* 是否支持 OIDC
*/
private Boolean oidcSupport = false;
/**
* 默认响应类型
*/
private String defaultResponseType = "code";
}

View File

@ -75,6 +75,12 @@ public class OAuth2ClientSaveReqVO {
@Schema(description = "附加信息", example = "{yunai: true}")
private String additionalInformation;
@Schema(description = "是否支持 OIDC", example = "true")
private Boolean oidcSupport = false;
@Schema(description = "默认响应类型", example = "code id_token")
private String defaultResponseType = "code";
@AssertTrue(message = "附加信息必须是 JSON 格式")
public boolean isAdditionalInformationJson() {
return StrUtil.isEmpty(additionalInformation) || JsonUtils.isJson(additionalInformation);

View File

@ -1,4 +1,4 @@
package cd.casic.ci.process.properties;
package cd.casic.ci.commons.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

View File

@ -27,6 +27,10 @@
<groupId>cd.casic.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>cd.casic.boot</groupId>
<artifactId>module-ci-machine</artifactId>
</dependency>
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java</artifactId>
@ -61,6 +65,10 @@
<artifactId>jakarta.ws.rs-api</artifactId>
<version>3.1.0</version>
</dependency>
<dependency>
<groupId>com.jcraft</groupId>
<artifactId>jsch</artifactId>
</dependency>
</dependencies>

View File

@ -7,6 +7,8 @@ import cd.casic.module.execute.docker.dataobject.model.DockerImage;
import cd.casic.module.execute.docker.dataobject.vo.DockerImagePageReqVO;
import cd.casic.module.execute.docker.service.IImageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
@ -47,4 +49,14 @@ public class DockerImageController {
return success(imageService.getLocalImagePage(pageVO));
}
@GetMapping("/localImagePush")
@Operation(summary = "镜像推送,或者部署到目标服务器")
@Parameters({
@Parameter(name = "imageId", description = "镜像id"),
@Parameter(name = "machineId", description = "主机id")
})
@PreAuthorize("@ss.hasPermission('docker:images:localImagePush')")
public CommonResult localImagePush(@RequestParam(value = "imageId") String imageId, @RequestParam("machineId") String machineId) {
return success(imageService.localImagePush(imageId,machineId));
}
}

View File

@ -1,20 +0,0 @@
package cd.casic.module.execute.docker.dataobject.convert;
import cd.casic.module.execute.docker.dataobject.dto.DockerImageDo;
import cd.casic.module.execute.docker.dataobject.model.DockerImage;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
/**
*
* @author Yuru Pu
* @version 1.0
* @since 2025/7/23 17:16
*/
@Mapper(componentModel = "spring")
public interface DockerImageConvert {
DockerImageConvert INSTANCE = Mappers.getMapper(DockerImageConvert.class);
DockerImageDo convert(DockerImage dockerImage);
}

View File

@ -1,8 +1,6 @@
package cd.casic.module.execute.docker.dataobject.model;
import cd.casic.framework.commons.dataobject.BaseDO;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 本地镜像
@ -12,8 +10,7 @@ import lombok.experimental.Accessors;
* @since 2025/7/23 16:22
*/
@Data
@Accessors(chain = true)
public class DockerImage extends BaseDO {
public class DockerImage {
private String id;

View File

@ -109,4 +109,6 @@ public interface IImageService {
int localImageUpload(DockerImage dockerImage);
PageResult<DockerImageDo> getLocalImagePage(DockerImagePageReqVO pageVO);
Boolean localImagePush(String imageId, String machineId);
}

View File

@ -1,22 +1,35 @@
package cd.casic.module.execute.docker.service.impl;
import cd.casic.ci.commons.properties.TargetFileUploadProperties;
import cd.casic.framework.commons.exception.ServerException;
import cd.casic.framework.commons.pojo.PageResult;
import cd.casic.framework.mybatis.core.query.LambdaQueryWrapperX;
import cd.casic.module.execute.docker.DockerClientFactory;
import cd.casic.module.execute.docker.dao.DockerImageDao;
import cd.casic.module.execute.docker.dataobject.convert.DockerImageConvert;
import cd.casic.module.execute.docker.dataobject.dto.DockerImageDo;
import cd.casic.module.execute.docker.dataobject.model.DockerImage;
import cd.casic.module.execute.docker.dataobject.vo.DockerImagePageReqVO;
import cd.casic.module.execute.docker.service.IImageService;
import cd.casic.module.machine.contants.MachineErrorCodeConstants;
import cd.casic.module.machine.dal.dataobject.MachineInfoDO;
import cd.casic.module.machine.dal.dataobject.SecretKeyDO;
import cd.casic.module.machine.dal.mysql.MachineInfoMapper;
import cd.casic.module.machine.dal.mysql.SecretKeyMapper;
import cd.casic.module.machine.enums.AuthenticationType;
import cd.casic.module.machine.utils.CryptogramUtil;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.IoUtil;
import cn.hutool.extra.ftp.FtpConfig;
import cn.hutool.extra.ssh.JschUtil;
import cn.hutool.extra.ssh.Sftp;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.command.InspectImageResponse;
import com.github.dockerjava.api.command.SaveImageCmd;
import com.github.dockerjava.api.exception.NotFoundException;
import com.github.dockerjava.api.model.Image;
import com.github.dockerjava.api.model.PruneType;
import com.jcraft.jsch.Session;
import jakarta.annotation.Nonnull;
import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
@ -24,11 +37,11 @@ import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
/**
* @description: 镜像的服务类
@ -46,6 +59,15 @@ public class ImageService implements IImageService {
@Resource
private DockerImageDao dockerImageDao;
@Resource
private MachineInfoMapper machineInfoMapper;
@Resource
private TargetFileUploadProperties fileUploadProperties;
@Resource
private SecretKeyMapper secretKeyMapper;
@Override
public List<Image> list(@Nonnull String clientId) {
DockerClient dockerClient = dockerClientFactory.getdockerClient(clientId);
@ -144,7 +166,9 @@ public class ImageService implements IImageService {
@Transactional(rollbackFor = Exception.class)
@Override
public int localImageUpload(DockerImage dockerImage) {
return dockerImageDao.insert(DockerImageConvert.INSTANCE.convert(dockerImage));
DockerImageDo imageDo = new DockerImageDo();
BeanUtil.copyProperties(dockerImage,imageDo);
return dockerImageDao.insert(imageDo);
}
@Override
@ -153,4 +177,66 @@ public class ImageService implements IImageService {
PageResult<DockerImageDo> page = dockerImageDao.selectPage(pageVO, queryWrapperX.likeIfPresent(DockerImageDo::getName, pageVO.getName()).orderByDesc(DockerImageDo::getCreateTime));
return page;
}
/**
* 先下载后上传到目标主机 先确定目标主机是否可连接在继续
* @param imageId
* @param machineId
* @return
*/
@Override
public Boolean localImagePush(String imageId, String machineId) {
if (Objects.isNull(imageId) || Objects.isNull(machineId)) {
throw new ServerException(MachineErrorCodeConstants.MACHINE_INFO_TAG_NULL);
}
// 1 建立连接 区分密码还是秘钥
MachineInfoDO machineInfoDO = machineInfoMapper.selectById(machineId);
FtpConfig targetFtp = FtpConfig.create().setHost(machineInfoDO.getHostIp()).setPort(machineInfoDO.getSshPort()).setUser(machineInfoDO.getUsername()).setPassword(CryptogramUtil.doDecrypt(machineInfoDO.getPassword()));
if (AuthenticationType.of(machineInfoDO.getAuthenticationType()).equals(AuthenticationType.SECRET_KEY)) {
SecretKeyDO secretKeyDO = secretKeyMapper.selectById(machineInfoDO.getSecretKeyId());
targetFtp.setSystemKey(CryptogramUtil.doDecrypt(secretKeyDO.getPrivateKey()));
targetFtp.setPassword(CryptogramUtil.doDecrypt(secretKeyDO.getPassword()));
}
// 2 拿到目标主机连接
Sftp uploadSftp = getSftp(targetFtp);
//远程下载
DockerImageDo imageDo = dockerImageDao.selectById(imageId);
Sftp sftp = getSftp(FtpConfig.create().setHost(fileUploadProperties.getRemoteHost()).setPort(fileUploadProperties.getRemotePort()).setUser(fileUploadProperties.getUsername()).setPassword(fileUploadProperties.getPassword()));
String srcPath = imageDo.getPath();
ByteArrayOutputStream byteOut = new ByteArrayOutputStream();
sftp.download(srcPath, byteOut); //远程下载到中间缓存区
sftp.close();
//远程上传
String suffix = FileUtil.getSuffix(srcPath);
String fileName = imageDo.getName() + "." + suffix;
ByteArrayInputStream arrayInputStream = new ByteArrayInputStream(byteOut.toByteArray()); //转换
String romPath = "/home/image/" + UUID.randomUUID()+"/";
uploadSftp.mkDirs(romPath);//递归创建
uploadSftp.upload(romPath, fileName, arrayInputStream);
uploadSftp.close();
return true;
}
/**
*
* 获取Sftp连接
* @param config 连接配置
* @return
*/
private Sftp getSftp(FtpConfig config) {
Session session;
Integer timeOut = 5000;
try {
if (Objects.nonNull(config.getSystemKey())) {
session = JschUtil.openSession(config.getHost(), config.getPort(), config.getUser(), config.getSystemKey().getBytes(StandardCharsets.UTF_8), config.getPassword().getBytes(), timeOut);
} else {
session = JschUtil.openSession(config.getHost(), config.getPort(), config.getUser(), config.getPassword(), timeOut);
}
} catch (Exception e) {
log.error("与主机IP:{} 建立SSH连接失败", config.getHost());
throw new RuntimeException("部署失败,请检测主机是否可用", e);
}
return new Sftp(session);
}
}

View File

@ -3,7 +3,7 @@ package cd.casic.ci.process.process.service.sftpFile.impl;
import cd.casic.ci.commons.sftp.SftpClientUtils;
import cd.casic.ci.process.process.service.sftpFile.SftpFileService;
import cd.casic.ci.process.properties.TargetFileUploadProperties;
import cd.casic.ci.commons.properties.TargetFileUploadProperties;
import com.amazonaws.util.IOUtils;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;

View File

@ -15,7 +15,7 @@ import cd.casic.ci.process.process.dataObject.target.TargetVersion;
import cd.casic.ci.process.process.service.pipeline.PipelineService;
import cd.casic.ci.process.process.service.sftpFile.impl.SftpFileServiceImpl;
import cd.casic.ci.process.process.service.target.TargetManagerService;
import cd.casic.ci.process.properties.TargetFileUploadProperties;
import cd.casic.ci.commons.properties.TargetFileUploadProperties;
import cd.casic.ci.process.util.SftpUploadUtil;
import cd.casic.framework.commons.exception.ServiceException;
import cd.casic.framework.commons.exception.enums.GlobalErrorCodeConstants;

View File

@ -13,7 +13,7 @@ import cd.casic.ci.process.process.dataObject.testCase.TestCaseInfo;
import cd.casic.ci.process.process.dataObject.testCase.TestCaseManager;
import cd.casic.ci.process.process.service.testCase.TestCaseInfoService;
import cd.casic.ci.process.process.service.testCase.TestCaseManagerService;
import cd.casic.ci.process.properties.TargetFileUploadProperties;
import cd.casic.ci.commons.properties.TargetFileUploadProperties;
import cd.casic.framework.commons.exception.ServiceException;
import cd.casic.framework.commons.exception.enums.GlobalErrorCodeConstants;
import cd.casic.framework.commons.pojo.PageResult;

View File

@ -1,38 +1,41 @@
package cd.casic.module.system.controller.admin.oauth2;
import cd.casic.framework.security.dal.oauth2.OAuth2AccessTokenDO;
import cd.casic.framework.security.dal.oauth2.OAuth2ApproveDO;
import cd.casic.framework.security.dal.oauth2.OAuth2ClientDO;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cd.casic.framework.commons.enums.UserTypeEnum;
import cd.casic.framework.commons.pojo.CommonResult;
import cd.casic.framework.commons.util.http.HttpUtils;
import cd.casic.framework.commons.util.json.JsonUtils;
import cd.casic.framework.datapermission.service.user.AdminUserService;
import cd.casic.framework.datapermission.service.user.OAuth2TokenService;
import cd.casic.framework.security.dal.oauth2.OAuth2AccessTokenDO;
import cd.casic.framework.security.dal.oauth2.OAuth2ApproveDO;
import cd.casic.framework.security.dal.oauth2.OAuth2ClientDO;
import cd.casic.framework.security.oauth2.OAuth2ApproveService;
import cd.casic.framework.security.oauth2.OAuth2ClientService;
import cd.casic.framework.security.vo.vo.open.OAuth2OpenAccessTokenRespVO;
import cd.casic.framework.security.vo.vo.open.OAuth2OpenAuthorizeInfoRespVO;
import cd.casic.framework.security.vo.vo.open.OAuth2OpenCheckTokenRespVO;
import cd.casic.framework.tenant.core.service.OAuth2GrantService;
import cd.casic.module.system.convert.oauth2.OAuth2OpenConvert;
import cd.casic.module.system.enums.oauth2.OAuth2GrantTypeEnum;
import cd.casic.framework.security.oauth2.OAuth2ApproveService;
import cd.casic.framework.security.oauth2.OAuth2ClientService;
import cd.casic.framework.tenant.core.service.OAuth2GrantService;
import cd.casic.framework.datapermission.service.user.OAuth2TokenService;
import cd.casic.module.system.util.oauth2.OAuth2Utils;
import io.swagger.v3.oas.annotations.tags.Tag;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
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.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import jakarta.annotation.Resource;
import jakarta.annotation.security.PermitAll;
import jakarta.servlet.http.HttpServletRequest;
import java.time.ZoneId;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
@ -69,6 +72,8 @@ public class OAuth2OpenController {
private OAuth2ApproveService oauth2ApproveService;
@Resource
private OAuth2TokenService oauth2TokenService;
@Resource
private AdminUserService adminUserService;
/**
* 对应 Spring Security OAuth TokenEndpoint 类的 postAccessToken 方法
@ -181,13 +186,36 @@ public class OAuth2OpenController {
@GetMapping("/authorize")
@Operation(summary = "获得授权信息", description = "适合 code 授权码模式,或者 implicit 简化模式;在 sso.vue 单点登录界面被【获取】调用")
@Parameter(name = "clientId", required = true, description = "客户端编号", example = "tudou")
public CommonResult<OAuth2OpenAuthorizeInfoRespVO> authorize(@RequestParam("clientId") String clientId) {
public CommonResult<OAuth2OpenAuthorizeInfoRespVO> authorize(@RequestParam("clientId") String clientId,
@RequestParam(value = "response_type", required = false) String responseType) {
// // 0. 校验用户已经登录通过 Spring Security 实现
//
// // 1. 获得 Client 客户端的信息
// OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientId);
// // 2. 获得用户已经授权的信息
// List<OAuth2ApproveDO> approves = oauth2ApproveService.getApproveList(getLoginUserId(), getUserType(), clientId);
// // 拼接返回
// return success(OAuth2OpenConvert.INSTANCE.convert(client, approves));
// 0. 校验用户已经登录通过 Spring Security 实现
// 1. 获得 Client 客户端的信息
OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientId);
// 2. 获得用户已经授权的信息
// 2. 检查是否为 OIDC 请求
boolean isOIDCRequest = isOIDCRequest(responseType, client);
// 3. 获得用户已经授权的信息
List<OAuth2ApproveDO> approves = oauth2ApproveService.getApproveList(getLoginUserId(), getUserType(), clientId);
// 4. 如果是 OIDC 请求确保 openid scope 被包含
if (isOIDCRequest) {
// 可以在这里添加特殊的处理逻辑
}
// 拼接返回
return success(OAuth2OpenConvert.INSTANCE.convert(client, approves));
}
@ -217,9 +245,11 @@ public class OAuth2OpenController {
@RequestParam(value = "scope", required = false) String scope,
@RequestParam("redirect_uri") String redirectUri,
@RequestParam(value = "auto_approve") Boolean autoApprove,
@RequestParam(value = "state", required = false) String state) {
@RequestParam(value = "state", required = false) String state,
@RequestParam(value = "nonce", required = false) String nonce) {
@SuppressWarnings("unchecked")
Map<String, Boolean> scopes = JsonUtils.parseObject(scope, Map.class);
Map<String, Boolean> scopes = JsonUtils.parseObject(scope, Map.class);
scopes = ObjectUtil.defaultIfNull(scopes, Collections.emptyMap());
// 0. 校验用户已经登录通过 Spring Security 实现
@ -229,6 +259,9 @@ public class OAuth2OpenController {
OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientId, null,
grantTypeEnum.getGrantType(), scopes.keySet(), redirectUri);
// 检查是否为 OIDC 请求
boolean isOIDCRequest = checkOIDCRequest(responseType, scopes);
// 2.1 假设 approved null说明是场景一
if (Boolean.TRUE.equals(autoApprove)) {
// 如果无法自动授权通过则返回空 url前端不进行跳转
@ -243,12 +276,17 @@ public class OAuth2OpenController {
}
}
// 3.1 如果是 code 授权码模式则发放 code 授权码并重定向
// 3. 根据不同的响应类型处理
List<String> approveScopes = convertList(scopes.entrySet(), Map.Entry::getKey, Map.Entry::getValue);
if (grantTypeEnum == OAuth2GrantTypeEnum.AUTHORIZATION_CODE) {
if (isOIDCRequest && responseType.contains("id_token")) {
// 处理包含 ID Token 的响应类型
return handleOIDCResponse(grantTypeEnum, getLoginUserId(), client, approveScopes, redirectUri, state, responseType, nonce);
} else if (grantTypeEnum == OAuth2GrantTypeEnum.AUTHORIZATION_CODE) {
return success(getAuthorizationCodeRedirect(getLoginUserId(), client, approveScopes, redirectUri, state));
} else if (grantTypeEnum == OAuth2GrantTypeEnum.IMPLICIT) {
return success(getImplicitGrantRedirect(getLoginUserId(), client, approveScopes, redirectUri, state));
}
// 3.2 如果是 token 则是 implicit 简化模式则发送 accessToken 访问令牌并重定向
return success(getImplicitGrantRedirect(getLoginUserId(), client, approveScopes, redirectUri, state));
}
@ -294,4 +332,85 @@ public class OAuth2OpenController {
return clientIdAndSecret;
}
// 添加判断是否为 OIDC 请求的方法
private boolean isOIDCRequest(String responseType, OAuth2ClientDO client) {
// 检查客户端是否支持 OIDC
if (client.getOidcSupport() == null || !client.getOidcSupport()) {
return false;
}
// 检查 response_type 是否包含 OIDC 相关类型
if (responseType != null &&
(responseType.contains("id_token") || "token".equals(responseType))) {
return true;
}
// 检查客户端默认响应类型
if (client.getDefaultResponseType() != null &&
(client.getDefaultResponseType().contains("id_token") ||
"token".equals(client.getDefaultResponseType()))) {
return true;
}
return false;
}
// 检查是否为 OIDC 请求
private boolean checkOIDCRequest(String responseType, Map<String, Boolean> scopes) {
// 检查 scope 中是否包含 openid
if (scopes.containsKey("openid")) {
return true;
}
// 检查 responseType 是否包含 id_token
if (responseType != null && responseType.contains("id_token")) {
return true;
}
return false;
}
// 处理 OIDC 响应
private CommonResult<String> handleOIDCResponse(OAuth2GrantTypeEnum grantTypeEnum,
Long userId,
OAuth2ClientDO client,
List<String> scopes,
String redirectUri,
String state,
String responseType,
String nonce) {
if (responseType.equals("code id_token")) {
// Hybrid Flow
String authorizationCode = oauth2GrantService.grantAuthorizationCodeForCode(
userId, getUserType(), client.getClientId(), scopes, redirectUri, state);
// 创建 ID Token
String idToken = createIDTokenWithNonce(userId, client.getClientId(), scopes, nonce);
return success(OAuth2Utils.buildHybridRedirectUri(redirectUri, authorizationCode, idToken, state));
} else if (responseType.equals("id_token")) {
// Implicit Flow with ID Token only
String idToken = createIDTokenWithNonce(userId, client.getClientId(), scopes, nonce);
return success(OAuth2Utils.buildIDTokenRedirectUri(redirectUri, idToken, state));
} else if (responseType.equals("id_token token")) {
// Implicit Flow with Access Token and ID Token
OAuth2AccessTokenDO accessTokenDO = oauth2GrantService.grantImplicit(
userId, getUserType(), client.getClientId(), scopes);
String idToken = createIDTokenWithNonce(userId, client.getClientId(), scopes, nonce);
return success(OAuth2Utils.buildImplicitWithIDTokenRedirectUri(
redirectUri, accessTokenDO.getAccessToken(), idToken, state, Date.from(accessTokenDO.getExpiresTime().atZone(ZoneId.systemDefault()).toInstant())));
}
// 当没有匹配的响应类型时抛出异常
throw exception0(BAD_REQUEST.getCode(), StrUtil.format("不支持的 OIDC 响应类型: {}", responseType));
}
// 创建 ID Token
// 添加带 nonce ID Token 创建方法
private String createIDTokenWithNonce(Long userId, String clientId, List<String> scopes, String nonce) {
return oauth2GrantService.grantIDToken(userId, getUserType(), clientId, scopes, nonce);
}
}

View File

@ -0,0 +1,56 @@
package cd.casic.module.system.controller.admin.oauth2;
import cd.casic.framework.commons.pojo.CommonResult;
import cd.casic.framework.datapermission.service.user.AdminUserService;
import cd.casic.framework.security.core.util.SecurityFrameworkUtils;
import cd.casic.framework.security.dal.user.AdminUserDO;
import cd.casic.module.system.dal.dataobject.oidc.OIDCUserInfoRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author HopeLi
* @version v1.0
* @ClassName OIDCUserInfoController
* @Date: 2025/7/28 10:56
* @Description:
*/
@Tag(name = "管理后台 - OIDC 用户信息")
@RestController
@RequestMapping("/system/oidc")
@Validated
@Slf4j
public class OIDCUserInfoController {
@Resource
private AdminUserService userService;
@GetMapping("/userinfo")
@Operation(summary = "获取 OIDC 用户信息")
@PreAuthorize("@ss.hasScope('openid')")
public CommonResult<OIDCUserInfoRespVO> getUserInfo() {
// 获得用户基本信息
Long userId = SecurityFrameworkUtils.getLoginUserId();
AdminUserDO user = userService.getUser(userId);
if (user == null) {
return CommonResult.error(404, "用户不存在");
}
OIDCUserInfoRespVO userInfo = new OIDCUserInfoRespVO();
userInfo.setSub(String.valueOf(user.getId()));
userInfo.setName(user.getNickname());
userInfo.setNickname(user.getNickname());
userInfo.setEmail(user.getEmail());
// 如果有头像字段可以设置
// userInfo.setPicture(user.getAvatar());
return CommonResult.success(userInfo);
}
}

View File

@ -0,0 +1,33 @@
package cd.casic.module.system.dal.dataobject.oidc;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* @author HopeLi
* @version v1.0
* @ClassName OIDCUserInfoRespVO
* @Date: 2025/7/28 10:21
* @Description:
*/
@Schema(description = "OIDC 用户信息 Response VO")
@Data
public class OIDCUserInfoRespVO {
@Schema(description = "用户子标识", example = "123")
private String sub;
@Schema(description = "用户名", example = "tudou")
private String name;
@Schema(description = "用户昵称", example = "土豆")
private String nickname;
@Schema(description = "邮箱", example = "tudou@iocoder.cn")
private String email;
@Schema(description = "头像地址", example = "https://www.iocoder.cn/avatar.jpg")
private String picture;
// 可根据需要添加更多标准字段
}

View File

@ -100,4 +100,80 @@ public class OAuth2Utils {
return StrUtil.split(scope, ' ');
}
/**
* 判断是否为 OIDC 请求
*/
public static boolean isOIDCRequest(String responseType, String scope) {
// 检查 scope 中是否包含 openid
if (StrUtil.isNotBlank(scope) && scope.contains("openid")) {
return true;
}
// 检查 responseType 是否包含 id_token
if (StrUtil.isNotBlank(responseType) && responseType.contains("id_token")) {
return true;
}
return false;
}
/**
* 构建带有 ID Token 的重定向 URI (response_type=id_token)
*/
public static String buildIDTokenRedirectUri(String redirectUri, String idToken, String state) {
StringBuilder sb = new StringBuilder();
sb.append(redirectUri);
if (!redirectUri.contains("?")) {
sb.append("?");
} else {
sb.append("&");
}
sb.append("id_token=").append(idToken);
if (StrUtil.isNotBlank(state)) {
sb.append("&state=").append(state);
}
return sb.toString();
}
/**
* 构建带有 Access Token ID Token 的重定向 URI (response_type=id_token token)
*/
public static String buildImplicitWithIDTokenRedirectUri(String redirectUri, String accessToken,
String idToken, String state, Date expiresTime) {
StringBuilder sb = new StringBuilder();
sb.append(redirectUri);
if (!redirectUri.contains("?")) {
sb.append("?");
} else {
sb.append("&");
}
sb.append("access_token=").append(accessToken);
sb.append("&id_token=").append(idToken);
sb.append("&token_type=Bearer");
sb.append("&expires_in=").append((expiresTime.getTime() - System.currentTimeMillis()) / 1000);
if (StrUtil.isNotBlank(state)) {
sb.append("&state=").append(state);
}
return sb.toString();
}
/**
* 构建混合流程的重定向 URI (response_type=code id_token)
*/
public static String buildHybridRedirectUri(String redirectUri, String code, String idToken, String state) {
StringBuilder sb = new StringBuilder();
sb.append(redirectUri);
if (!redirectUri.contains("?")) {
sb.append("?");
} else {
sb.append("&");
}
sb.append("code=").append(code);
sb.append("&id_token=").append(idToken);
if (StrUtil.isNotBlank(state)) {
sb.append("&state=").append(state);
}
return sb.toString();
}
}

View File

@ -205,7 +205,7 @@ public class OAuth2OpenControllerTest extends BaseMockitoUnitTest {
when(oauth2ApproveService.getApproveList(isNull(), eq(UserTypeEnum.ADMIN.getValue()), eq(clientId))).thenReturn(approves);
// 调用
CommonResult<OAuth2OpenAuthorizeInfoRespVO> result = oauth2OpenController.authorize(clientId);
CommonResult<OAuth2OpenAuthorizeInfoRespVO> result = oauth2OpenController.authorize(clientId,null);
// 断言
assertEquals(0, result.getCode());
assertPojoEquals(client, result.getData().getClient());
@ -218,7 +218,7 @@ public class OAuth2OpenControllerTest extends BaseMockitoUnitTest {
public void testApproveOrDeny_grantTypeError() {
// 调用并断言
assertServiceException(() -> oauth2OpenController.approveOrDeny(randomString(), null,
null, null, null, null),
null, null, null, null,null),
new ErrorCode(400, "response_type 参数值只允许 code 和 token"));
}
@ -237,7 +237,7 @@ public class OAuth2OpenControllerTest extends BaseMockitoUnitTest {
// 调用
CommonResult<String> result = oauth2OpenController.approveOrDeny(responseType, clientId,
scope, redirectUri, true, state);
scope, redirectUri, true, state,null);
// 断言
assertEquals(0, result.getCode());
assertNull(result.getData());
@ -258,7 +258,7 @@ public class OAuth2OpenControllerTest extends BaseMockitoUnitTest {
// 调用
CommonResult<String> result = oauth2OpenController.approveOrDeny(responseType, clientId,
scope, redirectUri, false, state);
scope, redirectUri, false, state,null);
// 断言
assertEquals(0, result.getCode());
assertEquals("https://www.iocoder.cn#error=access_denied&error_description=User%20denied%20access&state=test", result.getData());
@ -287,7 +287,7 @@ public class OAuth2OpenControllerTest extends BaseMockitoUnitTest {
// 调用
CommonResult<String> result = oauth2OpenController.approveOrDeny(responseType, clientId,
scope, redirectUri, true, state);
scope, redirectUri, true, state,null);
// 断言
assertEquals(0, result.getCode());
assertThat(result.getData(), anyOf( // 29 30 都有一定概率主要是时间计算
@ -319,7 +319,7 @@ public class OAuth2OpenControllerTest extends BaseMockitoUnitTest {
// 调用
CommonResult<String> result = oauth2OpenController.approveOrDeny(responseType, clientId,
scope, redirectUri, false, state);
scope, redirectUri, false, state,null);
// 断言
assertEquals(0, result.getCode());
assertEquals("https://www.iocoder.cn?code=test_code&state=test", result.getData());

View File

@ -1,10 +1,8 @@
package cd.casic.server;
import cd.casic.ci.process.properties.TargetFileUploadProperties;
import cd.casic.ci.process.util.SftpUploadUtil;
import cd.casic.ci.commons.properties.TargetFileUploadProperties;
import com.jcraft.jsch.*;
import jakarta.annotation.Resource;
import jodd.io.IOUtil;
import org.apache.commons.io.IOUtils;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;