Kaynağa Gözat

搭建 oauth/authorize 的初步逻辑

YunaiV 2 yıl önce
ebeveyn
işleme
aa8fb4acf0

+ 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;
     }
 
 }

+ 4 - 0
yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java

@@ -126,5 +126,9 @@ public interface ErrorCodeConstants {
     // ========== 系统敏感词 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(1002020004, "重定向地址不匹配");
 
 }

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

@@ -1,21 +1,31 @@
 package cn.iocoder.yudao.module.system.controller.admin.auth;
 
+import cn.hutool.core.util.ObjectUtil;
 import cn.hutool.core.util.StrUtil;
-import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
+import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
 import cn.iocoder.yudao.framework.operatelog.core.annotations.OperateLog;
+import cn.iocoder.yudao.module.system.enums.auth.OAuth2GrantTypeEnum;
+import cn.iocoder.yudao.module.system.service.auth.OAuth2ApproveService;
+import cn.iocoder.yudao.module.system.service.auth.OAuth2ClientService;
 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.validation.annotation.Validated;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.*;
 
+import javax.annotation.Resource;
+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.security.core.util.SecurityFrameworkUtils.getLoginUserId;
 
 @Api(tags = "管理后台 - OAuth2.0 授权")
 @RestController
@@ -32,37 +42,105 @@ public class OAuth2Controller {
 
 //    GET  oauth/authorize AuthorizationEndpoint
 
-    @PostMapping("/authorize")
-    @ApiOperation(value = "申请授权", notes = "适合 code 授权码模式,或者 implicit 简化模式;在 authorize.vue 单点登录界面被调用")
+    @Resource
+    private OAuth2ClientService oauth2ClientService;
+    @Resource
+    private OAuth2ApproveService oauth2ApproveService;
+
+    @GetMapping("/authorize")
+    @ApiOperation(value = "获得授权信息", notes = "适合 code 授权码模式,或者 implicit 简化模式;在 authorize.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), // 多个使用逗号分隔
+            @ApiImplicitParam(name = "scope", value = "授权范围", example = "userinfo.read", dataTypeClass = String.class), // 多个使用空格分隔
             @ApiImplicitParam(name = "redirect_uri", required = true, value = "重定向 URI", example = "https://www.iocoder.cn", dataTypeClass = String.class),
             @ApiImplicitParam(name = "state", example = "123321", dataTypeClass = String.class)
     })
-    @OperateLog(enable = false) // 避免 Post 请求被记录操作日志
-    // 因为前后端分离,Axios 无法很好的处理 302 重定向,所以和 Spring Security OAuth 略有不同,返回结果是重定向的 URL,剩余交给前端处理
-    public CommonResult<String> authorize(@RequestParam("response_type") String responseType,
+    // 情况一:满足自动授权,则走类似 approveOrDeny 的逻辑,最终返回是 CommonResult<String>
+    // 情况二:如果不满足自动授权,则返回授权相关的展示信息,最终返回是 CommonResult<OAuth2AuthorizeRespInfo>
+    public CommonResult<Object> authorize(@RequestParam("response_type") String responseType,
                                           @RequestParam("client_id") String clientId,
                                           @RequestParam(value = "scope", required = false) String scope,
                                           @RequestParam("redirect_uri") String redirectUri,
                                           @RequestParam(value = "state", required = false) String state) {
+        List<String> scopes = StrUtil.split(scope, ' ');
         // 0. 校验用户已经登录。通过 Spring Security 实现
 
         // 1.1 校验 responseType 是否满足 code 或者 token 值
-        if (!StrUtil.equalsAny(responseType, "code", "token")) {
-            throw exception0(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), "response_type 参数值允许 code 和 token");
+        OAuth2GrantTypeEnum grantTypeEnum = getGrantTypeEnum(responseType);
+        // 1.2 校验 redirectUri 重定向域名是否合法 + 校验 scope 是否在 Client 授权范围内
+        oauth2ClientService.validOAuthClientFromCache(clientId, grantTypeEnum.getGrantType(), scopes, redirectUri);
+
+        // 2. 判断是否满足自动授权(满足)
+        boolean approved = oauth2ApproveService.checkForPreApproval(getLoginUserId(), UserTypeEnum.ADMIN.getValue(), clientId, scopes);
+        if (approved) {
+            // 2.1 如果是 code 授权码模式,则发放 code 授权码,并重定向
+            if (grantTypeEnum == OAuth2GrantTypeEnum.AUTHORIZATION_CODE) {
+                return success(getAuthorizationCodeRedirect());
+            }
+            return success(getImplicitGrantRedirect());
         }
-        // 1.2 校验 redirectUri 重定向域名是否合法
 
-        // 1.3 校验 scope 是否在 Client 授权范围内
+        // 3. 不满足自动授权,则返回授权相关的展示信息
+        return null;
+    }
 
-        // 2.1 如果是 code 授权码模式,则发放 code 授权码,并重定向
+    @PostMapping("/authorize")
+    @ApiOperation(value = "申请授权", notes = "适合 code 授权码模式,或者 implicit 简化模式;在 authorize.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 = "approved", value = "用户是否接受", example = "true", dataTypeClass = Boolean.class), // 该参数为 null 时,会基于用户是否已经授权过,进行自动判断
+            @ApiImplicitParam(name = "state", example = "123321", dataTypeClass = String.class)
+    })
+    @OperateLog(enable = false) // 避免 Post 请求被记录操作日志
+    // 因为前后端分离,Axios 无法很好的处理 302 重定向,所以和 Spring Security OAuth 略有不同,返回结果是重定向的 URL,剩余交给前端处理
+    public CommonResult<String> approveOrDeny(@RequestParam("response_type") String responseType,
+                                              @RequestParam("client_id") String clientId,
+                                              @RequestParam(value = "scope", required = false) String scope,
+                                              @RequestParam("redirect_uri") String redirectUri,
+                                              @RequestParam("approved") Boolean approved,
+                                              @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 授权范围内
+        oauth2ClientService.validOAuthClientFromCache(clientId, grantTypeEnum.getGrantType(), scopes.keySet(), redirectUri);
+
+        // 2.1
+        // 2.2
+
+        // 3.1 如果是 code 授权码模式,则发放 code 授权码,并重定向
+        if (grantTypeEnum == OAuth2GrantTypeEnum.AUTHORIZATION_CODE) {
+            return success(getAuthorizationCodeRedirect());
+        }
+        // 3.2 如果是 token 则是 implicit 简化模式,则发送 accessToken 访问令牌,并重定向
+        return success(getImplicitGrantRedirect());
+    }
+
+    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() {
+        return "";
+    }
 
-        // 2.2 如果是 token 则是 implicit 简化模式,则发送 accessToken 访问令牌,并重定向
-        // TODO 需要确认,是否要 refreshToken 生成
-        return CommonResult.success("");
+    private String getAuthorizationCodeRedirect() {
+        return "";
     }
 
 }

+ 16 - 10
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/auth/OAuth2CodeDO.java

@@ -3,18 +3,21 @@ package cn.iocoder.yudao.module.system.dal.dataobject.auth;
 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;
 
 }

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

@@ -1,5 +1,9 @@
 package cn.iocoder.yudao.module.system.service.auth;
 
+import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2AccessTokenDO;
+
+import java.util.Collection;
+
 /**
  * 管理后台的 OAuth2 Service 接口
  *
@@ -11,4 +15,14 @@ package cn.iocoder.yudao.module.system.service.auth;
  * @author 芋道源码
  */
 public interface AdminOAuth2Service {
+
+    // ImplicitTokenGranter
+    OAuth2AccessTokenDO grantImplicit(Long userId, Integer userType,
+                                      String clientId, Collection<String> scopes);
+
+    // AuthorizationCodeTokenGranter
+    String grantAuthorizationCode(Long userId, Integer userType,
+                                  String clientId, Collection<String> scopes,
+                                  String redirectUri, String state);
+
 }

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

@@ -0,0 +1,27 @@
+package cn.iocoder.yudao.module.system.service.auth;
+
+import java.util.Collection;
+
+/**
+ * OAuth2 批准 Service 接口
+ *
+ * 从功能上,和 Spring Security OAuth 的 ApprovalStoreUserApprovalHandler 的功能,记录用户针对指定客户端的授权,减少手动确定。
+ *
+ * @author 芋道源码
+ */
+public interface OAuth2ApproveService {
+
+    /**
+     * 获得指定用户,针对指定客户端的指定授权,是否通过
+     *
+     * 参考 ApprovalStoreUserApprovalHandler 的 checkForPreApproval 方法
+     *
+     * @param userId 用户编号
+     * @param userType 用户类型
+     * @param clientId 客户端编号
+     * @param scopes 授权范围
+     * @return 是否授权通过
+     */
+    boolean checkForPreApproval(Long userId, Integer userType, String clientId, Collection<String> scopes);
+
+}

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

@@ -0,0 +1,20 @@
+package cn.iocoder.yudao.module.system.service.auth;
+
+import org.springframework.stereotype.Service;
+
+import java.util.Collection;
+
+/**
+ * OAuth2 批准 Service 实现类
+ *
+ * @author 芋道源码
+ */
+@Service
+public class OAuth2ApproveServiceImpl implements OAuth2ApproveService {
+
+    @Override
+    public boolean checkForPreApproval(Long userId, Integer userType, String clientId, Collection<String> scopes) {
+        return false;
+    }
+
+}

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

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

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

@@ -1,7 +1,10 @@
 package cn.iocoder.yudao.module.system.service.auth;
 
 import cn.hutool.core.collection.CollUtil;
+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.framework.common.util.string.StrUtils;
 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;
@@ -18,15 +21,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 +175,29 @@ public class OAuth2ClientServiceImpl implements OAuth2ClientService {
     }
 
     @Override
-    public OAuth2ClientDO validOAuthClientFromCache(String clientId) {
-        return clientCache.get(clientId);
+    public OAuth2ClientDO validOAuthClientFromCache(String clientId, String authorizedGrantType, Collection<String> scopes, String redirectUri) {
+        // 校验客户端存在、且开启
+        OAuth2ClientDO client = clientCache.get(clientId);
+        if (client == null) {
+            throw exception(OAUTH2_CLIENT_EXISTS);
+        }
+        if (Objects.equals(client.getStatus(), CommonStatusEnum.ENABLE.getStatus())) {
+            throw exception(OAUTH2_CLIENT_DISABLE);
+        }
+
+        // 校验授权方式
+        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);
+        }
+        return client;
     }
 
 }

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

@@ -7,5 +7,8 @@ package cn.iocoder.yudao.module.system.service.auth;
  *
  * @author 芋道源码
  */
-public class OAuth2CodeService {
+public interface OAuth2CodeService {
+
+
+
 }

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

@@ -122,6 +122,14 @@ export function authorize() {
       response_type: 'code',
       client_id: 'test',
       redirect_uri: 'https://www.iocoder.cn',
+      // scopes: {
+      //   read: true,
+      //   write: false
+      // }
+      scope: {
+        read: true,
+        write: false
+      }
     },
     method: 'post'
   })