Explorar o código

完成 oauth2 code 授权码模式的实现

YunaiV %!s(int64=3) %!d(string=hai) anos
pai
achega
99ba7ccee8
Modificáronse 14 ficheiros con 267 adicións e 31 borrados
  1. 28 0
      yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/http/HttpUtils.java
  2. 2 2
      yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/util/SecurityFrameworkUtils.java
  3. 9 2
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java
  4. 5 0
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/auth/OAuth2GrantTypeEnum.java
  5. 8 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2Controller.http
  6. 72 12
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2OpenController.java
  7. 32 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/open/OAuth2OpenAccessTokenRespVO.java
  8. 24 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/convert/oauth2/OAuth2OpenConvert.java
  9. 2 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/security/config/SecurityConfiguration.java
  10. 4 3
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2ClientService.java
  11. 7 2
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2ClientServiceImpl.java
  12. 30 4
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2GrantService.java
  13. 38 3
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2GrantServiceImpl.java
  14. 6 3
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/util/oauth2/OAuth2Utils.java

+ 28 - 0
yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/http/HttpUtils.java

@@ -1,11 +1,15 @@
 package cn.iocoder.yudao.framework.common.util.http;
 
+import cn.hutool.core.codec.Base64;
 import cn.hutool.core.map.TableMap;
 import cn.hutool.core.net.url.UrlBuilder;
 import cn.hutool.core.util.ReflectUtil;
+import cn.hutool.core.util.StrUtil;
+import org.springframework.util.StringUtils;
 import org.springframework.web.util.UriComponents;
 import org.springframework.web.util.UriComponentsBuilder;
 
+import javax.servlet.http.HttpServletRequest;
 import java.net.URI;
 import java.nio.charset.Charset;
 import java.util.Map;
@@ -95,4 +99,28 @@ public class HttpUtils {
         return builder.build().toUriString();
     }
 
+    public static String[] obtainBasicAuthorization(HttpServletRequest request) {
+        String clientId;
+        String clientSecret;
+        // 先从 Header 中获取
+        String authorization = request.getHeader("Authorization");
+        authorization = StrUtil.subAfter(authorization, "Basic ", true);
+        if (StringUtils.hasText(authorization)) {
+            authorization = Base64.decodeStr(authorization);
+            clientId = StrUtil.subBefore(authorization, ":", false);
+            clientSecret = StrUtil.subAfter(authorization, ":", false);
+        // 再从 Param 中获取
+        } else {
+            clientId = request.getParameter("client_id");
+            clientSecret = request.getParameter("client_secret");
+        }
+
+        // 如果两者非空,则返回
+        if (StrUtil.isNotEmpty(clientId) && StrUtil.isNotEmpty(clientSecret)) {
+            return new String[]{clientId, clientSecret};
+        }
+        return null;
+    }
+
+
 }

+ 2 - 2
yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/util/SecurityFrameworkUtils.java

@@ -20,7 +20,7 @@ import java.util.Collections;
  */
 public class SecurityFrameworkUtils {
 
-    public static final String TOKEN_TYPE = "Bearer";
+    public static final String AUTHORIZATION_BEARER = "Bearer";
 
     private SecurityFrameworkUtils() {}
 
@@ -36,7 +36,7 @@ public class SecurityFrameworkUtils {
         if (!StringUtils.hasText(authorization)) {
             return null;
         }
-        int index = authorization.indexOf(TOKEN_TYPE + " ");
+        int index = authorization.indexOf(AUTHORIZATION_BEARER + " ");
         if (index == -1) { // 未找到
             return null;
         }

+ 9 - 2
yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java

@@ -123,12 +123,19 @@ public interface ErrorCodeConstants {
     ErrorCode SENSITIVE_WORD_NOT_EXISTS = new ErrorCode(1002019000, "系统敏感词在所有标签中都不存在");
     ErrorCode SENSITIVE_WORD_EXISTS = new ErrorCode(1002019001, "系统敏感词已在标签中存在");
 
-    // ========== 系统敏感词 1002020000 =========
+    // ========== OAuth2 客户端 1002020000 =========
     ErrorCode OAUTH2_CLIENT_NOT_EXISTS = new ErrorCode(1002020000, "OAuth2 客户端不存在");
     ErrorCode OAUTH2_CLIENT_EXISTS = new ErrorCode(1002020001, "OAuth2 客户端编号已存在");
     ErrorCode OAUTH2_CLIENT_DISABLE = new ErrorCode(1002020002, "OAuth2 客户端已禁用");
     ErrorCode OAUTH2_CLIENT_AUTHORIZED_GRANT_TYPE_NOT_EXISTS = new ErrorCode(1002020003, "不支持该授权类型");
     ErrorCode OAUTH2_CLIENT_SCOPE_OVER = new ErrorCode(1002020004, "授权范围过大");
-    ErrorCode OAUTH2_CLIENT_REDIRECT_URI_NOT_MATCH = new ErrorCode(1002020005, "重定向地址不匹配");
+    ErrorCode OAUTH2_CLIENT_REDIRECT_URI_NOT_MATCH = new ErrorCode(1002020005, "无效 redirect_uri: {}");
+    ErrorCode OAUTH2_CLIENT_CLIENT_SECRET_ERROR = new ErrorCode(1002020006, "无效 client_secret: {}");
+
+    // ========== OAuth2 授权 1002021000 =========
+    ErrorCode OAUTH2_GRANT_CLIENT_ID_MISMATCH = new ErrorCode(1002020000, "client_id 不匹配");
+    ErrorCode OAUTH2_GRANT_REDIRECT_URI_MISMATCH = new ErrorCode(1002020001, "redirect_uri 不匹配");
+    ErrorCode OAUTH2_GRANT_STATE_MISMATCH = new ErrorCode(1002020002, "state 不匹配");
+    ErrorCode OAUTH2_GRANT_CODE_NOT_EXISTS = new ErrorCode(1002020003, "code 不存在");
 
 }

+ 5 - 0
yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/auth/OAuth2GrantTypeEnum.java

@@ -1,5 +1,6 @@
 package cn.iocoder.yudao.module.system.enums.auth;
 
+import cn.hutool.core.util.ArrayUtil;
 import lombok.AllArgsConstructor;
 import lombok.Getter;
 
@@ -21,4 +22,8 @@ public enum OAuth2GrantTypeEnum {
 
     private final String grantType;
 
+    public static OAuth2GrantTypeEnum getByGranType(String grantType) {
+        return ArrayUtil.firstMatch(o -> o.getGrantType().equals(grantType), values());
+    }
+
 }

+ 8 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2Controller.http

@@ -13,3 +13,11 @@ Authorization: Bearer {{token}}
 tenant-id: {{adminTenentId}}
 
 response_type=code&client_id=default&scope={"user_info": true}&redirect_uri=https://www.iocoder.cn&auto_approve=true
+
+### 请求 /system/oauth2/token + code 接口 => 成功
+POST {{baseUrl}}/system/oauth2/token
+Content-Type: application/x-www-form-urlencoded
+Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==
+tenant-id: {{adminTenentId}}
+
+grant_type=authorization_code&redirect_uri=https://www.iocoder.cn

+ 72 - 12
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2Controller.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2OpenController.java

@@ -1,12 +1,16 @@
 package cn.iocoder.yudao.module.system.controller.admin.oauth2;
 
 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 cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
 import cn.iocoder.yudao.framework.operatelog.core.annotations.OperateLog;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.open.OAuth2OpenAccessTokenRespVO;
+import cn.iocoder.yudao.module.system.convert.oauth2.OAuth2OpenConvert;
 import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2AccessTokenDO;
 import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2ClientDO;
 import cn.iocoder.yudao.module.system.enums.auth.OAuth2GrantTypeEnum;
@@ -23,6 +27,7 @@ import org.springframework.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
@@ -33,21 +38,17 @@ import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
 import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
 
-@Api(tags = "管理后台 - OAuth2.0 授权")
+@Api(tags = "管理后台 - OAuth2.0 授权") // 提供给外部应用调用为主
 @RestController
 @RequestMapping("/system/oauth2")
 @Validated
 @Slf4j
-public class OAuth2Controller {
-
-//    POST oauth/token TokenEndpoint:Password、Implicit、Code、Refresh Token
+public class OAuth2OpenController {
 
 //    POST oauth/check_token CheckTokenEndpoint
 
 //    DELETE oauth/token ConsumerTokenServices#revokeToken
 
-//    GET  oauth/authorize AuthorizationEndpoint
-
     @Resource
     private OAuth2GrantService oauth2GrantService;
     @Resource
@@ -55,6 +56,56 @@ public class OAuth2Controller {
     @Resource
     private OAuth2ApproveService oauth2ApproveService;
 
+    @PostMapping("/token")
+    @ApiOperation(value = "获得访问令牌", notes = "适合 code 授权码模式,或者 implicit 简化模式;在 authorize.vue 单点登录界面被【获取】调用")
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "grant_type", required = true, value = "授权类型", example = "code", dataTypeClass = String.class),
+            @ApiImplicitParam(name = "code", value = "授权范围", example = "userinfo.read", dataTypeClass = String.class),
+            @ApiImplicitParam(name = "redirect_uri", value = "重定向 URI", example = "https://www.iocoder.cn", dataTypeClass = String.class),
+            @ApiImplicitParam(name = "state", example = "123321", dataTypeClass = String.class)
+    })
+    @OperateLog(enable = false) // 避免 Post 请求被记录操作日志
+    public CommonResult<OAuth2OpenAccessTokenRespVO> postAccessToken(HttpServletRequest request,
+                                                                     @RequestParam("grant_type") String grantType,
+                                                                     @RequestParam(value = "code", required = false) String code, // 授权码模式
+                                                                     @RequestParam(value = "redirect_uri", required = false) String redirectUri, // 授权码模式
+                                                                     @RequestParam(value = "state", required = false) String state) { // 授权码模式
+        // 授权类型
+        OAuth2GrantTypeEnum grantTypeEnum = OAuth2GrantTypeEnum.getByGranType(grantType);
+        if (grantTypeEnum == null) {
+            throw exception0(BAD_REQUEST.getCode(), StrUtil.format("未知授权类型({})", grantType));
+        }
+        if (grantTypeEnum == OAuth2GrantTypeEnum.IMPLICIT) {
+            throw exception0(BAD_REQUEST.getCode(), "Token 接口不支持 implicit 授权模式");
+        }
+
+        // 校验客户端
+        String[] clientIdAndSecret = HttpUtils.obtainBasicAuthorization(request);
+        if (ArrayUtil.isEmpty(clientIdAndSecret) || clientIdAndSecret.length != 2) {
+            throw exception0(BAD_REQUEST.getCode(), "client_id 或 client_secret 未正确传递");
+        }
+        OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientIdAndSecret[0], clientIdAndSecret[1], grantType, null, null);
+
+        // 根据授权模式,获取访问令牌
+        OAuth2AccessTokenDO accessTokenDO = null;
+        switch (grantTypeEnum) {
+            case AUTHORIZATION_CODE:
+                accessTokenDO = oauth2GrantService.grantAuthorizationCodeForAccessToken(client.getClientId(), code, redirectUri, state);
+                break;
+            case PASSWORD:
+                break;
+            case CLIENT_CREDENTIALS:
+                break;
+            case REFRESH_TOKEN:
+                break;
+            default:
+                throw new IllegalArgumentException("未知授权类型:" + grantType);
+        }
+        Assert.notNull(accessTokenDO, "访问令牌不能为空"); // 防御性检查
+        return success(OAuth2OpenConvert.INSTANCE.convert(accessTokenDO));
+    }
+
+    //    GET  oauth/authorize AuthorizationEndpoint TODO
     @GetMapping("/authorize")
     @ApiOperation(value = "获得授权信息", notes = "适合 code 授权码模式,或者 implicit 简化模式;在 authorize.vue 单点登录界面被【获取】调用")
     @ApiImplicitParams({
@@ -75,12 +126,23 @@ public class OAuth2Controller {
         // 1.1 校验 responseType 是否满足 code 或者 token 值
         OAuth2GrantTypeEnum grantTypeEnum = getGrantTypeEnum(responseType);
         // 1.2 校验 redirectUri 重定向域名是否合法 + 校验 scope 是否在 Client 授权范围内
-        oauth2ClientService.validOAuthClientFromCache(clientId, grantTypeEnum.getGrantType(), scopes, redirectUri);
+        oauth2ClientService.validOAuthClientFromCache(clientId, null,
+                grantTypeEnum.getGrantType(), scopes, redirectUri);
 
         // 3. 不满足自动授权,则返回授权相关的展示信息
         return null;
     }
 
+    /**
+     * 对应 Spring Security OAuth 的 AuthorizationEndpoint 类的 approveOrDeny 方法
+     *
+     * 场景一:【自动授权 autoApprove = true】
+     *      刚进入 authorize.vue 界面,调用该接口,用户历史已经给该应用做过对应的授权,或者 OAuth2Client 支持该 scope 的自动授权
+     * 场景二:【手动授权 autoApprove = false】
+     *      在 authorize.vue 界面,用户选择好 scope 授权范围,调用该接口,进行授权。此时,approved 为 true 或者 false
+     *
+     * 因为前后端分离,Axios 无法很好的处理 302 重定向,所以和 Spring Security OAuth 略有不同,返回结果是重定向的 URL,剩余交给前端处理
+     */
     @PostMapping("/authorize")
     @ApiOperation(value = "申请授权", notes = "适合 code 授权码模式,或者 implicit 简化模式;在 authorize.vue 单点登录界面被【提交】调用")
     @ApiImplicitParams({
@@ -92,9 +154,6 @@ public class OAuth2Controller {
             @ApiImplicitParam(name = "state", example = "123321", dataTypeClass = String.class)
     })
     @OperateLog(enable = false) // 避免 Post 请求被记录操作日志
-    // 场景一:【自动授权 autoApprove = true】刚进入 authorize.vue 界面,调用该接口,用户历史已经给该应用做过对应的授权,或者 OAuth2Client 支持该 scope 的自动授权
-    // 场景二:【手动授权 autoApprove = false】在 authorize.vue 界面,用户选择好 scope 授权范围,调用该接口,进行授权。此时,approved 为 true 或者 false
-    // 因为前后端分离,Axios 无法很好的处理 302 重定向,所以和 Spring Security OAuth 略有不同,返回结果是重定向的 URL,剩余交给前端处理
     public CommonResult<String> approveOrDeny(@RequestParam("response_type") String responseType,
                                               @RequestParam("client_id") String clientId,
                                               @RequestParam(value = "scope", required = false) String scope,
@@ -110,7 +169,8 @@ public class OAuth2Controller {
         // 1.1 校验 responseType 是否满足 code 或者 token 值
         OAuth2GrantTypeEnum grantTypeEnum = getGrantTypeEnum(responseType);
         // 1.2 校验 redirectUri 重定向域名是否合法 + 校验 scope 是否在 Client 授权范围内
-        OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientId, grantTypeEnum.getGrantType(), scopes.keySet(), redirectUri);
+        OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientId, null,
+                grantTypeEnum.getGrantType(), scopes.keySet(), redirectUri);
 
         // 2.1 假设 approved 为 null,说明是场景一
         if (Boolean.TRUE.equals(autoApprove)) {
@@ -159,7 +219,7 @@ public class OAuth2Controller {
     private String getAuthorizationCodeRedirect(Long userId, OAuth2ClientDO client,
                                                 List<String> scopes, String redirectUri, String state) {
         // 1. 创建 code 授权码
-        String authorizationCode = oauth2GrantService.grantAuthorizationCode(userId,getUserType(), client.getClientId(), scopes,
+        String authorizationCode = oauth2GrantService.grantAuthorizationCodeForCode(userId,getUserType(), client.getClientId(), scopes,
                 redirectUri, state);
         // 2. 拼接重定向的 URL
         return OAuth2Utils.buildAuthorizationCodeRedirectUri(redirectUri, authorizationCode, state);

+ 32 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/open/OAuth2OpenAccessTokenRespVO.java

@@ -0,0 +1,32 @@
+package cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.open;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@ApiModel("管理后台 - 访问令牌 Response VO")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class OAuth2OpenAccessTokenRespVO {
+
+    @ApiModelProperty(value = "访问令牌", required = true, example = "tudou")
+    @JsonProperty("access_token")
+    private String accessToken;
+
+    @ApiModelProperty(value = "刷新令牌", required = true, example = "nice")
+    @JsonProperty("refresh_token")
+    private String refreshToken;
+
+    @ApiModelProperty(value = "令牌类型", required = true, example = "bearer")
+    @JsonProperty("token_type")
+    private String tokenType;
+
+    @ApiModelProperty(value = "过期时间", required = true, example = "42430", notes = "单位:秒")
+    @JsonProperty("expires_in")
+    private Long expiresIn;
+
+}

+ 24 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/convert/oauth2/OAuth2OpenConvert.java

@@ -0,0 +1,24 @@
+package cn.iocoder.yudao.module.system.convert.oauth2;
+
+import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.open.OAuth2OpenAccessTokenRespVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2AccessTokenDO;
+import cn.iocoder.yudao.module.system.util.oauth2.OAuth2Utils;
+import org.mapstruct.Mapper;
+import org.mapstruct.factory.Mappers;
+
+@Mapper
+public interface OAuth2OpenConvert {
+
+    OAuth2OpenConvert INSTANCE = Mappers.getMapper(OAuth2OpenConvert.class);
+
+    default OAuth2OpenAccessTokenRespVO convert(OAuth2AccessTokenDO bean) {
+        OAuth2OpenAccessTokenRespVO respVO = convert0(bean);
+        respVO.setTokenType(SecurityFrameworkUtils.AUTHORIZATION_BEARER.toLowerCase());
+        respVO.setExpiresIn(OAuth2Utils.getExpiresIn(bean.getExpiresTime()));
+        return respVO;
+    }
+
+    OAuth2OpenAccessTokenRespVO convert0(OAuth2AccessTokenDO bean);
+
+}

+ 2 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/security/config/SecurityConfiguration.java

@@ -35,6 +35,8 @@ public class SecurityConfiguration {
                 registry.antMatchers(buildAdminApi("/system/tenant/get-id-by-name")).permitAll();
                 // 短信回调 API
                 registry.antMatchers(buildAdminApi("/system/sms/callback/**")).permitAll();
+                // OAuth2 API
+                registry.antMatchers(buildAdminApi("/system/oauth2/token")).permitAll();
             }
 
         };

+ 4 - 3
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2ClientService.java

@@ -67,7 +67,7 @@ public interface OAuth2ClientService {
      * @return 客户端
      */
     default OAuth2ClientDO validOAuthClientFromCache(String clientId) {
-        return validOAuthClientFromCache(clientId, null, null, null);
+        return validOAuthClientFromCache(clientId, null, null, null, null);
     }
 
     /**
@@ -76,12 +76,13 @@ public interface OAuth2ClientService {
      * 非空时,进行校验
      *
      * @param clientId 客户端编号
+     * @param clientSecret 客户端密钥
      * @param authorizedGrantType 授权方式
      * @param scopes 授权范围
      * @param redirectUri 重定向地址
      * @return 客户端
      */
-    OAuth2ClientDO validOAuthClientFromCache(String clientId, String authorizedGrantType,
-                                             Collection<String> scopes, String redirectUri);
+    OAuth2ClientDO validOAuthClientFromCache(String clientId, String clientSecret,
+                                             String authorizedGrantType, Collection<String> scopes, String redirectUri);
 
 }

+ 7 - 2
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2ClientServiceImpl.java

@@ -176,7 +176,8 @@ public class OAuth2ClientServiceImpl implements OAuth2ClientService {
     }
 
     @Override
-    public OAuth2ClientDO validOAuthClientFromCache(String clientId, String authorizedGrantType, Collection<String> scopes, String redirectUri) {
+    public OAuth2ClientDO validOAuthClientFromCache(String clientId, String clientSecret,
+                                                    String authorizedGrantType, Collection<String> scopes, String redirectUri) {
         // 校验客户端存在、且开启
         OAuth2ClientDO client = clientCache.get(clientId);
         if (client == null) {
@@ -186,6 +187,10 @@ public class OAuth2ClientServiceImpl implements OAuth2ClientService {
             throw exception(OAUTH2_CLIENT_DISABLE);
         }
 
+        // 校验客户端密钥
+        if (StrUtil.isNotEmpty(clientSecret) && ObjectUtil.notEqual(client.getSecret(), clientSecret)) {
+            throw exception(OAUTH2_CLIENT_CLIENT_SECRET_ERROR, clientSecret);
+        }
         // 校验授权方式
         if (StrUtil.isNotEmpty(authorizedGrantType) && !CollUtil.contains(client.getAuthorizedGrantTypes(), authorizedGrantType)) {
             throw exception(OAUTH2_CLIENT_AUTHORIZED_GRANT_TYPE_NOT_EXISTS);
@@ -196,7 +201,7 @@ public class OAuth2ClientServiceImpl implements OAuth2ClientService {
         }
         // 校验回调地址
         if (StrUtil.isNotEmpty(redirectUri) && !StrUtils.startWithAny(redirectUri, client.getRedirectUris())) {
-            throw exception(OAUTH2_CLIENT_REDIRECT_URI_NOT_MATCH);
+            throw exception(OAUTH2_CLIENT_REDIRECT_URI_NOT_MATCH, redirectUri);
         }
         return client;
     }

+ 30 - 4
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2GrantService.java

@@ -22,9 +22,35 @@ public interface OAuth2GrantService {
     OAuth2AccessTokenDO grantImplicit(Long userId, Integer userType,
                                       String clientId, List<String> scopes);
 
-    // AuthorizationCodeTokenGranter
-    String grantAuthorizationCode(Long userId, Integer userType,
-                                  String clientId, List<String> scopes,
-                                  String redirectUri, String state);
+    /**
+     * 授权码模式,第一阶段,获得 code 授权码
+     *
+     * 对应 Spring Security OAuth2 的 AuthorizationEndpoint 的 generateCode 方法
+     *
+     * @param userId 用户编号
+     * @param userType 用户类型
+     * @param clientId 客户端编号
+     * @param scopes 授权范围
+     * @param redirectUri 重定向 URI
+     * @param state 状态
+     * @return 授权码
+     */
+    String grantAuthorizationCodeForCode(Long userId, Integer userType,
+                                         String clientId, List<String> scopes,
+                                         String redirectUri, String state);
+
+    /**
+     * 授权码模式,第二阶段,获得 accessToken 访问令牌
+     *
+     * 对应 Spring Security OAuth2 的 AuthorizationCodeTokenGranter 功能
+     *
+     * @param clientId 客户端编号
+     * @param code 授权码
+     * @param redirectUri 重定向 URI
+     * @param state 状态
+     * @return 访问令牌
+     */
+    OAuth2AccessTokenDO grantAuthorizationCodeForAccessToken(String clientId, String code,
+                                                             String redirectUri, String state);
 
 }

+ 38 - 3
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2GrantServiceImpl.java

@@ -1,11 +1,18 @@
 package cn.iocoder.yudao.module.system.service.oauth2;
 
+import cn.hutool.core.util.StrUtil;
 import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2AccessTokenDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2CodeDO;
+import cn.iocoder.yudao.module.system.enums.ErrorCodeConstants;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
 import java.util.List;
 
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.OAUTH2_GRANT_CODE_NOT_EXISTS;
+import static java.util.Collections.singletonList;
+
 /**
  * OAuth2 授予 Service 实现类
  *
@@ -24,10 +31,38 @@ public class OAuth2GrantServiceImpl implements OAuth2GrantService {
     }
 
     @Override
-    public String grantAuthorizationCode(Long userId, Integer userType,
-                                         String clientId, List<String> scopes,
-                                         String redirectUri, String state) {
+    public String grantAuthorizationCodeForCode(Long userId, Integer userType,
+                                                String clientId, List<String> scopes,
+                                                String redirectUri, String state) {
         return "test";
     }
 
+    @Override
+    public OAuth2AccessTokenDO grantAuthorizationCodeForAccessToken(String clientId, String code,
+                                                                    String redirectUri, String state) {
+        // TODO 消费 code
+        OAuth2CodeDO codeDO = new OAuth2CodeDO().setClientId("default").setRedirectUri("https://www.iocoder.cn").setState("")
+                .setUserId(1L).setUserType(2).setScopes(singletonList("user_info"));
+        if (codeDO == null) {
+            throw exception(OAUTH2_GRANT_CODE_NOT_EXISTS);
+        }
+        // 校验 clientId 是否匹配
+        if (!StrUtil.equals(clientId, codeDO.getClientId())) {
+            throw exception(ErrorCodeConstants.OAUTH2_GRANT_CLIENT_ID_MISMATCH);
+        }
+        // 校验 redirectUri 是否匹配
+        if (!StrUtil.equals(redirectUri, codeDO.getRedirectUri())) {
+            throw exception(ErrorCodeConstants.OAUTH2_GRANT_REDIRECT_URI_MISMATCH);
+        }
+        // 校验 state 是否匹配
+        state = StrUtil.nullToDefault(state, ""); // 数据库 state 为 null 时,会设置为 "" 空串
+        if (!StrUtil.equals(state, codeDO.getState())) {
+            throw exception(ErrorCodeConstants.OAUTH2_GRANT_STATE_MISMATCH);
+        }
+
+        // 创建访问令牌
+        return oauth2TokenService.createAccessToken(codeDO.getUserId(), codeDO.getUserType(),
+                codeDO.getClientId(), codeDO.getScopes());
+    }
+
 }

+ 6 - 3
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/util/oauth2/OAuth2Utils.java

@@ -50,13 +50,12 @@ public class OAuth2Utils {
         Map<String, Object> vars = new LinkedHashMap<String, Object>();
         Map<String, String> keys = new HashMap<String, String>();
         vars.put("access_token", accessToken);
-        vars.put("token_type", SecurityFrameworkUtils.TOKEN_TYPE.toLowerCase());
+        vars.put("token_type", SecurityFrameworkUtils.AUTHORIZATION_BEARER.toLowerCase());
         if (state != null) {
             vars.put("state", state);
         }
         if (expireTime != null) {
-            long expires_in = (expireTime.getTime() - System.currentTimeMillis()) / 1000;
-            vars.put("expires_in", expires_in);
+            vars.put("expires_in", getExpiresIn(expireTime));
         }
         if (CollUtil.isNotEmpty(scopes)) {
             vars.put("scope", CollUtil.join(scopes, " "));
@@ -83,4 +82,8 @@ public class OAuth2Utils {
         return HttpUtils.append(redirectUri, query, null, !responseType.contains("code"));
     }
 
+    public static long getExpiresIn(Date expireTime) {
+        return (expireTime.getTime() - System.currentTimeMillis()) / 1000;
+    }
+
 }