From 28ce63a0e104e149e94197a8284e9303f2d4605e Mon Sep 17 00:00:00 2001 From: HopeLi <1278288511@qq.com> Date: Mon, 28 Jul 2025 16:02:09 +0800 Subject: [PATCH] =?UTF-8?q?0728=20ljc=20=20=E5=8D=87=E7=BA=A7=E4=B8=BAOIDC?= =?UTF-8?q?=E7=89=88=E6=9C=AC=EF=BC=8C=E8=8B=A5=E5=87=BA=E7=8E=B0=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E5=8F=AF=E9=80=89=E6=8B=A9=E5=9B=9E=E9=80=80=E7=89=88?= =?UTF-8?q?=E6=9C=AC=EF=BC=8Cversion=201.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/service/OAuth2GrantService.java | 11 ++ .../core/service/OAuth2GrantServiceImpl.java | 77 +++++++- .../security/dal/oauth2/OAuth2ClientDO.java | 10 ++ .../vo/vo/client/OAuth2ClientSaveReqVO.java | 6 + .../admin/oauth2/OAuth2OpenController.java | 165 +++++++++++++++--- .../admin/oauth2/OIDCUserInfoController.java | 56 ++++++ .../dataobject/oidc/OIDCUserInfoRespVO.java | 33 ++++ .../system/util/oauth2/OAuth2Utils.java | 76 ++++++++ .../oauth2/OAuth2OpenControllerTest.java | 12 +- 9 files changed, 413 insertions(+), 33 deletions(-) create mode 100644 modules/module-system-biz/src/main/java/cd/casic/module/system/controller/admin/oauth2/OIDCUserInfoController.java create mode 100644 modules/module-system-biz/src/main/java/cd/casic/module/system/dal/dataobject/oidc/OIDCUserInfoRespVO.java diff --git a/framework/spring-boot-starter-biz-tenant/src/main/java/cd/casic/framework/tenant/core/service/OAuth2GrantService.java b/framework/spring-boot-starter-biz-tenant/src/main/java/cd/casic/framework/tenant/core/service/OAuth2GrantService.java index f4584f04..9eb058ab 100644 --- a/framework/spring-boot-starter-biz-tenant/src/main/java/cd/casic/framework/tenant/core/service/OAuth2GrantService.java +++ b/framework/spring-boot-starter-biz-tenant/src/main/java/cd/casic/framework/tenant/core/service/OAuth2GrantService.java @@ -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 scopes, String nonce); } diff --git a/framework/spring-boot-starter-biz-tenant/src/main/java/cd/casic/framework/tenant/core/service/OAuth2GrantServiceImpl.java b/framework/spring-boot-starter-biz-tenant/src/main/java/cd/casic/framework/tenant/core/service/OAuth2GrantServiceImpl.java index 5765c38d..22c86d8c 100644 --- a/framework/spring-boot-starter-biz-tenant/src/main/java/cd/casic/framework/tenant/core/service/OAuth2GrantServiceImpl.java +++ b/framework/spring-boot-starter-biz-tenant/src/main/java/cd/casic/framework/tenant/core/service/OAuth2GrantServiceImpl.java @@ -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 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:8080") // 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 ""; + } + } + } diff --git a/framework/spring-boot-starter-security/src/main/java/cd/casic/framework/security/dal/oauth2/OAuth2ClientDO.java b/framework/spring-boot-starter-security/src/main/java/cd/casic/framework/security/dal/oauth2/OAuth2ClientDO.java index 70cd4b19..0b00f8dc 100644 --- a/framework/spring-boot-starter-security/src/main/java/cd/casic/framework/security/dal/oauth2/OAuth2ClientDO.java +++ b/framework/spring-boot-starter-security/src/main/java/cd/casic/framework/security/dal/oauth2/OAuth2ClientDO.java @@ -106,4 +106,14 @@ public class OAuth2ClientDO extends BaseDO { */ private String additionalInformation; + /** + * 是否支持 OIDC + */ + private Boolean oidcSupport = false; + + /** + * 默认响应类型 + */ + private String defaultResponseType = "code"; + } diff --git a/framework/spring-boot-starter-security/src/main/java/cd/casic/framework/security/vo/vo/client/OAuth2ClientSaveReqVO.java b/framework/spring-boot-starter-security/src/main/java/cd/casic/framework/security/vo/vo/client/OAuth2ClientSaveReqVO.java index 8f5a9ba5..e13f9e71 100644 --- a/framework/spring-boot-starter-security/src/main/java/cd/casic/framework/security/vo/vo/client/OAuth2ClientSaveReqVO.java +++ b/framework/spring-boot-starter-security/src/main/java/cd/casic/framework/security/vo/vo/client/OAuth2ClientSaveReqVO.java @@ -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); diff --git a/modules/module-system-biz/src/main/java/cd/casic/module/system/controller/admin/oauth2/OAuth2OpenController.java b/modules/module-system-biz/src/main/java/cd/casic/module/system/controller/admin/oauth2/OAuth2OpenController.java index 86239b81..5eab326d 100644 --- a/modules/module-system-biz/src/main/java/cd/casic/module/system/controller/admin/oauth2/OAuth2OpenController.java +++ b/modules/module-system-biz/src/main/java/cd/casic/module/system/controller/admin/oauth2/OAuth2OpenController.java @@ -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 authorize(@RequestParam("clientId") String clientId) { + public CommonResult 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 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 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 scopes = JsonUtils.parseObject(scope, Map.class); + + Map 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 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 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 handleOIDCResponse(OAuth2GrantTypeEnum grantTypeEnum, + Long userId, + OAuth2ClientDO client, + List 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 scopes, String nonce) { + return oauth2GrantService.grantIDToken(userId, getUserType(), clientId, scopes, nonce); + } } diff --git a/modules/module-system-biz/src/main/java/cd/casic/module/system/controller/admin/oauth2/OIDCUserInfoController.java b/modules/module-system-biz/src/main/java/cd/casic/module/system/controller/admin/oauth2/OIDCUserInfoController.java new file mode 100644 index 00000000..cf904f5a --- /dev/null +++ b/modules/module-system-biz/src/main/java/cd/casic/module/system/controller/admin/oauth2/OIDCUserInfoController.java @@ -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 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); + } +} diff --git a/modules/module-system-biz/src/main/java/cd/casic/module/system/dal/dataobject/oidc/OIDCUserInfoRespVO.java b/modules/module-system-biz/src/main/java/cd/casic/module/system/dal/dataobject/oidc/OIDCUserInfoRespVO.java new file mode 100644 index 00000000..173b398d --- /dev/null +++ b/modules/module-system-biz/src/main/java/cd/casic/module/system/dal/dataobject/oidc/OIDCUserInfoRespVO.java @@ -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; + + // 可根据需要添加更多标准字段 +} diff --git a/modules/module-system-biz/src/main/java/cd/casic/module/system/util/oauth2/OAuth2Utils.java b/modules/module-system-biz/src/main/java/cd/casic/module/system/util/oauth2/OAuth2Utils.java index d6dea293..c880aca9 100644 --- a/modules/module-system-biz/src/main/java/cd/casic/module/system/util/oauth2/OAuth2Utils.java +++ b/modules/module-system-biz/src/main/java/cd/casic/module/system/util/oauth2/OAuth2Utils.java @@ -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(); + } + } diff --git a/modules/module-system-biz/src/test/java/cd/casic/module/system/oauth2/OAuth2OpenControllerTest.java b/modules/module-system-biz/src/test/java/cd/casic/module/system/oauth2/OAuth2OpenControllerTest.java index 778cb0d3..be2091b1 100644 --- a/modules/module-system-biz/src/test/java/cd/casic/module/system/oauth2/OAuth2OpenControllerTest.java +++ b/modules/module-system-biz/src/test/java/cd/casic/module/system/oauth2/OAuth2OpenControllerTest.java @@ -205,7 +205,7 @@ public class OAuth2OpenControllerTest extends BaseMockitoUnitTest { when(oauth2ApproveService.getApproveList(isNull(), eq(UserTypeEnum.ADMIN.getValue()), eq(clientId))).thenReturn(approves); // 调用 - CommonResult result = oauth2OpenController.authorize(clientId); + CommonResult 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 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 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 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 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());