!176 基于 OAuth2.0 实现 SSO 单点登录

Merge pull request !176 from 芋道源码/feature/1.6.2
This commit is contained in:
芋道源码 2022-05-25 17:06:44 +00:00 committed by Gitee
commit 82cf4e1775
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
81 changed files with 2118 additions and 250 deletions

View File

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

View File

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

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

@ -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;
// ========== 上下文 ==========
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 已过期");
}

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

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

View File

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

View File

@ -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 TokenEndpointPasswordImplicitCodeRefresh Token
// POST oauth/check_token CheckTokenEndpoint
// DELETE oauth/token ConsumerTokenServices#revokeToken
// GET oauth/authorize AuthorizationEndpoint
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": "芋道源码"
}

View File

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

View File

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

View File

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

View File

@ -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.*;

View File

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

View File

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

View File

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

View File

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

View File

@ -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 是 scopeValue 为是否选中")
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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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")

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
/**
* 过期时间
*/

View File

@ -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") // 用于 OraclePostgreSQLKingbaseDB2H2 数据库的主键自增如果是 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;
}

View File

@ -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;
/**
* 权限
*/

View File

@ -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") // 用于 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,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;
/**
* 过期时间
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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);
/**
* 账号登录
*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 {
/**
* 从缓存中校验客户端是否合法
*
* @param clientId 客户端编号
* @return 客户端
*/
OAuth2ClientDO validOAuthClientFromCache(String clientId);
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, String clientSecret,
String authorizedGrantType, Collection<String> scopes, String redirectUri);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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);
/**
* 刷新访问令牌

View File

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

View File

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

View File

@ -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);
// 调用, 并断言异常

View File

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

View File

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

View File

@ -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),

View File

@ -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_idscope
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>

View File

@ -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,