搭建 oauth/authorize 的初步逻辑

This commit is contained in:
YunaiV 2022-05-14 20:10:06 +08:00
parent ce60ec0815
commit aa8fb4acf0
11 changed files with 253 additions and 53 deletions

View File

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

View File

@ -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, "重定向地址不匹配");
}

View File

@ -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 实现
// 2.2 如果是 token 则是 implicit 简化模式则发送 accessToken 访问令牌并重定向
// TODO 需要确认是否要 refreshToken 生成
return CommonResult.success("");
// 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 "";
}
private String getAuthorizationCodeRedirect() {
return "";
}
}

View File

@ -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") // 用于 OraclePostgreSQLKingbaseDB2H2 数据库的主键自增如果是 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 String redirectUri;
/**
* 状态
*/
private String state;
/**
* 过期时间
*/
private Date expiresTime;
/**
* 创建 IP
*/
private String createIp;
}

View File

@ -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);
}

View File

@ -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);
}

View File

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

View File

@ -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 {
/**
* 从缓存中校验客户端是否合法
*
* @param clientId 客户端编号
* @return 客户端
*/
OAuth2ClientDO validOAuthClientFromCache(String clientId);
default OAuth2ClientDO validOAuthClientFromCache(String clientId) {
return validOAuthClientFromCache(clientId, null, null, null);
}
/**
* 从缓存中校验客户端是否合法
*
* 非空时进行校验
*
* @param clientId 客户端编号
* @param authorizedGrantType 授权方式
* @param scopes 授权范围
* @param redirectUri 重定向地址
* @return 客户端
*/
OAuth2ClientDO validOAuthClientFromCache(String clientId, String authorizedGrantType,
Collection<String> scopes, String redirectUri);
}

View File

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

View File

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

View File

@ -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'
})