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

pull/2/head
YunaiV 2022-05-15 15:59:49 +08:00
parent 66034d26c0
commit 99ba7ccee8
14 changed files with 267 additions and 31 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -67,7 +67,7 @@ public interface OAuth2ClientService {
* @return
*/
default OAuth2ClientDO validOAuthClientFromCache(String clientId) {
return validOAuthClientFromCache(clientId, null, null, null);
return validOAuthClientFromCache(clientId, null, null, null, null);
}
/**
@ -76,12 +76,13 @@ public interface OAuth2ClientService {
*
*
* @param clientId
* @param clientSecret
* @param authorizedGrantType
* @param scopes
* @param redirectUri
* @return
*/
OAuth2ClientDO validOAuthClientFromCache(String clientId, String authorizedGrantType,
Collection<String> scopes, String redirectUri);
OAuth2ClientDO validOAuthClientFromCache(String clientId, String clientSecret,
String authorizedGrantType, Collection<String> scopes, String redirectUri);
}

View File

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

View File

@ -22,9 +22,35 @@ public interface OAuth2GrantService {
OAuth2AccessTokenDO grantImplicit(Long userId, Integer userType,
String clientId, List<String> scopes);
// AuthorizationCodeTokenGranter
String grantAuthorizationCode(Long userId, Integer userType,
String clientId, List<String> scopes,
String redirectUri, String state);
/**
* code
*
* Spring Security OAuth2 AuthorizationEndpoint generateCode
*
* @param userId
* @param userType
* @param clientId
* @param scopes
* @param redirectUri URI
* @param state
* @return
*/
String grantAuthorizationCodeForCode(Long userId, Integer userType,
String clientId, List<String> scopes,
String redirectUri, String state);
/**
* accessToken 访
*
* Spring Security OAuth2 AuthorizationCodeTokenGranter
*
* @param clientId
* @param code
* @param redirectUri URI
* @param state
* @return 访
*/
OAuth2AccessTokenDO grantAuthorizationCodeForAccessToken(String clientId, String code,
String redirectUri, String state);
}

View File

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

View File

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