소스 검색

!176 基于 OAuth2.0 实现 SSO 单点登录
Merge pull request !176 from 芋道源码/feature/1.6.2

芋道源码 3 년 전
부모
커밋
82cf4e1775
81개의 변경된 파일2117개의 추가작업 그리고 249개의 파일을 삭제
  1. 7 0
      yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/collection/CollectionUtils.java
  2. 98 0
      yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/http/HttpUtils.java
  3. 17 14
      yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/string/StrUtils.java
  4. 5 0
      yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/LoginUser.java
  5. 1 1
      yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/filter/TokenAuthenticationFilter.java
  6. 15 0
      yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/service/SecurityFrameworkService.java
  7. 19 0
      yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/service/SecurityFrameworkServiceImpl.java
  8. 3 1
      yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/util/SecurityFrameworkUtils.java
  9. 5 0
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/auth/dto/OAuth2AccessTokenCheckRespDTO.java
  10. 5 0
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/auth/dto/OAuth2AccessTokenCreateReqDTO.java
  11. 16 1
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java
  12. 5 0
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/auth/OAuth2GrantTypeEnum.java
  13. 3 3
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/auth/OAuth2TokenApiImpl.java
  14. 1 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/AuthController.java
  15. 0 24
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/OAuth2Controller.java
  16. 1 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthLoginReqVO.java
  17. 1 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthLoginRespVO.java
  18. 1 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthMenuRespVO.java
  19. 1 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthPermissionInfoRespVO.java
  20. 1 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthSmsLoginReqVO.java
  21. 1 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthSmsSendReqVO.java
  22. 1 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthSocialBindLoginReqVO.java
  23. 1 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthSocialQuickLoginReqVO.java
  24. 0 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2ClientController.http
  25. 7 7
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2ClientController.java
  26. 69 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2OpenController.http
  27. 348 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2OpenController.java
  28. 5 5
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2TokenController.java
  29. 13 14
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/client/OAuth2ClientBaseVO.java
  30. 1 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/client/OAuth2ClientCreateReqVO.java
  31. 3 3
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/client/OAuth2ClientPageReqVO.java
  32. 2 2
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/client/OAuth2ClientRespVO.java
  33. 2 2
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/client/OAuth2ClientUpdateReqVO.java
  34. 35 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/open/OAuth2OpenAccessTokenRespVO.java
  35. 39 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/open/OAuth2OpenAuthorizeInfoRespVO.java
  36. 40 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/open/OAuth2OpenCheckTokenRespVO.java
  37. 71 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/open/user/OAuth2OpenUserInfoRespVO.java
  38. 35 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/open/user/OAuth2OpenUserUpdateReqVO.java
  39. 1 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/token/OAuth2AccessTokenPageReqVO.java
  40. 1 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/token/OAuth2AccessTokenRespVO.java
  41. 0 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/user/UserProfileController.java
  42. 1 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/user/vo/profile/UserProfileUpdateReqVO.java
  43. 2 2
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/convert/auth/AuthConvert.java
  44. 4 4
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/convert/auth/OAuth2ClientConvert.java
  45. 2 2
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/convert/auth/OAuth2TokenConvert.java
  46. 70 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/convert/oauth2/OAuth2OpenConvert.java
  47. 9 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/oauth2/OAuth2AccessTokenDO.java
  48. 63 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/oauth2/OAuth2ApproveDO.java
  49. 8 5
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/oauth2/OAuth2ClientDO.java
  50. 17 11
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/oauth2/OAuth2CodeDO.java
  51. 9 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/oauth2/OAuth2RefreshTokenDO.java
  52. 3 3
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2AccessTokenMapper.java
  53. 28 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2ApproveMapper.java
  54. 3 3
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2ClientMapper.java
  55. 14 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2CodeMapper.java
  56. 2 2
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2RefreshTokenMapper.java
  57. 1 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/redis/RedisKeyConstants.java
  58. 2 2
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/redis/oauth2/OAuth2AccessTokenRedisDAO.java
  59. 3 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/security/config/SecurityConfiguration.java
  60. 1 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/mq/consumer/auth/OAuth2ClientRefreshConsumer.java
  61. 11 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthService.java
  62. 27 26
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java
  63. 0 14
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminOAuth2Service.java
  64. 0 11
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/auth/OAuth2CodeService.java
  65. 52 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2ApproveService.java
  66. 98 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2ApproveServiceImpl.java
  67. 23 6
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2ClientService.java
  68. 40 13
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2ClientServiceImpl.java
  69. 39 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2CodeService.java
  70. 64 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2CodeServiceImpl.java
  71. 113 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2GrantService.java
  72. 104 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2GrantServiceImpl.java
  73. 7 4
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenService.java
  74. 15 13
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImpl.java
  75. 98 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/util/oauth2/OAuth2Utils.java
  76. 14 13
      yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImplTest.java
  77. 6 5
      yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/service/auth/OAuth2ClientServiceImplTest.java
  78. 37 0
      yudao-ui-admin/src/api/login.js
  79. 5 0
      yudao-ui-admin/src/router/index.js
  80. 236 0
      yudao-ui-admin/src/views/sso.vue
  81. 6 14
      yudao-ui-admin/src/views/system/oauth2/client/index.vue

+ 7 - 0
yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/collection/CollectionUtils.java

@@ -68,6 +68,13 @@ public class CollectionUtils {
         return from.stream().map(func).filter(Objects::nonNull).collect(Collectors.toSet());
     }
 
+    public static <T, U> Set<U> convertSet(Collection<T> from, Function<T, U> func, Predicate<T> filter) {
+        if (CollUtil.isEmpty(from)) {
+            return new HashSet<>();
+        }
+        return from.stream().filter(filter).map(func).filter(Objects::nonNull).collect(Collectors.toSet());
+    }
+
     public static <T, K> Map<K, T> convertMap(Collection<T> from, Function<T, K> keyFunc) {
         if (CollUtil.isEmpty(from)) {
             return new HashMap<>();

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

@@ -1,10 +1,18 @@
 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;
 
 /**
  * HTTP 工具类
@@ -25,4 +33,94 @@ public class HttpUtils {
         return builder.build();
     }
 
+    private String append(String base, Map<String, ?> query, boolean fragment) {
+        return append(base, query, null, fragment);
+    }
+
+    /**
+     * 拼接 URL
+     *
+     * copy from Spring Security OAuth2 的 AuthorizationEndpoint 类的 append 方法
+     *
+     * @param base 基础 URL
+     * @param query 查询参数
+     * @param keys query 的 key,对应的原本的 key 的映射。例如说 query 里有个 key 是 xx,实际它的 key 是 extra_xx,则通过 keys 里添加这个映射
+     * @param fragment URL 的 fragment,即拼接到 # 中
+     * @return 拼接后的 URL
+     */
+    public static String append(String base, Map<String, ?> query, Map<String, String> keys, boolean fragment) {
+        UriComponentsBuilder template = UriComponentsBuilder.newInstance();
+        UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(base);
+        URI redirectUri;
+        try {
+            // assume it's encoded to start with (if it came in over the wire)
+            redirectUri = builder.build(true).toUri();
+        } catch (Exception e) {
+            // ... but allow client registrations to contain hard-coded non-encoded values
+            redirectUri = builder.build().toUri();
+            builder = UriComponentsBuilder.fromUri(redirectUri);
+        }
+        template.scheme(redirectUri.getScheme()).port(redirectUri.getPort()).host(redirectUri.getHost())
+                .userInfo(redirectUri.getUserInfo()).path(redirectUri.getPath());
+
+        if (fragment) {
+            StringBuilder values = new StringBuilder();
+            if (redirectUri.getFragment() != null) {
+                String append = redirectUri.getFragment();
+                values.append(append);
+            }
+            for (String key : query.keySet()) {
+                if (values.length() > 0) {
+                    values.append("&");
+                }
+                String name = key;
+                if (keys != null && keys.containsKey(key)) {
+                    name = keys.get(key);
+                }
+                values.append(name).append("={").append(key).append("}");
+            }
+            if (values.length() > 0) {
+                template.fragment(values.toString());
+            }
+            UriComponents encoded = template.build().expand(query).encode();
+            builder.fragment(encoded.getFragment());
+        } else {
+            for (String key : query.keySet()) {
+                String name = key;
+                if (keys != null && keys.containsKey(key)) {
+                    name = keys.get(key);
+                }
+                template.queryParam(name, "{" + key + "}");
+            }
+            template.fragment(redirectUri.getFragment());
+            UriComponents encoded = template.build().expand(query).encode();
+            builder.query(encoded.getQuery());
+        }
+        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;
+    }
+
+
 }

+ 17 - 14
yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/string/StrUtils.java

@@ -1,9 +1,9 @@
 package cn.iocoder.yudao.framework.common.util.string;
 
-import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.ArrayUtil;
 import cn.hutool.core.util.StrUtil;
 
-import java.util.Map;
+import java.util.Collection;
 
 /**
  * 字符串工具类
@@ -17,21 +17,24 @@ public class StrUtils {
     }
 
     /**
-     * 指定字符串的
-     * @param str
-     * @param replaceMap
-     * @return
+     * 给定字符串是否以任何一个字符串开始
+     * 给定字符串和数组为空都返回 false
+     *
+     * @param str      给定字符串
+     * @param prefixes 需要检测的开始字符串
+     * @since 3.0.6
      */
-    public static String replace(String str, Map<String, String> replaceMap) {
-        assert StrUtil.isNotBlank(str);
-        if (ObjectUtil.isEmpty(replaceMap)) {
-            return str;
+    public static boolean startWithAny(String str, Collection<String> prefixes) {
+        if (StrUtil.isEmpty(str) || ArrayUtil.isEmpty(prefixes)) {
+            return false;
         }
-        String result = null;
-        for (String key : replaceMap.keySet()) {
-            result = str.replace(key, replaceMap.get(key));
+
+        for (CharSequence suffix : prefixes) {
+            if (StrUtil.startWith(str, suffix, false)) {
+                return true;
+            }
         }
-        return result;
+        return false;
     }
 
 }

+ 5 - 0
yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/LoginUser.java

@@ -6,6 +6,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
 import lombok.Data;
 
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
 /**
@@ -30,6 +31,10 @@ public class LoginUser {
      * 租户编号
      */
     private Long tenantId;
+    /**
+     * 授权范围
+     */
+    private List<String> scopes;
 
     // ========== 上下文 ==========
     /**

+ 1 - 1
yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/filter/TokenAuthenticationFilter.java

@@ -79,7 +79,7 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter {
             }
             // 构建登录用户
             return new LoginUser().setId(accessToken.getUserId()).setUserType(accessToken.getUserType())
-                    .setTenantId(accessToken.getTenantId());
+                    .setTenantId(accessToken.getTenantId()).setScopes(accessToken.getScopes());
         } catch (ServiceException serviceException) {
             // 校验 Token 不通过时,考虑到一些接口是无需登录的,所以直接返回 null 即可
             return null;

+ 15 - 0
yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/service/SecurityFrameworkService.java

@@ -41,4 +41,19 @@ public interface SecurityFrameworkService {
      */
     boolean hasAnyRoles(String... roles);
 
+    /**
+     * 判断是否有授权
+     *
+     * @param scope 授权
+     * @return 是否
+     */
+    boolean hasScope(String scope);
+
+    /**
+     * 判断是否有授权范围,任一一个即可
+     *
+     * @param scope 授权范围数组
+     * @return 是否
+     */
+    boolean hasAnyScopes(String... scope);
 }

+ 19 - 0
yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/service/SecurityFrameworkServiceImpl.java

@@ -1,8 +1,13 @@
 package cn.iocoder.yudao.framework.security.core.service;
 
+import cn.hutool.core.collection.CollUtil;
+import cn.iocoder.yudao.framework.security.core.LoginUser;
+import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
 import cn.iocoder.yudao.module.system.api.permission.PermissionApi;
 import lombok.AllArgsConstructor;
 
+import java.util.Arrays;
+
 import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
 
 /**
@@ -35,4 +40,18 @@ public class SecurityFrameworkServiceImpl implements SecurityFrameworkService {
         return permissionApi.hasAnyRoles(getLoginUserId(), roles);
     }
 
+    @Override
+    public boolean hasScope(String scope) {
+        return hasAnyScopes(scope);
+    }
+
+    @Override
+    public boolean hasAnyScopes(String... scope) {
+        LoginUser user = SecurityFrameworkUtils.getLoginUser();
+        if (user == null) {
+            return false;
+        }
+        return CollUtil.containsAny(user.getScopes(), Arrays.asList(scope));
+    }
+
 }

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

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

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

@@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.system.api.auth.dto;
 import lombok.Data;
 
 import java.io.Serializable;
+import java.util.List;
 
 /**
  * OAuth2.0 访问令牌的校验 Response DTO
@@ -24,5 +25,9 @@ public class OAuth2AccessTokenCheckRespDTO implements Serializable {
      * 租户编号
      */
     private Long tenantId;
+    /**
+     * 授权范围的数组
+     */
+    private List<String> scopes;
 
 }

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

@@ -6,6 +6,7 @@ import lombok.Data;
 
 import javax.validation.constraints.NotNull;
 import java.io.Serializable;
+import java.util.List;
 
 /**
  * OAuth2.0 访问令牌创建 Request DTO
@@ -31,5 +32,9 @@ public class OAuth2AccessTokenCreateReqDTO implements Serializable {
      */
     @NotNull(message = "客户端编号不能为空")
     private String clientId;
+    /**
+     * 授权范围
+     */
+    private List<String> scopes;
 
 }

+ 16 - 1
yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java

@@ -123,8 +123,23 @@ 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, "无效 redirect_uri: {}");
+    ErrorCode OAUTH2_CLIENT_CLIENT_SECRET_ERROR = new ErrorCode(1002020006, "无效 client_secret: {}");
+
+    // ========== OAuth2 授权 1002021000 =========
+    ErrorCode OAUTH2_GRANT_CLIENT_ID_MISMATCH = new ErrorCode(1002021000, "client_id 不匹配");
+    ErrorCode OAUTH2_GRANT_REDIRECT_URI_MISMATCH = new ErrorCode(1002021001, "redirect_uri 不匹配");
+    ErrorCode OAUTH2_GRANT_STATE_MISMATCH = new ErrorCode(1002021002, "state 不匹配");
+    ErrorCode OAUTH2_GRANT_CODE_NOT_EXISTS = new ErrorCode(1002021003, "code 不存在");
+
+    // ========== OAuth2 授权 1002022000 =========
+    ErrorCode OAUTH2_CODE_NOT_EXISTS = new ErrorCode(1002022000, "code 不存在");
+    ErrorCode OAUTH2_CODE_EXPIRE = new ErrorCode(1002022000, "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());
+    }
+
 }

+ 3 - 3
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/auth/OAuth2TokenApiImpl.java

@@ -4,8 +4,8 @@ import cn.iocoder.yudao.module.system.api.auth.dto.OAuth2AccessTokenCheckRespDTO
 import cn.iocoder.yudao.module.system.api.auth.dto.OAuth2AccessTokenCreateReqDTO;
 import cn.iocoder.yudao.module.system.api.auth.dto.OAuth2AccessTokenRespDTO;
 import cn.iocoder.yudao.module.system.convert.auth.OAuth2TokenConvert;
-import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2AccessTokenDO;
-import cn.iocoder.yudao.module.system.service.auth.OAuth2TokenService;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
+import cn.iocoder.yudao.module.system.service.oauth2.OAuth2TokenService;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
@@ -24,7 +24,7 @@ public class OAuth2TokenApiImpl implements OAuth2TokenApi {
     @Override
     public OAuth2AccessTokenRespDTO createAccessToken(OAuth2AccessTokenCreateReqDTO reqDTO) {
         OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken(
-                reqDTO.getUserId(), reqDTO.getUserType(), reqDTO.getClientId());
+                reqDTO.getUserId(), reqDTO.getUserType(), reqDTO.getClientId(), reqDTO.getScopes());
         return OAuth2TokenConvert.INSTANCE.convert2(accessTokenDO);
     }
 

+ 1 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/AuthController.java

@@ -6,7 +6,7 @@ import cn.iocoder.yudao.framework.common.pojo.CommonResult;
 import cn.iocoder.yudao.framework.common.util.collection.SetUtils;
 import cn.iocoder.yudao.framework.operatelog.core.annotations.OperateLog;
 import cn.iocoder.yudao.framework.security.config.SecurityProperties;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.auth.*;
+import cn.iocoder.yudao.module.system.controller.admin.auth.vo.*;
 import cn.iocoder.yudao.module.system.convert.auth.AuthConvert;
 import cn.iocoder.yudao.module.system.dal.dataobject.permission.MenuDO;
 import cn.iocoder.yudao.module.system.dal.dataobject.permission.RoleDO;

+ 0 - 24
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/OAuth2Controller.java

@@ -1,24 +0,0 @@
-package cn.iocoder.yudao.module.system.controller.admin.auth;
-
-import io.swagger.annotations.Api;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.validation.annotation.Validated;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
-
-@Api(tags = "管理后台 - OAuth2.0 授权")
-@RestController
-@RequestMapping("/system/oauth2")
-@Validated
-@Slf4j
-public class OAuth2Controller {
-
-//    POST oauth/token TokenEndpoint:Password、Implicit、Code、Refresh Token
-
-//    POST oauth/check_token CheckTokenEndpoint
-
-//    DELETE oauth/token ConsumerTokenServices#revokeToken
-
-//    GET  oauth/authorize AuthorizationEndpoint
-
-}

+ 1 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/auth/AuthLoginReqVO.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthLoginReqVO.java

@@ -1,4 +1,4 @@
-package cn.iocoder.yudao.module.system.controller.admin.auth.vo.auth;
+package cn.iocoder.yudao.module.system.controller.admin.auth.vo;
 
 import io.swagger.annotations.ApiModel;
 import io.swagger.annotations.ApiModelProperty;

+ 1 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/auth/AuthLoginRespVO.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthLoginRespVO.java

@@ -1,4 +1,4 @@
-package cn.iocoder.yudao.module.system.controller.admin.auth.vo.auth;
+package cn.iocoder.yudao.module.system.controller.admin.auth.vo;
 
 import io.swagger.annotations.ApiModel;
 import io.swagger.annotations.ApiModelProperty;

+ 1 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/auth/AuthMenuRespVO.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthMenuRespVO.java

@@ -1,4 +1,4 @@
-package cn.iocoder.yudao.module.system.controller.admin.auth.vo.auth;
+package cn.iocoder.yudao.module.system.controller.admin.auth.vo;
 
 import io.swagger.annotations.ApiModel;
 import io.swagger.annotations.ApiModelProperty;

+ 1 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/auth/AuthPermissionInfoRespVO.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthPermissionInfoRespVO.java

@@ -1,4 +1,4 @@
-package cn.iocoder.yudao.module.system.controller.admin.auth.vo.auth;
+package cn.iocoder.yudao.module.system.controller.admin.auth.vo;
 
 import io.swagger.annotations.ApiModel;
 import io.swagger.annotations.ApiModelProperty;

+ 1 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/auth/AuthSmsLoginReqVO.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthSmsLoginReqVO.java

@@ -1,4 +1,4 @@
-package cn.iocoder.yudao.module.system.controller.admin.auth.vo.auth;
+package cn.iocoder.yudao.module.system.controller.admin.auth.vo;
 
 import io.swagger.annotations.ApiModel;
 import io.swagger.annotations.ApiModelProperty;

+ 1 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/auth/AuthSmsSendReqVO.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthSmsSendReqVO.java

@@ -1,4 +1,4 @@
-package cn.iocoder.yudao.module.system.controller.admin.auth.vo.auth;
+package cn.iocoder.yudao.module.system.controller.admin.auth.vo;
 
 import cn.iocoder.yudao.framework.common.validation.InEnum;
 import cn.iocoder.yudao.framework.common.validation.Mobile;

+ 1 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/auth/AuthSocialBindLoginReqVO.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthSocialBindLoginReqVO.java

@@ -1,4 +1,4 @@
-package cn.iocoder.yudao.module.system.controller.admin.auth.vo.auth;
+package cn.iocoder.yudao.module.system.controller.admin.auth.vo;
 
 import cn.iocoder.yudao.framework.common.validation.InEnum;
 import cn.iocoder.yudao.module.system.enums.social.SocialTypeEnum;

+ 1 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/auth/AuthSocialQuickLoginReqVO.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthSocialQuickLoginReqVO.java

@@ -1,4 +1,4 @@
-package cn.iocoder.yudao.module.system.controller.admin.auth.vo.auth;
+package cn.iocoder.yudao.module.system.controller.admin.auth.vo;
 
 import cn.iocoder.yudao.module.system.enums.social.SocialTypeEnum;
 import cn.iocoder.yudao.framework.common.validation.InEnum;

+ 0 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/OAuth2ClientController.http → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2ClientController.http


+ 7 - 7
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/OAuth2ClientController.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2ClientController.java

@@ -1,14 +1,14 @@
-package cn.iocoder.yudao.module.system.controller.admin.auth;
+package cn.iocoder.yudao.module.system.controller.admin.oauth2;
 
 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.client.OAuth2ClientCreateReqVO;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.client.OAuth2ClientPageReqVO;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.client.OAuth2ClientRespVO;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.client.OAuth2ClientUpdateReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client.OAuth2ClientCreateReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client.OAuth2ClientPageReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client.OAuth2ClientRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client.OAuth2ClientUpdateReqVO;
 import cn.iocoder.yudao.module.system.convert.auth.OAuth2ClientConvert;
-import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2ClientDO;
-import cn.iocoder.yudao.module.system.service.auth.OAuth2ClientService;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2ClientDO;
+import cn.iocoder.yudao.module.system.service.oauth2.OAuth2ClientService;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiImplicitParam;
 import io.swagger.annotations.ApiOperation;

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

@@ -0,0 +1,69 @@
+### 请求 /system/oauth2/authorize 接口 => 成功
+GET {{baseUrl}}/system/oauth2/authorize?clientId=default
+Authorization: Bearer {{token}}
+tenant-id: {{adminTenentId}}
+
+### 请求 /system/oauth2/authorize + token 接口 => 成功
+POST {{baseUrl}}/system/oauth2/authorize
+Content-Type: application/x-www-form-urlencoded
+Authorization: Bearer {{token}}
+tenant-id: {{adminTenentId}}
+
+response_type=token&client_id=default&scope={"user.read": true}&redirect_uri=https://www.iocoder.cn&auto_approve=true
+
+### 请求 /system/oauth2/authorize + code 接口 => 成功
+POST {{baseUrl}}/system/oauth2/authorize
+Content-Type: application/x-www-form-urlencoded
+Authorization: Bearer {{token}}
+tenant-id: {{adminTenentId}}
+
+response_type=code&client_id=default&scope={"user.read": true}&redirect_uri=https://www.iocoder.cn&auto_approve=false
+
+### 请求 /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&code=189956c07a174588a97157eabef2f93a
+
+### 请求 /system/oauth2/token + password 接口 => 成功
+POST {{baseUrl}}/system/oauth2/token
+Content-Type: application/x-www-form-urlencoded
+Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==
+tenant-id: {{adminTenentId}}
+
+grant_type=password&username=admin&password=admin123&scope=user.read
+
+### 请求 /system/oauth2/token + refresh_token 接口 => 成功
+POST {{baseUrl}}/system/oauth2/token
+Content-Type: application/x-www-form-urlencoded
+Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==
+tenant-id: {{adminTenentId}}
+
+grant_type=refresh_token&refresh_token=00895465d6994f72a9d926ceeed0f588
+
+### 请求 /system/oauth2/token + DELETE 接口 => 成功
+DELETE {{baseUrl}}/system/oauth2/token?token=ca8a188f464441d6949c51493a2b7596
+Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==
+tenant-id: {{adminTenentId}}
+
+### 请求 /system/oauth2/check-token 接口 => 成功
+POST {{baseUrl}}/system/oauth2/check-token?token=620d307c5b4148df8a98dd6c6c547106
+Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==
+tenant-id: {{adminTenentId}}
+
+### 请求 /system/oauth2/user/get 接口 => 成功
+GET {{baseUrl}}/system/oauth2/user/get
+Authorization: Bearer 9502bd7a768a4ade920b90f41e2efd5c
+tenant-id: {{adminTenentId}}
+
+### 请求 /system/oauth2/user/update 接口 => 成功
+PUT {{baseUrl}}/system/oauth2/user/update
+Content-Type: application/json
+Authorization: Bearer 9502bd7a768a4ade920b90f41e2efd5c
+tenant-id: {{adminTenentId}}
+
+{
+  "nickname": "芋道源码"
+}

+ 348 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2OpenController.java

@@ -0,0 +1,348 @@
+package cn.iocoder.yudao.module.system.controller.admin.oauth2;
+
+import cn.hutool.core.collection.CollUtil;
+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.controller.admin.oauth2.vo.open.OAuth2OpenAuthorizeInfoRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.open.OAuth2OpenCheckTokenRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.open.user.OAuth2OpenUserInfoRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.open.user.OAuth2OpenUserUpdateReqVO;
+import cn.iocoder.yudao.module.system.convert.oauth2.OAuth2OpenConvert;
+import cn.iocoder.yudao.module.system.dal.dataobject.dept.DeptDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.dept.PostDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2ApproveDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2ClientDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
+import cn.iocoder.yudao.module.system.enums.auth.OAuth2GrantTypeEnum;
+import cn.iocoder.yudao.module.system.service.dept.DeptService;
+import cn.iocoder.yudao.module.system.service.dept.PostService;
+import cn.iocoder.yudao.module.system.service.oauth2.OAuth2ApproveService;
+import cn.iocoder.yudao.module.system.service.oauth2.OAuth2ClientService;
+import cn.iocoder.yudao.module.system.service.oauth2.OAuth2GrantService;
+import cn.iocoder.yudao.module.system.service.oauth2.OAuth2TokenService;
+import cn.iocoder.yudao.module.system.service.user.AdminUserService;
+import cn.iocoder.yudao.module.system.util.oauth2.OAuth2Utils;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiImplicitParam;
+import io.swagger.annotations.ApiImplicitParams;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
+import javax.validation.Valid;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception0;
+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;
+
+/**
+ * 提供给外部应用调用为主
+ *
+ * 一般来说,管理后台的 /system-api/* 是不直接提供给外部应用使用,主要是外部应用能够访问的数据与接口是有限的,而管理后台的 RBAC 无法很好的控制。
+ * 参考大量的开放平台,都是独立的一套 OpenAPI,对应到【本系统】就是在 Controller 下新建 open 包,实现 /open-api/* 接口,然后通过 scope 进行控制。
+ * 另外,一个公司如果有多个管理后台,它们 client_id 产生的 access token 相互之间是无法互通的,即无法访问它们系统的 API 接口,直到两个 client_id 产生信任授权。
+ *
+ * 考虑到【本系统】暂时不想做的过于复杂,默认只有获取到 access token 之后,可以访问【本系统】管理后台的 /system-api/* 所有接口,除非手动添加 scope 控制。
+ * scope 的使用示例,可见当前类的 getUserInfo 和 updateUserInfo 方法,上面有 @PreAuthorize("@ss.hasScope('user.read')") 和 @PreAuthorize("@ss.hasScope('user.write')") 注解
+ *
+ * @author 芋道源码
+ */
+@Api(tags = "管理后台 - OAuth2.0 授权")
+@RestController
+@RequestMapping("/system/oauth2")
+@Validated
+@Slf4j
+public class OAuth2OpenController {
+
+    @Resource
+    private OAuth2GrantService oauth2GrantService;
+    @Resource
+    private OAuth2ClientService oauth2ClientService;
+    @Resource
+    private OAuth2ApproveService oauth2ApproveService;
+    @Resource
+    private OAuth2TokenService oauth2TokenService;
+
+    /**
+     * 对应 Spring Security OAuth 的 TokenEndpoint 类的 postAccessToken 方法
+     *
+     * 授权码 authorization_code 模式时:code + redirectUri + state 参数
+     * 密码 password 模式时:username + password + scope 参数
+     * 刷新 refresh_token 模式时:refreshToken 参数
+     * 客户端 client_credentials 模式:scope 参数
+     * 简化 implicit 模式时:不支持
+     *
+     * 注意,默认需要传递 client_id + client_secret 参数
+     */
+    @PostMapping("/token")
+    @ApiOperation(value = "获得访问令牌", notes = "适合 code 授权码模式,或者 implicit 简化模式;在 sso.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 = "username", example = "tudou", dataTypeClass = String.class),
+            @ApiImplicitParam(name = "password", example = "cai", dataTypeClass = String.class), // 多个使用空格分隔
+            @ApiImplicitParam(name = "scope", example = "user_info", 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, // 授权码模式
+                                                                     @RequestParam(value = "username", required = false) String username, // 密码模式
+                                                                     @RequestParam(value = "password", required = false) String password, // 密码模式
+                                                                     @RequestParam(value = "scope", required = false) String scope, // 密码模式
+                                                                     @RequestParam(value = "refresh_token", required = false) String refreshToken) { // 刷新模式
+        List<String> scopes = OAuth2Utils.buildScopes(scope);
+        // 授权类型
+        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 = obtainBasicAuthorization(request);
+        OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientIdAndSecret[0], clientIdAndSecret[1],
+                grantType, scopes, redirectUri);
+
+        // 根据授权模式,获取访问令牌
+        OAuth2AccessTokenDO accessTokenDO;
+        switch (grantTypeEnum) {
+            case AUTHORIZATION_CODE:
+                accessTokenDO = oauth2GrantService.grantAuthorizationCodeForAccessToken(client.getClientId(), code, redirectUri, state);
+                break;
+            case PASSWORD:
+                accessTokenDO = oauth2GrantService.grantPassword(username, password, client.getClientId(), scopes);
+                break;
+            case CLIENT_CREDENTIALS:
+                accessTokenDO = oauth2GrantService.grantClientCredentials(client.getClientId(), scopes);
+                break;
+            case REFRESH_TOKEN:
+                accessTokenDO = oauth2GrantService.grantRefreshToken(refreshToken, client.getClientId());
+                break;
+            default:
+                throw new IllegalArgumentException("未知授权类型:" + grantType);
+        }
+        Assert.notNull(accessTokenDO, "访问令牌不能为空"); // 防御性检查
+        return success(OAuth2OpenConvert.INSTANCE.convert(accessTokenDO));
+    }
+
+    @DeleteMapping("/token")
+    @ApiOperation(value = "删除访问令牌")
+    @ApiImplicitParam(name = "token", required = true, value = "访问令牌", example = "biu", dataTypeClass = String.class)
+    @OperateLog(enable = false) // 避免 Post 请求被记录操作日志
+    public CommonResult<Boolean> revokeToken(HttpServletRequest request,
+                                             @RequestParam("token") String token) {
+        // 校验客户端
+        String[] clientIdAndSecret = obtainBasicAuthorization(request);
+        OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientIdAndSecret[0], clientIdAndSecret[1],
+                null, null, null);
+
+        // 删除访问令牌
+        return success(oauth2GrantService.revokeToken(client.getClientId(), token));
+    }
+
+    /**
+     * 对应 Spring Security OAuth 的 CheckTokenEndpoint 类的 checkToken 方法
+     */
+    @PostMapping("/check-token")
+    @ApiOperation(value = "校验访问令牌")
+    @ApiImplicitParam(name = "token", required = true, value = "访问令牌", example = "biu", dataTypeClass = String.class)
+    @OperateLog(enable = false) // 避免 Post 请求被记录操作日志
+    public CommonResult<OAuth2OpenCheckTokenRespVO> checkToken(HttpServletRequest request,
+                                                               @RequestParam("token") String token) {
+        // 校验客户端
+        String[] clientIdAndSecret = obtainBasicAuthorization(request);
+        oauth2ClientService.validOAuthClientFromCache(clientIdAndSecret[0], clientIdAndSecret[1],
+                null, null, null);
+
+        // 校验令牌
+        OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.checkAccessToken(token);
+        Assert.notNull(accessTokenDO, "访问令牌不能为空"); // 防御性检查
+        return success(OAuth2OpenConvert.INSTANCE.convert2(accessTokenDO));
+    }
+
+    /**
+     * 对应 Spring Security OAuth 的 AuthorizationEndpoint 类的 authorize 方法
+     */
+    @GetMapping("/authorize")
+    @ApiOperation(value = "获得授权信息", notes = "适合 code 授权码模式,或者 implicit 简化模式;在 sso.vue 单点登录界面被【获取】调用")
+    @ApiImplicitParam(name = "clientId", required = true, value = "客户端编号", example = "tudou", dataTypeClass = String.class)
+    public CommonResult<OAuth2OpenAuthorizeInfoRespVO> authorize(@RequestParam("clientId") String clientId) {
+        // 0. 校验用户已经登录。通过 Spring Security 实现
+
+        // 1. 获得 Client 客户端的信息
+        OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientId, null,
+                null, null, null);
+        // 2. 获得用户已经授权的信息
+        List<OAuth2ApproveDO> approves = oauth2ApproveService.getApproveList(getLoginUserId(), getUserType(), clientId);
+        // 拼接返回
+        return success(OAuth2OpenConvert.INSTANCE.convert(client, approves));
+    }
+
+    /**
+     * 对应 Spring Security OAuth 的 AuthorizationEndpoint 类的 approveOrDeny 方法
+     *
+     * 场景一:【自动授权 autoApprove = true】
+     *      刚进入 sso.vue 界面,调用该接口,用户历史已经给该应用做过对应的授权,或者 OAuth2Client 支持该 scope 的自动授权
+     * 场景二:【手动授权 autoApprove = false】
+     *      在 sso.vue 界面,用户选择好 scope 授权范围,调用该接口,进行授权。此时,approved 为 true 或者 false
+     *
+     * 因为前后端分离,Axios 无法很好的处理 302 重定向,所以和 Spring Security OAuth 略有不同,返回结果是重定向的 URL,剩余交给前端处理
+     */
+    @PostMapping("/authorize")
+    @ApiOperation(value = "申请授权", notes = "适合 code 授权码模式,或者 implicit 简化模式;在 sso.vue 单点登录界面被【提交】调用")
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "response_type", required = true, value = "响应类型", example = "code", dataTypeClass = String.class),
+            @ApiImplicitParam(name = "client_id", required = true, value = "客户端编号", example = "tudou", dataTypeClass = String.class),
+            @ApiImplicitParam(name = "scope", value = "授权范围", example = "userinfo.read", dataTypeClass = String.class), // 使用 Map<String, Boolean> 格式,Spring MVC 暂时不支持这么接收参数
+            @ApiImplicitParam(name = "redirect_uri", required = true, value = "重定向 URI", example = "https://www.iocoder.cn", dataTypeClass = String.class),
+            @ApiImplicitParam(name = "autoApprove", required = true, value = "用户是否接受", example = "true", dataTypeClass = Boolean.class),
+            @ApiImplicitParam(name = "state", example = "123321", dataTypeClass = String.class)
+    })
+    @OperateLog(enable = false) // 避免 Post 请求被记录操作日志
+    public CommonResult<String> approveOrDeny(@RequestParam("response_type") String responseType,
+                                              @RequestParam("client_id") String clientId,
+                                              @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) {
+        @SuppressWarnings("unchecked")
+        Map<String, Boolean> scopes = JsonUtils.parseObject(scope, Map.class);
+        scopes = ObjectUtil.defaultIfNull(scopes, Collections.emptyMap());
+        // TODO 芋艿:针对 approved + scopes 在看看 spring security 的实现
+        // 0. 校验用户已经登录。通过 Spring Security 实现
+
+        // 1.1 校验 responseType 是否满足 code 或者 token 值
+        OAuth2GrantTypeEnum grantTypeEnum = getGrantTypeEnum(responseType);
+        // 1.2 校验 redirectUri 重定向域名是否合法 + 校验 scope 是否在 Client 授权范围内
+        OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientId, null,
+                grantTypeEnum.getGrantType(), scopes.keySet(), redirectUri);
+
+        // 2.1 假设 approved 为 null,说明是场景一
+        if (Boolean.TRUE.equals(autoApprove)) {
+            // 如果无法自动授权通过,则返回空 url,前端不进行跳转
+            if (!oauth2ApproveService.checkForPreApproval(getLoginUserId(), getUserType(), clientId, scopes.keySet())) {
+                return success(null);
+            }
+        } else { // 2.2 假设 approved 非 null,说明是场景二
+            // 如果计算后不通过,则跳转一个错误链接
+            if (!oauth2ApproveService.updateAfterApproval(getLoginUserId(), getUserType(), clientId, scopes)) {
+                return success(OAuth2Utils.buildUnsuccessfulRedirect(redirectUri, responseType, state,
+                        "access_denied", "User denied access"));
+            }
+        }
+
+        // 3.1 如果是 code 授权码模式,则发放 code 授权码,并重定向
+        List<String> approveScopes = convertList(scopes.entrySet(), Map.Entry::getKey, Map.Entry::getValue);
+        if (grantTypeEnum == OAuth2GrantTypeEnum.AUTHORIZATION_CODE) {
+            return success(getAuthorizationCodeRedirect(getLoginUserId(), client, approveScopes, redirectUri, state));
+        }
+        // 3.2 如果是 token 则是 implicit 简化模式,则发送 accessToken 访问令牌,并重定向
+        return success(getImplicitGrantRedirect(getLoginUserId(), client, approveScopes, redirectUri, state));
+    }
+
+    private static OAuth2GrantTypeEnum getGrantTypeEnum(String responseType) {
+        if (StrUtil.equals(responseType, "code")) {
+            return OAuth2GrantTypeEnum.AUTHORIZATION_CODE;
+        }
+        if (StrUtil.equalsAny(responseType, "token")) {
+            return OAuth2GrantTypeEnum.IMPLICIT;
+        }
+        throw exception0(BAD_REQUEST.getCode(), "response_type 参数值允许 code 和 token");
+    }
+
+    private String getImplicitGrantRedirect(Long userId, OAuth2ClientDO client,
+                                            List<String> scopes, String redirectUri, String state) {
+        // 1. 创建 access token 访问令牌
+        OAuth2AccessTokenDO accessTokenDO = oauth2GrantService.grantImplicit(userId, getUserType(), client.getClientId(), scopes);
+        Assert.notNull(accessTokenDO, "访问令牌不能为空"); // 防御性检查
+        // 2. 拼接重定向的 URL
+        // noinspection unchecked
+        return OAuth2Utils.buildImplicitRedirectUri(redirectUri, accessTokenDO.getAccessToken(), state, accessTokenDO.getExpiresTime(),
+                scopes, JsonUtils.parseObject(client.getAdditionalInformation(), Map.class));
+    }
+
+    private String getAuthorizationCodeRedirect(Long userId, OAuth2ClientDO client,
+                                                List<String> scopes, String redirectUri, String state) {
+        // 1. 创建 code 授权码
+        String authorizationCode = oauth2GrantService.grantAuthorizationCodeForCode(userId,getUserType(), client.getClientId(), scopes,
+                redirectUri, state);
+        // 2. 拼接重定向的 URL
+        return OAuth2Utils.buildAuthorizationCodeRedirectUri(redirectUri, authorizationCode, state);
+    }
+
+    private Integer getUserType() {
+        return UserTypeEnum.ADMIN.getValue();
+    }
+
+    private String[] obtainBasicAuthorization(HttpServletRequest request) {
+        String[] clientIdAndSecret = HttpUtils.obtainBasicAuthorization(request);
+        if (ArrayUtil.isEmpty(clientIdAndSecret) || clientIdAndSecret.length != 2) {
+            throw exception0(BAD_REQUEST.getCode(), "client_id 或 client_secret 未正确传递");
+        }
+        return clientIdAndSecret;
+    }
+
+    // ============ 用户操作的示例,展示 scope 的使用 ============
+
+    @Resource
+    private AdminUserService userService;
+    @Resource
+    private DeptService deptService;
+    @Resource
+    private PostService postService;
+
+    @GetMapping("/user/get")
+    @ApiOperation("获得用户基本信息")
+    @PreAuthorize("@ss.hasScope('user.read')")
+    public CommonResult<OAuth2OpenUserInfoRespVO> getUserInfo() {
+        // 获得用户基本信息
+        AdminUserDO user = userService.getUser(getLoginUserId());
+        OAuth2OpenUserInfoRespVO resp = OAuth2OpenConvert.INSTANCE.convert(user);
+        // 获得部门信息
+        if (user.getDeptId() != null) {
+            DeptDO dept = deptService.getDept(user.getDeptId());
+            resp.setDept(OAuth2OpenConvert.INSTANCE.convert(dept));
+        }
+        // 获得岗位信息
+        if (CollUtil.isNotEmpty(user.getPostIds())) {
+            List<PostDO> posts = postService.getPosts(user.getPostIds());
+            resp.setPosts(OAuth2OpenConvert.INSTANCE.convertList(posts));
+        }
+        return success(resp);
+    }
+
+    @PutMapping("/user/update")
+    @ApiOperation("更新用户基本信息")
+    @PreAuthorize("@ss.hasScope('user.write')")
+    public CommonResult<Boolean> updateUserInfo(@Valid @RequestBody OAuth2OpenUserUpdateReqVO reqVO) {
+        // 这里将 UserProfileUpdateReqVO =》UserProfileUpdateReqVO 对象,实现接口的复用。
+        // 主要是,AdminUserService 没有自己的 BO 对象,所以复用只能这么做
+        userService.updateUserProfile(getLoginUserId(), OAuth2OpenConvert.INSTANCE.convert(reqVO));
+        return success(true);
+    }
+
+}

+ 5 - 5
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/OAuth2TokenController.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2TokenController.java

@@ -1,14 +1,14 @@
-package cn.iocoder.yudao.module.system.controller.admin.auth;
+package cn.iocoder.yudao.module.system.controller.admin.oauth2;
 
 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.token.OAuth2AccessTokenPageReqVO;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.token.OAuth2AccessTokenRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.token.OAuth2AccessTokenPageReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.token.OAuth2AccessTokenRespVO;
 import cn.iocoder.yudao.module.system.convert.auth.OAuth2TokenConvert;
-import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2AccessTokenDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
 import cn.iocoder.yudao.module.system.enums.logger.LoginLogTypeEnum;
 import cn.iocoder.yudao.module.system.service.auth.AdminAuthService;
-import cn.iocoder.yudao.module.system.service.auth.OAuth2TokenService;
+import cn.iocoder.yudao.module.system.service.oauth2.OAuth2TokenService;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiImplicitParam;
 import io.swagger.annotations.ApiOperation;

+ 13 - 14
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/client/OAuth2ClientBaseVO.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/client/OAuth2ClientBaseVO.java

@@ -1,4 +1,4 @@
-package cn.iocoder.yudao.module.system.controller.admin.auth.vo.client;
+package cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client;
 
 import cn.hutool.core.util.StrUtil;
 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
@@ -18,47 +18,43 @@ import java.util.List;
 @Data
 public class OAuth2ClientBaseVO {
 
-    @ApiModelProperty(value = "客户端编号", required = true)
+    @ApiModelProperty(value = "客户端编号", required = true, example = "tudou")
     @NotNull(message = "客户端编号不能为空")
     private String clientId;
 
-    @ApiModelProperty(value = "客户端密钥", required = true)
+    @ApiModelProperty(value = "客户端密钥", required = true, example = "fan")
     @NotNull(message = "客户端密钥不能为空")
     private String secret;
 
-    @ApiModelProperty(value = "应用名", required = true)
+    @ApiModelProperty(value = "应用名", required = true, example = "土豆")
     @NotNull(message = "应用名不能为空")
     private String name;
 
-    @ApiModelProperty(value = "应用图标", required = true)
+    @ApiModelProperty(value = "应用图标", required = true, example = "https://www.iocoder.cn/xx.png")
     @NotNull(message = "应用图标不能为空")
     @URL(message = "应用图标的地址不正确")
     private String logo;
 
-    @ApiModelProperty(value = "应用描述")
+    @ApiModelProperty(value = "应用描述", example = "我是一个应用")
     private String description;
 
-    @ApiModelProperty(value = "状态", required = true)
+    @ApiModelProperty(value = "状态", required = true, example = "1", notes = "参见 CommonStatusEnum 枚举")
     @NotNull(message = "状态不能为空")
     private Integer status;
 
-    @ApiModelProperty(value = "访问令牌的有效期", required = true)
+    @ApiModelProperty(value = "访问令牌的有效期", required = true, example = "8640")
     @NotNull(message = "访问令牌的有效期不能为空")
     private Integer accessTokenValiditySeconds;
 
-    @ApiModelProperty(value = "刷新令牌的有效期", required = true)
+    @ApiModelProperty(value = "刷新令牌的有效期", required = true, example = "8640000")
     @NotNull(message = "刷新令牌的有效期不能为空")
     private Integer refreshTokenValiditySeconds;
 
-    @ApiModelProperty(value = "可重定向的 URI 地址", required = true)
+    @ApiModelProperty(value = "可重定向的 URI 地址", required = true, example = "https://www.iocoder.cn")
     @NotNull(message = "可重定向的 URI 地址不能为空")
     private List<@NotEmpty(message = "重定向的 URI 不能为空")
         @URL(message = "重定向的 URI 格式不正确") String> redirectUris;
 
-    @ApiModelProperty(value = "是否自动授权", required = true, example = "true")
-    @NotNull(message = "是否自动授权不能为空")
-    private Boolean autoApprove;
-
     @ApiModelProperty(value = "授权类型", required = true, example = "password", notes = "参见 OAuth2GrantTypeEnum 枚举")
     @NotNull(message = "授权类型不能为空")
     private List<String> authorizedGrantTypes;
@@ -66,6 +62,9 @@ public class OAuth2ClientBaseVO {
     @ApiModelProperty(value = "授权范围", example = "user_info")
     private List<String> scopes;
 
+    @ApiModelProperty(value = "自动通过的授权范围", example = "user_info")
+    private List<String> autoApproveScopes;
+
     @ApiModelProperty(value = "权限", example = "system:user:query")
     private List<String> authorities;
 

+ 1 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/client/OAuth2ClientCreateReqVO.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/client/OAuth2ClientCreateReqVO.java

@@ -1,4 +1,4 @@
-package cn.iocoder.yudao.module.system.controller.admin.auth.vo.client;
+package cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client;
 
 import lombok.*;
 import io.swagger.annotations.*;

+ 3 - 3
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/client/OAuth2ClientPageReqVO.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/client/OAuth2ClientPageReqVO.java

@@ -1,4 +1,4 @@
-package cn.iocoder.yudao.module.system.controller.admin.auth.vo.client;
+package cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client;
 
 import lombok.*;
 import io.swagger.annotations.*;
@@ -10,10 +10,10 @@ import cn.iocoder.yudao.framework.common.pojo.PageParam;
 @ToString(callSuper = true)
 public class OAuth2ClientPageReqVO extends PageParam {
 
-    @ApiModelProperty(value = "应用名")
+    @ApiModelProperty(value = "应用名", example = "土豆", notes = "模糊匹配")
     private String name;
 
-    @ApiModelProperty(value = "状态")
+    @ApiModelProperty(value = "状态", example = "1", notes = "参见 CommonStatusEnum 枚举")
     private Integer status;
 
 }

+ 2 - 2
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/client/OAuth2ClientRespVO.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/client/OAuth2ClientRespVO.java

@@ -1,4 +1,4 @@
-package cn.iocoder.yudao.module.system.controller.admin.auth.vo.client;
+package cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client;
 
 import io.swagger.annotations.ApiModel;
 import io.swagger.annotations.ApiModelProperty;
@@ -14,7 +14,7 @@ import java.util.Date;
 @ToString(callSuper = true)
 public class OAuth2ClientRespVO extends OAuth2ClientBaseVO {
 
-    @ApiModelProperty(value = "编号", required = true)
+    @ApiModelProperty(value = "编号", required = true, example = "1024")
     private Long id;
 
     @ApiModelProperty(value = "创建时间", required = true)

+ 2 - 2
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/client/OAuth2ClientUpdateReqVO.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/client/OAuth2ClientUpdateReqVO.java

@@ -1,4 +1,4 @@
-package cn.iocoder.yudao.module.system.controller.admin.auth.vo.client;
+package cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client;
 
 import io.swagger.annotations.ApiModel;
 import io.swagger.annotations.ApiModelProperty;
@@ -14,7 +14,7 @@ import javax.validation.constraints.NotNull;
 @ToString(callSuper = true)
 public class OAuth2ClientUpdateReqVO extends OAuth2ClientBaseVO {
 
-    @ApiModelProperty(value = "编号", required = true)
+    @ApiModelProperty(value = "编号", required = true, example = "1024")
     @NotNull(message = "编号不能为空")
     private Long id;
 

+ 35 - 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,35 @@
+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;
+
+    @ApiModelProperty(value = "授权范围", example = "user_info", notes = "如果多个授权范围,使用空格分隔")
+    private String scope;
+
+}

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

@@ -0,0 +1,39 @@
+package cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.open;
+
+import cn.iocoder.yudao.framework.common.core.KeyValue;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@ApiModel("管理后台 - 授权页的信息 Response VO")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class OAuth2OpenAuthorizeInfoRespVO {
+
+    /**
+     * 客户端
+     */
+    private Client client;
+
+    @ApiModelProperty(value = "scope 的选中信息", required = true, notes = "使用 List 保证有序性,Key 是 scope,Value 为是否选中")
+    private List<KeyValue<String, Boolean>> scopes;
+
+    @Data
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class Client {
+
+        @ApiModelProperty(value = "应用名", required = true, example = "土豆")
+        private String name;
+
+        @ApiModelProperty(value = "应用图标", required = true, example = "https://www.iocoder.cn/xx.png")
+        private String logo;
+
+    }
+
+}

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

@@ -0,0 +1,40 @@
+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;
+
+import java.util.Set;
+
+@ApiModel("管理后台 - 【开放接口】校验令牌 Response VO")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class OAuth2OpenCheckTokenRespVO {
+
+    @ApiModelProperty(value = "用户编号", required = true, example = "666")
+    @JsonProperty("user_id")
+    private Long userId;
+    @ApiModelProperty(value = "用户类型", required = true, example = "2", notes = "参见 UserTypeEnum 枚举")
+    @JsonProperty("user_type")
+    private Integer userType;
+    @ApiModelProperty(value = "租户编号", required = true, example = "1024")
+    @JsonProperty("tenant_id")
+    private Long tenantId;
+
+    @ApiModelProperty(value = "客户端编号", required = true, example = "car")
+    private String clientId;
+    @ApiModelProperty(value = "授权范围", required = true, example = "user_info")
+    private Set<String> scopes;
+
+    @ApiModelProperty(value = "访问令牌", required = true, example = "tudou")
+    @JsonProperty("access_token")
+    private String accessToken;
+
+    @ApiModelProperty(value = "过期时间", required = true, example = "1593092157", notes = "时间戳 / 1000,即单位:秒")
+    @JsonProperty("exp")
+    private Long exp;
+}

+ 71 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/open/user/OAuth2OpenUserInfoRespVO.java

@@ -0,0 +1,71 @@
+package cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.open.user;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@ApiModel("管理后台 - 【开放接口】获得用户基本信息 Response VO")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class OAuth2OpenUserInfoRespVO {
+
+    @ApiModelProperty(value = "用户编号", required = true, example = "1")
+    private Long id;
+
+    @ApiModelProperty(value = "用户昵称", required = true, example = "芋艿")
+    private String username;
+
+    @ApiModelProperty(value = "用户昵称", required = true, example = "芋道")
+    private String nickname;
+
+    @ApiModelProperty(value = "用户邮箱", example = "yudao@iocoder.cn")
+    private String email;
+    @ApiModelProperty(value = "手机号码", example = "15601691300")
+    private String mobile;
+
+    @ApiModelProperty(value = "用户性别", example = "1", notes = "参见 SexEnum 枚举类")
+    private Integer sex;
+
+    @ApiModelProperty(value = "用户头像", example = "https://www.iocoder.cn/xxx.png")
+    private String avatar;
+
+    /**
+     * 所在部门
+     */
+    private Dept dept;
+
+    /**
+     * 所属岗位数组
+     */
+    private List<Post> posts;
+
+    @ApiModel("部门")
+    @Data
+    public static class Dept {
+
+        @ApiModelProperty(value = "部门编号", required = true, example = "1")
+        private Long id;
+
+        @ApiModelProperty(value = "部门名称", required = true, example = "研发部")
+        private String name;
+
+    }
+
+    @ApiModel("岗位")
+    @Data
+    public static class Post {
+
+        @ApiModelProperty(value = "岗位编号", required = true, example = "1")
+        private Long id;
+
+        @ApiModelProperty(value = "岗位名称", required = true, example = "开发")
+        private String name;
+
+    }
+
+}

+ 35 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/open/user/OAuth2OpenUserUpdateReqVO.java

@@ -0,0 +1,35 @@
+package cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.open.user;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.hibernate.validator.constraints.Length;
+
+import javax.validation.constraints.Email;
+import javax.validation.constraints.Size;
+
+@ApiModel("管理后台 - 【开放接口】更新用户基本信息 Request VO")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class OAuth2OpenUserUpdateReqVO {
+
+    @ApiModelProperty(value = "用户昵称", required = true, example = "芋艿")
+    @Size(max = 30, message = "用户昵称长度不能超过 30 个字符")
+    private String nickname;
+
+    @ApiModelProperty(value = "用户邮箱", example = "yudao@iocoder.cn")
+    @Email(message = "邮箱格式不正确")
+    @Size(max = 50, message = "邮箱长度不能超过 50 个字符")
+    private String email;
+
+    @ApiModelProperty(value = "手机号码", example = "15601691300")
+    @Length(min = 11, max = 11, message = "手机号长度必须 11 位")
+    private String mobile;
+
+    @ApiModelProperty(value = "用户性别", example = "1", notes = "参见 SexEnum 枚举类")
+    private Integer sex;
+
+}

+ 1 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/token/OAuth2AccessTokenPageReqVO.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/token/OAuth2AccessTokenPageReqVO.java

@@ -1,4 +1,4 @@
-package cn.iocoder.yudao.module.system.controller.admin.auth.vo.token;
+package cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.token;
 
 import cn.iocoder.yudao.framework.common.pojo.PageParam;
 import io.swagger.annotations.ApiModel;

+ 1 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/token/OAuth2AccessTokenRespVO.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/token/OAuth2AccessTokenRespVO.java

@@ -1,4 +1,4 @@
-package cn.iocoder.yudao.module.system.controller.admin.auth.vo.token;
+package cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.token;
 
 import io.swagger.annotations.ApiModel;
 import io.swagger.annotations.ApiModelProperty;

+ 0 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/user/UserProfileController.java

@@ -46,7 +46,6 @@ public class UserProfileController {
     private AdminUserService userService;
     @Resource
     private DeptService deptService;
-
     @Resource
     private PostService postService;
     @Resource

+ 1 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/user/vo/profile/UserProfileUpdateReqVO.java

@@ -13,7 +13,7 @@ import javax.validation.constraints.Size;
 public class UserProfileUpdateReqVO {
 
     @ApiModelProperty(value = "用户昵称", required = true, example = "芋艿")
-    @Size(max = 30, message = "用户昵称长度不能超过30个字符")
+    @Size(max = 30, message = "用户昵称长度不能超过 30 个字符")
     private String nickname;
 
     @ApiModelProperty(value = "用户邮箱", example = "yudao@iocoder.cn")

+ 2 - 2
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/convert/auth/AuthConvert.java

@@ -4,8 +4,8 @@ import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
 import cn.iocoder.yudao.module.system.api.sms.dto.code.SmsCodeSendReqDTO;
 import cn.iocoder.yudao.module.system.api.sms.dto.code.SmsCodeUseReqDTO;
 import cn.iocoder.yudao.module.system.api.social.dto.SocialUserBindReqDTO;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.auth.*;
-import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2AccessTokenDO;
+import cn.iocoder.yudao.module.system.controller.admin.auth.vo.*;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
 import cn.iocoder.yudao.module.system.dal.dataobject.permission.MenuDO;
 import cn.iocoder.yudao.module.system.dal.dataobject.permission.RoleDO;
 import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;

+ 4 - 4
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/convert/auth/OAuth2ClientConvert.java

@@ -1,10 +1,10 @@
 package cn.iocoder.yudao.module.system.convert.auth;
 
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.client.OAuth2ClientCreateReqVO;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.client.OAuth2ClientRespVO;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.client.OAuth2ClientUpdateReqVO;
-import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2ClientDO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client.OAuth2ClientCreateReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client.OAuth2ClientRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client.OAuth2ClientUpdateReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2ClientDO;
 import org.mapstruct.Mapper;
 import org.mapstruct.factory.Mappers;
 

+ 2 - 2
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/convert/auth/OAuth2TokenConvert.java

@@ -3,8 +3,8 @@ package cn.iocoder.yudao.module.system.convert.auth;
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
 import cn.iocoder.yudao.module.system.api.auth.dto.OAuth2AccessTokenCheckRespDTO;
 import cn.iocoder.yudao.module.system.api.auth.dto.OAuth2AccessTokenRespDTO;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.token.OAuth2AccessTokenRespVO;
-import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2AccessTokenDO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.token.OAuth2AccessTokenRespVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
 import org.mapstruct.Mapper;
 import org.mapstruct.factory.Mappers;
 

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

@@ -0,0 +1,70 @@
+package cn.iocoder.yudao.module.system.convert.oauth2;
+
+import cn.iocoder.yudao.framework.common.core.KeyValue;
+import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
+import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
+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.controller.admin.oauth2.vo.open.OAuth2OpenAuthorizeInfoRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.open.OAuth2OpenCheckTokenRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.open.user.OAuth2OpenUserInfoRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.open.user.OAuth2OpenUserUpdateReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.user.vo.profile.UserProfileUpdateReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.dept.DeptDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.dept.PostDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2ApproveDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2ClientDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
+import cn.iocoder.yudao.module.system.util.oauth2.OAuth2Utils;
+import org.mapstruct.Mapper;
+import org.mapstruct.factory.Mappers;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+@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()));
+        respVO.setScope(OAuth2Utils.buildScopeStr(bean.getScopes()));
+        return respVO;
+    }
+    OAuth2OpenAccessTokenRespVO convert0(OAuth2AccessTokenDO bean);
+
+    default OAuth2OpenCheckTokenRespVO convert2(OAuth2AccessTokenDO bean) {
+        OAuth2OpenCheckTokenRespVO respVO = convert3(bean);
+        respVO.setExp(bean.getExpiresTime().getTime() / 1000L);
+        respVO.setUserType(UserTypeEnum.ADMIN.getValue());
+        return respVO;
+    }
+    OAuth2OpenCheckTokenRespVO convert3(OAuth2AccessTokenDO bean);
+
+    // ============ 用户操作的示例 ============
+
+    OAuth2OpenUserInfoRespVO convert(AdminUserDO bean);
+    OAuth2OpenUserInfoRespVO.Dept convert(DeptDO dept);
+    List<OAuth2OpenUserInfoRespVO.Post> convertList(List<PostDO> list);
+
+    UserProfileUpdateReqVO convert(OAuth2OpenUserUpdateReqVO bean);
+
+    default OAuth2OpenAuthorizeInfoRespVO convert(OAuth2ClientDO client, List<OAuth2ApproveDO> approves) {
+        // 构建 scopes
+        List<KeyValue<String, Boolean>> scopes = new ArrayList<>(client.getScopes().size());
+        Map<String, OAuth2ApproveDO> approveMap = CollectionUtils.convertMap(approves, OAuth2ApproveDO::getScope);
+        client.getScopes().forEach(scope -> {
+            OAuth2ApproveDO approve = approveMap.get(scope);
+            scopes.add(new KeyValue<>(scope, approve != null ? approve.getApproved() : false));
+        });
+        // 拼接返回
+        return new OAuth2OpenAuthorizeInfoRespVO(
+                new OAuth2OpenAuthorizeInfoRespVO.Client(client.getName(), client.getLogo()), scopes);
+    }
+
+}

+ 9 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/auth/OAuth2AccessTokenDO.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/oauth2/OAuth2AccessTokenDO.java

@@ -1,15 +1,18 @@
-package cn.iocoder.yudao.module.system.dal.dataobject.auth;
+package cn.iocoder.yudao.module.system.dal.dataobject.oauth2;
 
 import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
 import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
 import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 import lombok.experimental.Accessors;
 
 import java.util.Date;
+import java.util.List;
 
 /**
  * OAuth2 访问令牌 DO
@@ -55,6 +58,11 @@ public class OAuth2AccessTokenDO extends TenantBaseDO {
      * 关联 {@link OAuth2ClientDO#getId()}
      */
     private String clientId;
+    /**
+     * 授权范围
+     */
+    @TableField(typeHandler = JacksonTypeHandler.class)
+    private List<String> scopes;
     /**
      * 过期时间
      */

+ 63 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/oauth2/OAuth2ApproveDO.java

@@ -0,0 +1,63 @@
+package cn.iocoder.yudao.module.system.dal.dataobject.oauth2;
+
+import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
+import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.Date;
+
+/**
+ * OAuth2 批准 DO
+ *
+ * 用户在 sso.vue 界面时,记录接受的 scope 列表
+ *
+ * @author 芋道源码
+ */
+@TableName(value = "system_oauth2_approve", autoResultMap = true)
+@KeySequence("system_oauth2_approve_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class OAuth2ApproveDO extends BaseDO {
+
+    /**
+     * 编号,数据库自增
+     */
+    @TableId
+    private Long id;
+    /**
+     * 用户编号
+     */
+    private Long userId;
+    /**
+     * 用户类型
+     *
+     * 枚举 {@link UserTypeEnum}
+     */
+    private Integer userType;
+    /**
+     * 客户端编号
+     *
+     * 关联 {@link OAuth2ClientDO#getId()}
+     */
+    private String clientId;
+    /**
+     * 授权范围
+     */
+    private String scope;
+    /**
+     * 是否接受
+     *
+     * true - 接受
+     * false - 拒绝
+     */
+    private Boolean approved;
+    /**
+     * 过期时间
+     */
+    private Date expiresTime;
+
+}

+ 8 - 5
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/auth/OAuth2ClientDO.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/oauth2/OAuth2ClientDO.java

@@ -1,4 +1,4 @@
-package cn.iocoder.yudao.module.system.dal.dataobject.auth;
+package cn.iocoder.yudao.module.system.dal.dataobject.oauth2;
 
 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
 import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
@@ -70,10 +70,6 @@ public class OAuth2ClientDO extends BaseDO {
      */
     @TableField(typeHandler = JacksonTypeHandler.class)
     private List<String> redirectUris;
-    /**
-     * 是否自动授权
-     */
-    private Boolean autoApprove;
     /**
      * 授权类型(模式)
      *
@@ -86,6 +82,13 @@ public class OAuth2ClientDO extends BaseDO {
      */
     @TableField(typeHandler = JacksonTypeHandler.class)
     private List<String> scopes;
+    /**
+     * 自动授权的 Scope
+     *
+     * code 授权时,如果 scope 在这个范围内,则自动通过
+     */
+    @TableField(typeHandler = JacksonTypeHandler.class)
+    private List<String> autoApproveScopes;
     /**
      * 权限
      */

+ 17 - 11
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/auth/OAuth2CodeDO.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/oauth2/OAuth2CodeDO.java

@@ -1,20 +1,23 @@
-package cn.iocoder.yudao.module.system.dal.dataobject.auth;
+package cn.iocoder.yudao.module.system.dal.dataobject.oauth2;
 
 import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
 import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
 import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 
 import java.util.Date;
+import java.util.List;
 
 /**
  * OAuth2 授权码 DO
  *
  * @author 芋道源码
  */
-@TableName("system_oauth2_code")
+@TableName(value = "system_oauth2_code", autoResultMap = true)
 @KeySequence("system_oauth2_code_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
 @Data
 @EqualsAndHashCode(callSuper = true)
@@ -41,22 +44,25 @@ public class OAuth2CodeDO extends BaseDO {
     /**
      * 客户端编号
      *
-     * 关联 {@link OAuth2ClientDO#getId()}
+     * 关联 {@link OAuth2ClientDO#getClientId()}
      */
     private String clientId;
     /**
-     * 刷新令牌
-     *
-     * 关联 {@link OAuth2RefreshTokenDO#getRefreshToken()}
+     * 授权范围
      */
-    private String refreshToken;
+    @TableField(typeHandler = JacksonTypeHandler.class)
+    private List<String> scopes;
     /**
-     * 过期时间
+     * 重定向地址
      */
-    private Date expiresTime;
+    private String redirectUri;
     /**
-     * 创建 IP
+     * 状态
      */
-    private String createIp;
+    private String state;
+    /**
+     * 过期时间
+     */
+    private Date expiresTime;
 
 }

+ 9 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/auth/OAuth2RefreshTokenDO.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/oauth2/OAuth2RefreshTokenDO.java

@@ -1,14 +1,17 @@
-package cn.iocoder.yudao.module.system.dal.dataobject.auth;
+package cn.iocoder.yudao.module.system.dal.dataobject.oauth2;
 
 import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
 import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
 import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 import lombok.experimental.Accessors;
 
 import java.util.Date;
+import java.util.List;
 
 /**
  * OAuth2 刷新令牌
@@ -47,6 +50,11 @@ public class OAuth2RefreshTokenDO extends BaseDO {
      * 关联 {@link OAuth2ClientDO#getId()}
      */
     private String clientId;
+    /**
+     * 授权范围
+     */
+    @TableField(typeHandler = JacksonTypeHandler.class)
+    private List<String> scopes;
     /**
      * 过期时间
      */

+ 3 - 3
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/auth/OAuth2AccessTokenMapper.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2AccessTokenMapper.java

@@ -1,10 +1,10 @@
-package cn.iocoder.yudao.module.system.dal.mysql.auth;
+package cn.iocoder.yudao.module.system.dal.mysql.oauth2;
 
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.token.OAuth2AccessTokenPageReqVO;
-import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2AccessTokenDO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.token.OAuth2AccessTokenPageReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
 import org.apache.ibatis.annotations.Mapper;
 
 import java.util.Date;

+ 28 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2ApproveMapper.java

@@ -0,0 +1,28 @@
+package cn.iocoder.yudao.module.system.dal.mysql.oauth2;
+
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2ApproveDO;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+@Mapper
+public interface OAuth2ApproveMapper extends BaseMapperX<OAuth2ApproveDO> {
+
+    default int update(OAuth2ApproveDO updateObj) {
+        return update(updateObj, new LambdaQueryWrapperX<OAuth2ApproveDO>()
+                .eq(OAuth2ApproveDO::getUserId, updateObj.getUserId())
+                .eq(OAuth2ApproveDO::getUserType, updateObj.getUserType())
+                .eq(OAuth2ApproveDO::getClientId, updateObj.getClientId())
+                .eq(OAuth2ApproveDO::getScope, updateObj.getScope()));
+    }
+
+    default List<OAuth2ApproveDO> selectListByUserIdAndUserTypeAndClientId(Long userId, Integer userType, String clientId) {
+        return selectList(new LambdaQueryWrapperX<OAuth2ApproveDO>()
+                .eq(OAuth2ApproveDO::getUserId, userId)
+                .eq(OAuth2ApproveDO::getUserType, userType)
+                .eq(OAuth2ApproveDO::getClientId, clientId));
+    }
+
+}

+ 3 - 3
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/auth/OAuth2ClientMapper.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2ClientMapper.java

@@ -1,10 +1,10 @@
-package cn.iocoder.yudao.module.system.dal.mysql.auth;
+package cn.iocoder.yudao.module.system.dal.mysql.oauth2;
 
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.client.OAuth2ClientPageReqVO;
-import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2ClientDO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client.OAuth2ClientPageReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2ClientDO;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Select;
 

+ 14 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2CodeMapper.java

@@ -0,0 +1,14 @@
+package cn.iocoder.yudao.module.system.dal.mysql.oauth2;
+
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2CodeDO;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface OAuth2CodeMapper extends BaseMapperX<OAuth2CodeDO> {
+
+    default OAuth2CodeDO selectByCode(String code) {
+        return selectOne(OAuth2CodeDO::getCode, code);
+    }
+
+}

+ 2 - 2
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/auth/OAuth2RefreshTokenMapper.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2RefreshTokenMapper.java

@@ -1,8 +1,8 @@
-package cn.iocoder.yudao.module.system.dal.mysql.auth;
+package cn.iocoder.yudao.module.system.dal.mysql.oauth2;
 
 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
-import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2RefreshTokenDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2RefreshTokenDO;
 import org.apache.ibatis.annotations.Mapper;
 
 @Mapper

+ 1 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/redis/RedisKeyConstants.java

@@ -1,7 +1,7 @@
 package cn.iocoder.yudao.module.system.dal.redis;
 
 import cn.iocoder.yudao.framework.redis.core.RedisKeyDefine;
-import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2AccessTokenDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
 
 import java.time.Duration;
 

+ 2 - 2
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/redis/auth/OAuth2AccessTokenRedisDAO.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/redis/oauth2/OAuth2AccessTokenRedisDAO.java

@@ -1,8 +1,8 @@
-package cn.iocoder.yudao.module.system.dal.redis.auth;
+package cn.iocoder.yudao.module.system.dal.redis.oauth2;
 
 import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
-import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2AccessTokenDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
 import org.springframework.data.redis.core.StringRedisTemplate;
 import org.springframework.stereotype.Repository;
 

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

@@ -35,6 +35,9 @@ 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();
+                registry.antMatchers(buildAdminApi("/system/oauth2/check-token")).permitAll();
             }
 
         };

+ 1 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/mq/consumer/auth/OAuth2ClientRefreshConsumer.java

@@ -2,7 +2,7 @@ package cn.iocoder.yudao.module.system.mq.consumer.auth;
 
 import cn.iocoder.yudao.framework.mq.core.pubsub.AbstractChannelMessageListener;
 import cn.iocoder.yudao.module.system.mq.message.auth.OAuth2ClientRefreshMessage;
-import cn.iocoder.yudao.module.system.service.auth.OAuth2ClientService;
+import cn.iocoder.yudao.module.system.service.oauth2.OAuth2ClientService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
 

+ 11 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthService.java

@@ -1,6 +1,7 @@
 package cn.iocoder.yudao.module.system.service.auth;
 
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.auth.*;
+import cn.iocoder.yudao.module.system.controller.admin.auth.vo.*;
+import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
 
 import javax.validation.Valid;
 
@@ -13,6 +14,15 @@ import javax.validation.Valid;
  */
 public interface AdminAuthService {
 
+    /**
+     * 验证账号 + 密码。如果通过,则返回用户
+     *
+     * @param username 账号
+     * @param password 密码
+     * @return 用户
+     */
+    AdminUserDO authenticate(String username, String password);
+
     /**
      * 账号登录
      *

+ 27 - 26
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java

@@ -8,9 +8,9 @@ import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
 import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils;
 import cn.iocoder.yudao.module.system.api.logger.dto.LoginLogCreateReqDTO;
 import cn.iocoder.yudao.module.system.api.sms.SmsCodeApi;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.auth.*;
+import cn.iocoder.yudao.module.system.controller.admin.auth.vo.*;
 import cn.iocoder.yudao.module.system.convert.auth.AuthConvert;
-import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2AccessTokenDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
 import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
 import cn.iocoder.yudao.module.system.enums.auth.OAuth2ClientConstants;
 import cn.iocoder.yudao.module.system.enums.logger.LoginLogTypeEnum;
@@ -19,6 +19,7 @@ import cn.iocoder.yudao.module.system.enums.sms.SmsSceneEnum;
 import cn.iocoder.yudao.module.system.service.common.CaptchaService;
 import cn.iocoder.yudao.module.system.service.logger.LoginLogService;
 import cn.iocoder.yudao.module.system.service.member.MemberService;
+import cn.iocoder.yudao.module.system.service.oauth2.OAuth2TokenService;
 import cn.iocoder.yudao.module.system.service.social.SocialUserService;
 import cn.iocoder.yudao.module.system.service.user.AdminUserService;
 import com.google.common.annotations.VisibleForTesting;
@@ -61,13 +62,34 @@ public class AdminAuthServiceImpl implements AdminAuthService {
     @Resource
     private SmsCodeApi smsCodeApi;
 
+    @Override
+    public AdminUserDO authenticate(String username, String password) {
+        final LoginLogTypeEnum logTypeEnum = LoginLogTypeEnum.LOGIN_USERNAME;
+        // 校验账号是否存在
+        AdminUserDO user = userService.getUserByUsername(username);
+        if (user == null) {
+            createLoginLog(null, username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS);
+            throw exception(AUTH_LOGIN_BAD_CREDENTIALS);
+        }
+        if (!userService.isPasswordMatch(password, user.getPassword())) {
+            createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS);
+            throw exception(AUTH_LOGIN_BAD_CREDENTIALS);
+        }
+        // 校验是否禁用
+        if (ObjectUtil.notEqual(user.getStatus(), CommonStatusEnum.ENABLE.getStatus())) {
+            createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.USER_DISABLED);
+            throw exception(AUTH_LOGIN_USER_DISABLED);
+        }
+        return user;
+    }
+
     @Override
     public AuthLoginRespVO login(AuthLoginReqVO reqVO) {
         // 判断验证码是否正确
         verifyCaptcha(reqVO);
 
         // 使用账号密码,进行登录
-        AdminUserDO user = login0(reqVO.getUsername(), reqVO.getPassword());
+        AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword());
 
         // 创建 Token 令牌,记录登录日志
         return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME);
@@ -124,27 +146,6 @@ public class AdminAuthServiceImpl implements AdminAuthService {
         captchaService.deleteCaptchaCode(reqVO.getUuid());
     }
 
-    @VisibleForTesting
-    AdminUserDO login0(String username, String password) {
-        final LoginLogTypeEnum logTypeEnum = LoginLogTypeEnum.LOGIN_USERNAME;
-        // 校验账号是否存在
-        AdminUserDO user = userService.getUserByUsername(username);
-        if (user == null) {
-            createLoginLog(null, username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS);
-            throw exception(AUTH_LOGIN_BAD_CREDENTIALS);
-        }
-        if (!userService.isPasswordMatch(password, user.getPassword())) {
-            createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS);
-            throw exception(AUTH_LOGIN_BAD_CREDENTIALS);
-        }
-        // 校验是否禁用
-        if (ObjectUtil.notEqual(user.getStatus(), CommonStatusEnum.ENABLE.getStatus())) {
-            createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.USER_DISABLED);
-            throw exception(AUTH_LOGIN_USER_DISABLED);
-        }
-        return user;
-    }
-
     private void createLoginLog(Long userId, String username,
                                 LoginLogTypeEnum logTypeEnum, LoginResultEnum loginResult) {
         // 插入登录日志
@@ -186,7 +187,7 @@ public class AdminAuthServiceImpl implements AdminAuthService {
     @Override
     public AuthLoginRespVO socialBindLogin(AuthSocialBindLoginReqVO reqVO) {
         // 使用账号密码,进行登录。
-        AdminUserDO user = login0(reqVO.getUsername(), reqVO.getPassword());
+        AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword());
 
         // 绑定社交用户
         socialUserService.bindSocialUser(AuthConvert.INSTANCE.convert(user.getId(), getUserType().getValue(), reqVO));
@@ -206,7 +207,7 @@ public class AdminAuthServiceImpl implements AdminAuthService {
         createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS);
         // 创建访问令牌
         OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken(userId, getUserType().getValue(),
-                OAuth2ClientConstants.CLIENT_ID_DEFAULT);
+                OAuth2ClientConstants.CLIENT_ID_DEFAULT, null);
         // 构建返回结果
         return AuthConvert.INSTANCE.convert(accessTokenDO);
     }

+ 0 - 14
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminOAuth2Service.java

@@ -1,14 +0,0 @@
-package cn.iocoder.yudao.module.system.service.auth;
-
-/**
- * 管理后台的 OAuth2 Service 接口
- *
- * 将自身的 AdminUser 用户,授权给第三方应用,采用 OAuth2.0 的协议。
- *
- * 问题:为什么自身也作为一个第三方应用,也走这套流程呢?
- * 回复:当然可以这么做,采用 Implicit 模式。考虑到大多数开发者使用不到这个特性,OAuth2.0 毕竟有一定学习成本,所以暂时没有采取这种方式。
- *
- * @author 芋道源码
- */
-public interface AdminOAuth2Service {
-}

+ 0 - 11
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/auth/OAuth2CodeService.java

@@ -1,11 +0,0 @@
-package cn.iocoder.yudao.module.system.service.auth;
-
-/**
- * OAuth2.0 授权码 Service 接口
- *
- * 从功能上,和 Spring Security OAuth 的 JdbcAuthorizationCodeServices 的功能,提供授权码的操作
- *
- * @author 芋道源码
- */
-public class OAuth2CodeService {
-}

+ 52 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2ApproveService.java

@@ -0,0 +1,52 @@
+package cn.iocoder.yudao.module.system.service.oauth2;
+
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2ApproveDO;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * OAuth2 批准 Service 接口
+ *
+ * 从功能上,和 Spring Security OAuth 的 ApprovalStoreUserApprovalHandler 的功能,记录用户针对指定客户端的授权,减少手动确定。
+ *
+ * @author 芋道源码
+ */
+public interface OAuth2ApproveService {
+
+    /**
+     * 获得指定用户,针对指定客户端的指定授权,是否通过
+     *
+     * 参考 ApprovalStoreUserApprovalHandler 的 checkForPreApproval 方法
+     *
+     * @param userId 用户编号
+     * @param userType 用户类型
+     * @param clientId 客户端编号
+     * @param requestedScopes 授权范围
+     * @return 是否授权通过
+     */
+    boolean checkForPreApproval(Long userId, Integer userType, String clientId, Collection<String> requestedScopes);
+
+    /**
+     * 在用户发起批准时,基于 scopes 的选项,计算最终是否通过
+     *
+     * @param userId 用户编号
+     * @param userType 用户类型
+     * @param clientId 客户端编号
+     * @param requestedScopes 授权范围
+     * @return 是否授权通过
+     */
+    boolean updateAfterApproval(Long userId, Integer userType, String clientId, Map<String, Boolean> requestedScopes);
+
+    /**
+     * 获得用户的批准列表,排除已过期的
+     *
+     * @param userId 用户编号
+     * @param userType 用户类型
+     * @param clientId 客户端编号
+     * @return 是否授权通过
+     */
+    List<OAuth2ApproveDO> getApproveList(Long userId, Integer userType, String clientId);
+
+}

+ 98 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2ApproveServiceImpl.java

@@ -0,0 +1,98 @@
+package cn.iocoder.yudao.module.system.service.oauth2;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.lang.Assert;
+import cn.iocoder.yudao.framework.common.util.date.DateUtils;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2ApproveDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2ClientDO;
+import cn.iocoder.yudao.module.system.dal.mysql.oauth2.OAuth2ApproveMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
+
+/**
+ * OAuth2 批准 Service 实现类
+ *
+ * @author 芋道源码
+ */
+@Service
+@Validated
+public class OAuth2ApproveServiceImpl implements OAuth2ApproveService {
+
+    /**
+     * 批准的过期时间,默认 30 天
+     */
+    private static final Integer TIMEOUT = 30 * 24 * 60 * 60; // 单位:秒
+
+    @Resource
+    private OAuth2ClientService oauth2ClientService;
+
+    @Resource
+    private OAuth2ApproveMapper oauth2ApproveMapper;
+
+    @Override
+    public boolean checkForPreApproval(Long userId, Integer userType, String clientId, Collection<String> requestedScopes) {
+        // 第一步,基于 Client 的自动授权计算,如果 scopes 都在自动授权中,则返回 true 通过
+        OAuth2ClientDO clientDO = oauth2ClientService.validOAuthClientFromCache(clientId);
+        Assert.notNull(clientDO, "客户端不能为空"); // 防御性编程
+        if (CollUtil.containsAll(clientDO.getAutoApproveScopes(), requestedScopes)) {
+            // gh-877 - if all scopes are auto approved, approvals still need to be added to the approval store.
+            Date expireTime = DateUtils.addDate(Calendar.SECOND, TIMEOUT);
+            for (String scope : requestedScopes) {
+                saveApprove(userId, userType, clientId, scope, true, expireTime);
+            }
+            return true;
+        }
+
+        // 第二步,算上用户已经批准的授权。如果 scopes 都包含,则返回 true
+        List<OAuth2ApproveDO> approveDOs = oauth2ApproveMapper.selectListByUserIdAndUserTypeAndClientId(
+                userId, userType, clientId);
+        Set<String> scopes = convertSet(approveDOs, OAuth2ApproveDO::getScope,
+                o -> o.getApproved() && !DateUtils.isExpired(o.getExpiresTime())); // 只保留未过期的
+        return CollUtil.containsAll(scopes, requestedScopes);
+    }
+
+    @Override
+    public boolean updateAfterApproval(Long userId, Integer userType, String clientId, Map<String, Boolean> requestedScopes) {
+        // 如果 requestedScopes 为空,说明没有要求,则返回 true 通过
+        if (CollUtil.isEmpty(requestedScopes)) {
+            return true;
+        }
+
+        // 更新批准的信息
+        boolean success = false; // 需要至少有一个同意
+        Date expireTime = DateUtils.addDate(Calendar.SECOND, TIMEOUT);
+        for (Map.Entry<String, Boolean> entry :requestedScopes.entrySet()) {
+            if (entry.getValue()) {
+                success = true;
+            }
+            saveApprove(userId, userType, clientId, entry.getKey(), entry.getValue(), expireTime);
+        }
+        return success;
+    }
+
+    @Override
+    public List<OAuth2ApproveDO> getApproveList(Long userId, Integer userType, String clientId) {
+        List<OAuth2ApproveDO> approveDOs = oauth2ApproveMapper.selectListByUserIdAndUserTypeAndClientId(
+                userId, userType, clientId);
+        approveDOs.removeIf(o -> DateUtils.isExpired(o.getExpiresTime()));
+        return approveDOs;
+    }
+
+    private void saveApprove(Long userId, Integer userType, String clientId,
+                             String scope, Boolean approved, Date expireTime) {
+        // 先更新
+        OAuth2ApproveDO approveDO = new OAuth2ApproveDO().setUserId(userId).setUserType(userType)
+                .setClientId(clientId).setScope(scope).setApproved(approved).setExpiresTime(expireTime);
+        if (oauth2ApproveMapper.update(approveDO) == 1) {
+            return;
+        }
+        // 失败,则说明不存在,进行更新
+        oauth2ApproveMapper.insert(approveDO);
+    }
+
+}

+ 23 - 6
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/auth/OAuth2ClientService.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2ClientService.java

@@ -1,12 +1,13 @@
-package cn.iocoder.yudao.module.system.service.auth;
+package cn.iocoder.yudao.module.system.service.oauth2;
 
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.client.OAuth2ClientCreateReqVO;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.client.OAuth2ClientPageReqVO;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.client.OAuth2ClientUpdateReqVO;
-import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2ClientDO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client.OAuth2ClientCreateReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client.OAuth2ClientPageReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client.OAuth2ClientUpdateReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2ClientDO;
 
 import javax.validation.Valid;
+import java.util.Collection;
 
 /**
  * OAuth2.0 Client Service 接口
@@ -63,9 +64,25 @@ public interface OAuth2ClientService {
     /**
      * 从缓存中,校验客户端是否合法
      *
+     * @return 客户端
+     */
+    default OAuth2ClientDO validOAuthClientFromCache(String clientId) {
+        return validOAuthClientFromCache(clientId, null, null, null, null);
+    }
+
+    /**
+     * 从缓存中,校验客户端是否合法
+     *
+     * 非空时,进行校验
+     *
      * @param clientId 客户端编号
+     * @param clientSecret 客户端密钥
+     * @param authorizedGrantType 授权方式
+     * @param scopes 授权范围
+     * @param redirectUri 重定向地址
      * @return 客户端
      */
-    OAuth2ClientDO validOAuthClientFromCache(String clientId);
+    OAuth2ClientDO validOAuthClientFromCache(String clientId, String clientSecret,
+                                             String authorizedGrantType, Collection<String> scopes, String redirectUri);
 
 }

+ 40 - 13
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/auth/OAuth2ClientServiceImpl.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2ClientServiceImpl.java

@@ -1,13 +1,17 @@
-package cn.iocoder.yudao.module.system.service.auth;
+package cn.iocoder.yudao.module.system.service.oauth2;
 
 import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.client.OAuth2ClientCreateReqVO;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.client.OAuth2ClientPageReqVO;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.client.OAuth2ClientUpdateReqVO;
+import cn.iocoder.yudao.framework.common.util.string.StrUtils;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client.OAuth2ClientCreateReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client.OAuth2ClientPageReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client.OAuth2ClientUpdateReqVO;
 import cn.iocoder.yudao.module.system.convert.auth.OAuth2ClientConvert;
-import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2ClientDO;
-import cn.iocoder.yudao.module.system.dal.mysql.auth.OAuth2ClientMapper;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2ClientDO;
+import cn.iocoder.yudao.module.system.dal.mysql.oauth2.OAuth2ClientMapper;
 import cn.iocoder.yudao.module.system.mq.producer.auth.OAuth2ClientProducer;
 import com.google.common.annotations.VisibleForTesting;
 import lombok.Getter;
@@ -18,15 +22,12 @@ import org.springframework.validation.annotation.Validated;
 
 import javax.annotation.PostConstruct;
 import javax.annotation.Resource;
-import java.util.Date;
-import java.util.List;
-import java.util.Map;
+import java.util.*;
 
 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.getMaxValue;
-import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.OAUTH2_CLIENT_EXISTS;
-import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.OAUTH2_CLIENT_NOT_EXISTS;
+import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*;
 
 /**
  * OAuth2.0 Client Service 实现类
@@ -175,8 +176,34 @@ public class OAuth2ClientServiceImpl implements OAuth2ClientService {
     }
 
     @Override
-    public OAuth2ClientDO validOAuthClientFromCache(String clientId) {
-        return clientCache.get(clientId);
+    public OAuth2ClientDO validOAuthClientFromCache(String clientId, String clientSecret,
+                                                    String authorizedGrantType, Collection<String> scopes, String redirectUri) {
+        // 校验客户端存在、且开启
+        OAuth2ClientDO client = clientCache.get(clientId);
+        if (client == null) {
+            throw exception(OAUTH2_CLIENT_NOT_EXISTS);
+        }
+        if (ObjectUtil.notEqual(client.getStatus(), CommonStatusEnum.ENABLE.getStatus())) {
+            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);
+        }
+        // 校验授权范围
+        if (CollUtil.isNotEmpty(scopes) && !CollUtil.containsAll(client.getScopes(), scopes)) {
+            throw exception(OAUTH2_CLIENT_SCOPE_OVER);
+        }
+        // 校验回调地址
+        if (StrUtil.isNotEmpty(redirectUri) && !StrUtils.startWithAny(redirectUri, client.getRedirectUris())) {
+            throw exception(OAUTH2_CLIENT_REDIRECT_URI_NOT_MATCH, redirectUri);
+        }
+        return client;
     }
 
 }

+ 39 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2CodeService.java

@@ -0,0 +1,39 @@
+package cn.iocoder.yudao.module.system.service.oauth2;
+
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2CodeDO;
+
+import java.util.List;
+
+/**
+ * OAuth2.0 授权码 Service 接口
+ *
+ * 从功能上,和 Spring Security OAuth 的 JdbcAuthorizationCodeServices 的功能,提供授权码的操作
+ *
+ * @author 芋道源码
+ */
+public interface OAuth2CodeService {
+
+    /**
+     * 创建授权码
+     *
+     * 参考 JdbcAuthorizationCodeServices 的 createAuthorizationCode 方法
+     *
+     * @param userId 用户编号
+     * @param userType 用户类型
+     * @param clientId 客户端编号
+     * @param scopes 授权范围
+     * @param redirectUri 重定向 URI
+     * @param state 状态
+     * @return 授权码的信息
+     */
+    OAuth2CodeDO createAuthorizationCode(Long userId, Integer userType, String clientId, List<String> scopes,
+                                         String redirectUri, String state);
+
+    /**
+     * 使用授权码
+     *
+     * @param code 授权码
+     */
+    OAuth2CodeDO consumeAuthorizationCode(String code);
+
+}

+ 64 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2CodeServiceImpl.java

@@ -0,0 +1,64 @@
+package cn.iocoder.yudao.module.system.service.oauth2;
+
+import cn.hutool.core.util.IdUtil;
+import cn.iocoder.yudao.framework.common.util.date.DateUtils;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2CodeDO;
+import cn.iocoder.yudao.module.system.dal.mysql.oauth2.OAuth2CodeMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import javax.annotation.Resource;
+import java.util.Calendar;
+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_CODE_EXPIRE;
+import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.OAUTH2_CODE_NOT_EXISTS;
+
+/**
+ * OAuth2.0 授权码 Service 实现类
+ *
+ * @author 芋道源码
+ */
+@Service
+@Validated
+public class OAuth2CodeServiceImpl implements OAuth2CodeService {
+
+    /**
+     * 授权码的过期时间,默认 5 分钟
+     */
+    private static final Integer TIMEOUT = 5 * 60;
+
+    @Resource
+    private OAuth2CodeMapper oauth2CodeMapper;
+
+    @Override
+    public OAuth2CodeDO createAuthorizationCode(Long userId, Integer userType, String clientId, List<String> scopes,
+                                                String redirectUri, String state) {
+        OAuth2CodeDO codeDO = new OAuth2CodeDO().setCode(generateCode())
+                .setUserId(userId).setUserType(userType)
+                .setClientId(clientId).setScopes(scopes)
+                .setExpiresTime(DateUtils.addDate(Calendar.SECOND, TIMEOUT))
+                .setRedirectUri(redirectUri).setState(state);
+        oauth2CodeMapper.insert(codeDO);
+        return codeDO;
+    }
+
+    @Override
+    public OAuth2CodeDO consumeAuthorizationCode(String code) {
+        OAuth2CodeDO codeDO = oauth2CodeMapper.selectByCode(code);
+        if (codeDO == null) {
+            throw exception(OAUTH2_CODE_NOT_EXISTS);
+        }
+        if (DateUtils.isExpired(codeDO.getExpiresTime())) {
+            throw exception(OAUTH2_CODE_EXPIRE);
+        }
+        oauth2CodeMapper.deleteById(codeDO.getId());
+        return codeDO;
+    }
+
+    private static String generateCode() {
+        return IdUtil.fastSimpleUUID();
+    }
+
+}

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

@@ -0,0 +1,113 @@
+package cn.iocoder.yudao.module.system.service.oauth2;
+
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
+
+import java.util.List;
+
+/**
+ * OAuth2 授予 Service 接口
+ *
+ * 从功能上,和 Spring Security OAuth 的 TokenGranter 的功能,提供访问令牌、刷新令牌的操作
+ *
+ * 将自身的 AdminUser 用户,授权给第三方应用,采用 OAuth2.0 的协议。
+ *
+ * 问题:为什么自身也作为一个第三方应用,也走这套流程呢?
+ * 回复:当然可以这么做,采用 Implicit 模式。考虑到大多数开发者使用不到这个特性,OAuth2.0 毕竟有一定学习成本,所以暂时没有采取这种方式。
+ *
+ * @author 芋道源码
+ */
+public interface OAuth2GrantService {
+
+    /**
+     * 简化模式
+     *
+     * 对应 Spring Security OAuth2 的 ImplicitTokenGranter 功能
+     *
+     * @param userId 用户编号
+     * @param userType 用户类型
+     * @param clientId 客户端编号
+     * @param scopes 授权范围
+     * @return 访问令牌
+     */
+    OAuth2AccessTokenDO grantImplicit(Long userId, Integer userType,
+                                      String clientId, List<String> scopes);
+
+    /**
+     * 授权码模式,第一阶段,获得 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);
+
+    /**
+     * 密码模式
+     *
+     * 对应 Spring Security OAuth2 的 ResourceOwnerPasswordTokenGranter 功能
+     *
+     * @param username 账号
+     * @param password 密码
+     * @param clientId 客户端编号
+     * @param scopes 授权范围
+     * @return 访问令牌
+     */
+    OAuth2AccessTokenDO grantPassword(String username, String password,
+                                      String clientId, List<String> scopes);
+
+    /**
+     * 刷新模式
+     *
+     * 对应 Spring Security OAuth2 的 ResourceOwnerPasswordTokenGranter 功能
+     *
+     * @param refreshToken 刷新令牌
+     * @param clientId 客户端编号
+     * @return 访问令牌
+     */
+    OAuth2AccessTokenDO grantRefreshToken(String refreshToken, String clientId);
+
+    /**
+     * 客户端模式
+     *
+     * 对应 Spring Security OAuth2 的 ClientCredentialsTokenGranter 功能
+     *
+     * @param clientId 客户端编号
+     * @param scopes 授权范围
+     * @return 访问令牌
+     */
+    OAuth2AccessTokenDO grantClientCredentials(String clientId, List<String> scopes);
+
+    /**
+     * 移除访问令牌
+     *
+     * 对应 Spring Security OAuth2 的 ConsumerTokenServices 的 revokeToken 方法
+     *
+     * @param accessToken 访问令牌
+     * @param clientId 客户端编号
+     * @return 是否移除到
+     */
+    boolean revokeToken(String clientId, String accessToken);
+
+}

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

@@ -0,0 +1,104 @@
+package cn.iocoder.yudao.module.system.service.oauth2;
+
+import cn.hutool.core.lang.Assert;
+import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2CodeDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
+import cn.iocoder.yudao.module.system.enums.ErrorCodeConstants;
+import cn.iocoder.yudao.module.system.service.auth.AdminAuthService;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+
+/**
+ * OAuth2 授予 Service 实现类
+ *
+ * @author 芋道源码
+ */
+@Service
+public class OAuth2GrantServiceImpl implements OAuth2GrantService {
+
+    @Resource
+    private OAuth2TokenService oauth2TokenService;
+    @Resource
+    private OAuth2CodeService oauth2CodeService;
+    @Resource
+    private AdminAuthService adminAuthService;
+
+    @Override
+    public OAuth2AccessTokenDO grantImplicit(Long userId, Integer userType,
+                                             String clientId, List<String> scopes) {
+        return oauth2TokenService.createAccessToken(userId, userType, clientId, scopes);
+    }
+
+    @Override
+    public String grantAuthorizationCodeForCode(Long userId, Integer userType,
+                                                String clientId, List<String> scopes,
+                                                String redirectUri, String state) {
+        return oauth2CodeService.createAuthorizationCode(userId, userType, clientId, scopes,
+                redirectUri, state).getCode();
+    }
+
+    @Override
+    public OAuth2AccessTokenDO grantAuthorizationCodeForAccessToken(String clientId, String code,
+                                                                    String redirectUri, String state) {
+        OAuth2CodeDO codeDO = oauth2CodeService.consumeAuthorizationCode(code);
+        Assert.notNull(codeDO, "授权码不能为空"); // 防御性编程
+        // 校验 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());
+    }
+
+    @Override
+    public OAuth2AccessTokenDO grantPassword(String username, String password, String clientId, List<String> scopes) {
+        // 使用账号 + 密码进行登录
+        AdminUserDO user = adminAuthService.authenticate(username, password);
+        Assert.notNull(user, "用户不能为空!"); // 防御性编程
+
+        // 创建访问令牌
+        return oauth2TokenService.createAccessToken(user.getId(), UserTypeEnum.ADMIN.getValue(), clientId, scopes);
+    }
+
+    @Override
+    public OAuth2AccessTokenDO grantRefreshToken(String refreshToken, String clientId) {
+        return oauth2TokenService.refreshAccessToken(refreshToken, clientId);
+    }
+
+    @Override
+    public OAuth2AccessTokenDO grantClientCredentials(String clientId, List<String> scopes) {
+        // TODO 芋艿:项目中使用 OAuth2 解决的是三方应用的授权,内部的 SSO 等问题,所以暂时不考虑 client_credentials 这个场景
+        throw new UnsupportedOperationException("暂时不支持 client_credentials 授权模式");
+    }
+
+    @Override
+    public boolean revokeToken(String clientId, String accessToken) {
+        // 先查询,保证 clientId 时匹配的
+        OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.getAccessToken(accessToken);
+        if (accessTokenDO == null || ObjectUtil.notEqual(clientId, accessTokenDO.getClientId())) {
+            return false;
+        }
+        // 再删除
+        return oauth2TokenService.removeAccessToken(accessToken) != null;
+    }
+
+}

+ 7 - 4
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/auth/OAuth2TokenService.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenService.java

@@ -1,8 +1,10 @@
-package cn.iocoder.yudao.module.system.service.auth;
+package cn.iocoder.yudao.module.system.service.oauth2;
 
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.token.OAuth2AccessTokenPageReqVO;
-import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2AccessTokenDO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.token.OAuth2AccessTokenPageReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
+
+import java.util.List;
 
 /**
  * OAuth2.0 Token Service 接口
@@ -22,9 +24,10 @@ public interface OAuth2TokenService {
      * @param userId 用户编号
      * @param userType 用户类型
      * @param clientId 客户端编号
+     * @param scopes 授权范围
      * @return 访问令牌的信息
      */
-    OAuth2AccessTokenDO createAccessToken(Long userId, Integer userType, String clientId);
+    OAuth2AccessTokenDO createAccessToken(Long userId, Integer userType, String clientId, List<String> scopes);
 
     /**
      * 刷新访问令牌

+ 15 - 13
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/auth/OAuth2TokenServiceImpl.java → yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImpl.java

@@ -1,4 +1,4 @@
-package cn.iocoder.yudao.module.system.service.auth;
+package cn.iocoder.yudao.module.system.service.oauth2;
 
 import cn.hutool.core.collection.CollUtil;
 import cn.hutool.core.util.IdUtil;
@@ -7,13 +7,13 @@ import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstant
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
 import cn.iocoder.yudao.framework.common.util.date.DateUtils;
 import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.token.OAuth2AccessTokenPageReqVO;
-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.dal.dataobject.auth.OAuth2RefreshTokenDO;
-import cn.iocoder.yudao.module.system.dal.mysql.auth.OAuth2AccessTokenMapper;
-import cn.iocoder.yudao.module.system.dal.mysql.auth.OAuth2RefreshTokenMapper;
-import cn.iocoder.yudao.module.system.dal.redis.auth.OAuth2AccessTokenRedisDAO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.token.OAuth2AccessTokenPageReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2ClientDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2RefreshTokenDO;
+import cn.iocoder.yudao.module.system.dal.mysql.oauth2.OAuth2AccessTokenMapper;
+import cn.iocoder.yudao.module.system.dal.mysql.oauth2.OAuth2RefreshTokenMapper;
+import cn.iocoder.yudao.module.system.dal.redis.oauth2.OAuth2AccessTokenRedisDAO;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -45,10 +45,10 @@ public class OAuth2TokenServiceImpl implements OAuth2TokenService {
 
     @Override
     @Transactional
-    public OAuth2AccessTokenDO createAccessToken(Long userId, Integer userType, String clientId) {
+    public OAuth2AccessTokenDO createAccessToken(Long userId, Integer userType, String clientId, List<String> scopes) {
         OAuth2ClientDO clientDO = oauth2ClientService.validOAuthClientFromCache(clientId);
         // 创建刷新令牌
-        OAuth2RefreshTokenDO refreshTokenDO = createOAuth2RefreshToken(userId, userType, clientDO);
+        OAuth2RefreshTokenDO refreshTokenDO = createOAuth2RefreshToken(userId, userType, clientDO, scopes);
         // 创建访问令牌
         return createOAuth2AccessToken(refreshTokenDO, clientDO);
     }
@@ -134,7 +134,8 @@ public class OAuth2TokenServiceImpl implements OAuth2TokenService {
 
     private OAuth2AccessTokenDO createOAuth2AccessToken(OAuth2RefreshTokenDO refreshTokenDO, OAuth2ClientDO clientDO) {
         OAuth2AccessTokenDO accessTokenDO = new OAuth2AccessTokenDO().setAccessToken(generateAccessToken())
-                .setUserId(refreshTokenDO.getUserId()).setUserType(refreshTokenDO.getUserType()).setClientId(clientDO.getClientId())
+                .setUserId(refreshTokenDO.getUserId()).setUserType(refreshTokenDO.getUserType())
+                .setClientId(clientDO.getClientId()).setScopes(refreshTokenDO.getScopes())
                 .setRefreshToken(refreshTokenDO.getRefreshToken())
                 .setExpiresTime(DateUtils.addDate(Calendar.SECOND, clientDO.getAccessTokenValiditySeconds()));
         accessTokenDO.setTenantId(TenantContextHolder.getTenantId()); // 手动设置租户编号,避免缓存到 Redis 的时候,无对应的租户编号
@@ -144,9 +145,10 @@ public class OAuth2TokenServiceImpl implements OAuth2TokenService {
         return accessTokenDO;
     }
 
-    private OAuth2RefreshTokenDO createOAuth2RefreshToken(Long userId, Integer userType, OAuth2ClientDO clientDO) {
+    private OAuth2RefreshTokenDO createOAuth2RefreshToken(Long userId, Integer userType, OAuth2ClientDO clientDO, List<String> scopes) {
         OAuth2RefreshTokenDO refreshToken = new OAuth2RefreshTokenDO().setRefreshToken(generateRefreshToken())
-                .setUserId(userId).setUserType(userType).setClientId(clientDO.getClientId())
+                .setUserId(userId).setUserType(userType)
+                .setClientId(clientDO.getClientId()).setScopes(scopes)
                 .setExpiresTime(DateUtils.addDate(Calendar.SECOND, clientDO.getRefreshTokenValiditySeconds()));
         oauth2RefreshTokenMapper.insert(refreshToken);
         return refreshToken;

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

@@ -0,0 +1,98 @@
+package cn.iocoder.yudao.module.system.util.oauth2;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
+import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
+
+import java.util.*;
+
+/**
+ * OAuth2 相关的工具类
+ *
+ * @author 芋道源码
+ */
+public class OAuth2Utils {
+
+    /**
+     * 构建授权码模式下,重定向的 URI
+     *
+     * copy from Spring Security OAuth2 的 AuthorizationEndpoint 类的 getSuccessfulRedirect 方法
+     *
+     * @param redirectUri 重定向 URI
+     * @param authorizationCode 授权码
+     * @param state 状态
+     * @return 授权码模式下的重定向 URI
+     */
+    public static String buildAuthorizationCodeRedirectUri(String redirectUri, String authorizationCode, String state) {
+        Map<String, String> query = new LinkedHashMap<>();
+        query.put("code", authorizationCode);
+        if (state != null) {
+            query.put("state", state);
+        }
+        return HttpUtils.append(redirectUri, query, null, false);
+    }
+
+    /**
+     * 构建简化模式下,重定向的 URI
+     *
+     * copy from Spring Security OAuth2 的 AuthorizationEndpoint 类的 appendAccessToken 方法
+     *
+     * @param redirectUri 重定向 URI
+     * @param accessToken 访问令牌
+     * @param state 状态
+     * @param expireTime 过期时间
+     * @param scopes 授权范围
+     * @param additionalInformation 附加信息
+     * @return 简化授权模式下的重定向 URI
+     */
+    public static String buildImplicitRedirectUri(String redirectUri, String accessToken, String state, Date expireTime,
+                                                  Collection<String> scopes, Map<String, Object> additionalInformation) {
+        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.AUTHORIZATION_BEARER.toLowerCase());
+        if (state != null) {
+            vars.put("state", state);
+        }
+        if (expireTime != null) {
+            vars.put("expires_in", getExpiresIn(expireTime));
+        }
+        if (CollUtil.isNotEmpty(scopes)) {
+            vars.put("scope", buildScopeStr(scopes));
+        }
+        for (String key : additionalInformation.keySet()) {
+            Object value = additionalInformation.get(key);
+            if (value != null) {
+                keys.put("extra_" + key, key);
+                vars.put("extra_" + key, value);
+            }
+        }
+        // Do not include the refresh token (even if there is one)
+        return HttpUtils.append(redirectUri, vars, keys, true);
+    }
+
+    public static String buildUnsuccessfulRedirect(String redirectUri, String responseType, String state,
+                                                   String error, String description) {
+        Map<String, String> query = new LinkedHashMap<String, String>();
+        query.put("error", error);
+        query.put("error_description", description);
+        if (state != null) {
+            query.put("state", state);
+        }
+        return HttpUtils.append(redirectUri, query, null, !responseType.contains("code"));
+    }
+
+    public static long getExpiresIn(Date expireTime) {
+        return (expireTime.getTime() - System.currentTimeMillis()) / 1000;
+    }
+
+    public static String buildScopeStr(Collection<String> scopes) {
+        return CollUtil.join(scopes, " ");
+    }
+
+    public static List<String> buildScopes(String scope) {
+        return StrUtil.split(scope, ' ');
+    }
+
+}

+ 14 - 13
yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/service/auth/AuthServiceImplTest.java → yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImplTest.java

@@ -5,15 +5,16 @@ import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
 import cn.iocoder.yudao.framework.test.core.util.AssertUtils;
 import cn.iocoder.yudao.module.system.api.sms.SmsCodeApi;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.auth.AuthLoginReqVO;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.auth.AuthLoginRespVO;
-import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2AccessTokenDO;
+import cn.iocoder.yudao.module.system.controller.admin.auth.vo.AuthLoginReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.auth.vo.AuthLoginRespVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
 import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
 import cn.iocoder.yudao.module.system.enums.logger.LoginLogTypeEnum;
 import cn.iocoder.yudao.module.system.enums.logger.LoginResultEnum;
 import cn.iocoder.yudao.module.system.service.common.CaptchaService;
 import cn.iocoder.yudao.module.system.service.logger.LoginLogService;
 import cn.iocoder.yudao.module.system.service.member.MemberService;
+import cn.iocoder.yudao.module.system.service.oauth2.OAuth2TokenService;
 import cn.iocoder.yudao.module.system.service.social.SocialUserService;
 import cn.iocoder.yudao.module.system.service.user.AdminUserService;
 import org.junit.jupiter.api.BeforeEach;
@@ -33,7 +34,7 @@ import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.*;
 
 @Import(AdminAuthServiceImpl.class)
-public class AuthServiceImplTest extends BaseDbUnitTest {
+public class AdminAuthServiceImplTest extends BaseDbUnitTest {
 
     @Resource
     private AdminAuthServiceImpl authService;
@@ -62,7 +63,7 @@ public class AuthServiceImplTest extends BaseDbUnitTest {
     }
 
     @Test
-    public void testLogin0_success() {
+    public void testAuthenticate_success() {
         // 准备参数
         String username = randomString();
         String password = randomString();
@@ -74,19 +75,19 @@ public class AuthServiceImplTest extends BaseDbUnitTest {
         when(userService.isPasswordMatch(eq(password), eq(user.getPassword()))).thenReturn(true);
 
         // 调用
-        AdminUserDO loginUser = authService.login0(username, password);
+        AdminUserDO loginUser = authService.authenticate(username, password);
         // 校验
         assertPojoEquals(user, loginUser);
     }
 
     @Test
-    public void testLogin0_userNotFound() {
+    public void testAuthenticate_userNotFound() {
         // 准备参数
         String username = randomString();
         String password = randomString();
 
         // 调用, 并断言异常
-        AssertUtils.assertServiceException(() -> authService.login0(username, password),
+        AssertUtils.assertServiceException(() -> authService.authenticate(username, password),
                 AUTH_LOGIN_BAD_CREDENTIALS);
         verify(loginLogService).createLoginLog(
                 argThat(o -> o.getLogType().equals(LoginLogTypeEnum.LOGIN_USERNAME.getType())
@@ -96,7 +97,7 @@ public class AuthServiceImplTest extends BaseDbUnitTest {
     }
 
     @Test
-    public void testLogin0_badCredentials() {
+    public void testAuthenticate_badCredentials() {
         // 准备参数
         String username = randomString();
         String password = randomString();
@@ -106,7 +107,7 @@ public class AuthServiceImplTest extends BaseDbUnitTest {
         when(userService.getUserByUsername(eq(username))).thenReturn(user);
 
         // 调用, 并断言异常
-        AssertUtils.assertServiceException(() -> authService.login0(username, password),
+        AssertUtils.assertServiceException(() -> authService.authenticate(username, password),
                 AUTH_LOGIN_BAD_CREDENTIALS);
         verify(loginLogService).createLoginLog(
                 argThat(o -> o.getLogType().equals(LoginLogTypeEnum.LOGIN_USERNAME.getType())
@@ -116,7 +117,7 @@ public class AuthServiceImplTest extends BaseDbUnitTest {
     }
 
     @Test
-    public void testLogin0_userDisabled() {
+    public void testAuthenticate_userDisabled() {
         // 准备参数
         String username = randomString();
         String password = randomString();
@@ -128,7 +129,7 @@ public class AuthServiceImplTest extends BaseDbUnitTest {
         when(userService.isPasswordMatch(eq(password), eq(user.getPassword()))).thenReturn(true);
 
         // 调用, 并断言异常
-        AssertUtils.assertServiceException(() -> authService.login0(username, password),
+        AssertUtils.assertServiceException(() -> authService.authenticate(username, password),
                 AUTH_LOGIN_USER_DISABLED);
         verify(loginLogService).createLoginLog(
                 argThat(o -> o.getLogType().equals(LoginLogTypeEnum.LOGIN_USERNAME.getType())
@@ -200,7 +201,7 @@ public class AuthServiceImplTest extends BaseDbUnitTest {
         // mock 缓存登录用户到 Redis
         OAuth2AccessTokenDO accessTokenDO = randomPojo(OAuth2AccessTokenDO.class, o -> o.setUserId(1L)
                 .setUserType(UserTypeEnum.ADMIN.getValue()));
-        when(oauth2TokenService.createAccessToken(eq(1L), eq(UserTypeEnum.ADMIN.getValue()), eq("default")))
+        when(oauth2TokenService.createAccessToken(eq(1L), eq(UserTypeEnum.ADMIN.getValue()), eq("default"), isNull()))
                 .thenReturn(accessTokenDO);
 
         // 调用, 并断言异常

+ 6 - 5
yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/service/auth/OAuth2ClientServiceImplTest.java

@@ -3,12 +3,13 @@ package cn.iocoder.yudao.module.system.service.auth;
 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.client.OAuth2ClientCreateReqVO;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.client.OAuth2ClientPageReqVO;
-import cn.iocoder.yudao.module.system.controller.admin.auth.vo.client.OAuth2ClientUpdateReqVO;
-import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2ClientDO;
-import cn.iocoder.yudao.module.system.dal.mysql.auth.OAuth2ClientMapper;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client.OAuth2ClientCreateReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client.OAuth2ClientPageReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client.OAuth2ClientUpdateReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2ClientDO;
+import cn.iocoder.yudao.module.system.dal.mysql.oauth2.OAuth2ClientMapper;
 import cn.iocoder.yudao.module.system.mq.producer.auth.OAuth2ClientProducer;
+import cn.iocoder.yudao.module.system.service.oauth2.OAuth2ClientServiceImpl;
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 import org.springframework.boot.test.mock.mockito.MockBean;

+ 37 - 0
yudao-ui-admin/src/api/login.js

@@ -109,3 +109,40 @@ export function refreshToken() {
     method: 'post'
   })
 }
+
+// ========== OAUTH 2.0 相关 ==========
+
+export function getAuthorize(clientId) {
+  return request({
+    url: '/system/oauth2/authorize?clientId=' + clientId,
+    method: 'get'
+  })
+}
+
+export function authorize(responseType, clientId, redirectUri, state,
+                          autoApprove, checkedScopes, uncheckedScopes) {
+  // 构建 scopes
+  const scopes = {};
+  for (const scope of checkedScopes) {
+    scopes[scope] = true;
+  }
+  for (const scope of uncheckedScopes) {
+    scopes[scope] = false;
+  }
+  // 发起请求
+  return service({
+    url: '/system/oauth2/authorize',
+    headers:{
+      'Content-type': 'application/x-www-form-urlencoded',
+    },
+    params: {
+      response_type: responseType,
+      client_id: clientId,
+      redirect_uri: redirectUri,
+      state: state,
+      auto_approve: autoApprove,
+      scope: JSON.stringify(scopes)
+    },
+    method: 'post'
+  })
+}

+ 5 - 0
yudao-ui-admin/src/router/index.js

@@ -42,6 +42,11 @@ export const constantRoutes = [
     component: (resolve) => require(['@/views/login'], resolve),
     hidden: true
   },
+  {
+    path: '/sso',
+    component: (resolve) => require(['@/views/sso'], resolve),
+    hidden: true
+  },
   {
     path: '/social-login',
     component: (resolve) => require(['@/views/socialLogin'], resolve),

+ 236 - 0
yudao-ui-admin/src/views/sso.vue

@@ -0,0 +1,236 @@
+<template>
+  <div class="container">
+    <div class="logo"></div>
+    <!-- 登录区域 -->
+    <div class="content">
+      <!-- 配图 -->
+      <div class="pic"></div>
+      <!-- 表单 -->
+      <div class="field">
+        <!-- [移动端]标题 -->
+        <h2 class="mobile-title">
+          <h3 class="title">芋道后台管理系统</h3>
+        </h2>
+
+        <!-- 表单 -->
+        <div class="form-cont">
+          <el-tabs class="form" style=" float:none;" value="uname">
+            <el-tab-pane :label="'三方授权(' + client.name + ')'" name="uname">
+            </el-tab-pane>
+          </el-tabs>
+          <div>
+            <el-form ref="loginForm" :model="loginForm" :rules="LoginRules" class="login-form">
+              <el-form-item prop="tenantName" v-if="tenantEnable">
+                <el-input v-model="loginForm.tenantName" type="text" auto-complete="off" placeholder='租户'>
+                  <svg-icon slot="prefix" icon-class="tree" class="el-input__icon input-icon"/>
+                </el-input>
+              </el-form-item>
+              <!-- 授权范围的选择 -->
+              此第三方应用请求获得以下权限:
+              <el-form-item prop="scopes">
+                <el-checkbox-group v-model="loginForm.scopes">
+                  <el-checkbox v-for="scope in params.scopes" :label="scope" :key="scope"
+                               style="display: block; margin-bottom: -10px;">{{formatScope(scope)}}</el-checkbox>
+                </el-checkbox-group>
+              </el-form-item>
+              <!-- 下方的登录按钮 -->
+              <el-form-item style="width:100%;">
+                <el-button :loading="loading" size="medium" type="primary" style="width:60%;"
+                           @click.native.prevent="handleAuthorize(true)">
+                  <span v-if="!loading">统一授权</span>
+                  <span v-else>授 权 中...</span>
+                </el-button>
+                <el-button size="medium" style="width:36%"
+                           @click.native.prevent="handleAuthorize(false)">拒绝</el-button>
+              </el-form-item>
+            </el-form>
+          </div>
+        </div>
+      </div>
+    </div>
+    <!-- footer -->
+    <div class="footer">
+      Copyright © 2020-2022 iocoder.cn All Rights Reserved.
+    </div>
+  </div>
+</template>
+
+<script>
+import {getTenantIdByName} from "@/api/system/tenant";
+import {getTenantEnable} from "@/utils/ruoyi";
+import {authorize, getAuthorize} from "@/api/login";
+import {getTenantName, setTenantId} from "@/utils/auth";
+
+export default {
+  name: "Login",
+  data() {
+    return {
+      tenantEnable: true,
+      loginForm: {
+        tenantName: "芋道源码",
+        scopes: [], // 已选中的 scope 数组
+      },
+      params: { // URL 上的 client_id、scope 等参数
+        responseType: undefined,
+        clientId: undefined,
+        redirectUri: undefined,
+        state: undefined,
+        scopes: [], // 优先从 query 参数获取;如果未传递,从后端获取
+      },
+      client: { // 客户端信息
+        name: '',
+        logo: '',
+      },
+      LoginRules: {
+        tenantName: [
+          {required: true, trigger: "blur", message: "租户不能为空"},
+          {
+            validator: (rule, value, callback) => {
+              // debugger
+              getTenantIdByName(value).then(res => {
+                const tenantId = res.data;
+                if (tenantId && tenantId >= 0) {
+                  // 设置租户
+                  setTenantId(tenantId)
+                  callback();
+                } else {
+                  callback('租户不存在');
+                }
+              });
+            },
+            trigger: 'blur'
+          }
+        ]
+      },
+      loading: false
+    };
+  },
+  created() {
+    // 租户开关
+    this.tenantEnable = getTenantEnable();
+    this.getCookie();
+
+    // 解析参数
+    // 例如说【自动授权不通过】:client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read%20user.write
+    // 例如说【自动授权通过】:client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read
+    this.params.responseType = this.$route.query.response_type
+    this.params.clientId = this.$route.query.client_id
+    this.params.redirectUri = this.$route.query.redirect_uri
+    this.params.state = this.$route.query.state
+    if (this.$route.query.scope) {
+      this.params.scopes = this.$route.query.scope.split(' ')
+    }
+
+    // 如果有 scope 参数,先执行一次自动授权,看看是否之前都授权过了。
+    if (this.params.scopes.length > 0) {
+      this.doAuthorize(true, this.params.scopes, []).then(res => {
+        const href = res.data
+        if (!href) {
+          console.log('自动授权未通过!')
+          return;
+        }
+        location.href = href
+      })
+    }
+
+    // 获取授权页的基本信息
+    getAuthorize(this.params.clientId).then(res => {
+      this.client = res.data.client
+      // 解析 scope
+      let scopes
+      // 1.1 如果 params.scope 非空,则过滤下返回的 scopes
+      if (this.params.scopes.length > 0) {
+        scopes = []
+        for (const scope of res.data.scopes) {
+          if (this.params.scopes.indexOf(scope.key) >= 0) {
+            scopes.push(scope)
+          }
+        }
+      // 1.2 如果 params.scope 为空,则使用返回的 scopes 设置它
+      } else {
+        scopes = res.data.scopes
+        for (const scope of scopes) {
+          this.params.scopes.push(scope.key)
+        }
+      }
+      // 生成已选中的 checkedScopes
+      for (const scope of scopes) {
+        if (scope.value) {
+          this.loginForm.scopes.push(scope.key)
+        }
+      }
+    })
+  },
+  methods: {
+    getCookie() {
+      const tenantName = getTenantName();
+      this.loginForm = {
+        ...this.loginForm,
+        tenantName: tenantName ? tenantName : this.loginForm.tenantName,
+      };
+    },
+    handleAuthorize(approved) {
+      this.$refs.loginForm.validate(valid => {
+        if (!valid) {
+          return
+        }
+        this.loading = true
+        // 计算 checkedScopes + uncheckedScopes
+        let checkedScopes;
+        let uncheckedScopes;
+        if (approved) { // 同意授权,按照用户的选择
+          checkedScopes = this.loginForm.scopes
+          uncheckedScopes = this.params.scopes.filter(item => checkedScopes.indexOf(item) === -1)
+        } else { // 拒绝,则都是取消
+          checkedScopes = []
+          uncheckedScopes = this.params.scopes
+        }
+        // 提交授权的请求
+        this.doAuthorize(false, checkedScopes, uncheckedScopes).then(res => {
+          const href = res.data
+          if (!href) {
+            return;
+          }
+          location.href = href
+        }).finally(() => {
+          this.loading = false
+        })
+      })
+    },
+    doAuthorize(autoApprove, checkedScopes, uncheckedScopes) {
+      return authorize(this.params.responseType, this.params.clientId, this.params.redirectUri, this.params.state,
+          autoApprove, checkedScopes, uncheckedScopes)
+    },
+    formatScope(scope) {
+      // 格式化 scope 授权范围,方便用户理解。
+      // 这里仅仅是一个 demo,可以考虑录入到字典数据中,例如说字典类型 "system_oauth2_scope",它的每个 scope 都是一条字典数据。
+      switch (scope) {
+        case 'user.read': return '访问你的个人信息'
+        case 'user.write': return '修改你的个人信息'
+        default: return scope
+      }
+    }
+  }
+};
+</script>
+<style lang="scss" scoped>
+@import "~@/assets/styles/login.scss";
+.oauth-login {
+  display: flex;
+  align-items: cen;
+  cursor:pointer;
+}
+.oauth-login-item {
+  display: flex;
+  align-items: center;
+  margin-right: 10px;
+}
+.oauth-login-item img {
+  height: 25px;
+  width: 25px;
+}
+.oauth-login-item span:hover {
+  text-decoration: underline red;
+  color: red;
+}
+</style>

+ 6 - 14
yudao-ui-admin/src/views/system/oauth2/client/index.vue

@@ -108,23 +108,16 @@
             <el-option v-for="redirectUri in form.redirectUris" :key="redirectUri" :label="redirectUri" :value="redirectUri"/>
           </el-select>
         </el-form-item>
-        <el-form-item label="是否自动授权" prop="autoApprove">
-          <el-radio-group v-model="form.autoApprove">
-            <el-radio :key="true" :label="true">自动登录</el-radio>
-            <el-radio :key="false" :label="false">手动登录</el-radio>
-          </el-radio-group>
-        </el-form-item>
-        <el-form-item label="授权类型" prop="authorizedGrantTypes">
-          <el-select v-model="form.authorizedGrantTypes" multiple filterable placeholder="请输入授权类型" style="width: 500px" >
-            <el-option v-for="dict in this.getDictDatas(DICT_TYPE.SYSTEM_OAUTH2_GRANT_TYPE)"
-                       :key="dict.value" :label="dict.label" :value="dict.value"/>
-          </el-select>
-        </el-form-item>
         <el-form-item label="授权范围" prop="scopes">
           <el-select v-model="form.scopes" multiple filterable allow-create placeholder="请输入授权范围" style="width: 500px" >
             <el-option v-for="scope in form.scopes" :key="scope" :label="scope" :value="scope"/>
           </el-select>
         </el-form-item>
+        <el-form-item label="自动授权" prop="autoApproveScopes">
+          <el-select v-model="form.autoApproveScopes" multiple filterable placeholder="请输入授权范围" style="width: 500px" >
+            <el-option v-for="scope in form.scopes" :key="scope" :label="scope" :value="scope"/>
+          </el-select>
+        </el-form-item>
         <el-form-item label="权限" prop="authorities">
           <el-select v-model="form.authorities" multiple filterable allow-create placeholder="请输入权限" style="width: 500px" >
             <el-option v-for="authority in form.authorities" :key="authority" :label="authority" :value="authority"/>
@@ -196,7 +189,6 @@ export default {
         accessTokenValiditySeconds: [{ required: true, message: "访问令牌的有效期不能为空", trigger: "blur" }],
         refreshTokenValiditySeconds: [{ required: true, message: "刷新令牌的有效期不能为空", trigger: "blur" }],
         redirectUris: [{ required: true, message: "可重定向的 URI 地址不能为空", trigger: "blur" }],
-        autoApprove: [{ required: true, message: "是否自动授权不能为空", trigger: "blur" }],
         authorizedGrantTypes: [{ required: true, message: "授权类型不能为空", trigger: "blur" }],
       }
     };
@@ -235,9 +227,9 @@ export default {
         accessTokenValiditySeconds: 30 * 60,
         refreshTokenValiditySeconds: 30 * 24 * 60,
         redirectUris: [],
-        autoApprove: true,
         authorizedGrantTypes: [],
         scopes: [],
+        autoApproveScopes: [],
         authorities: [],
         resourceIds: [],
         additionalInformation: undefined,