提交 | 用户 | 时间
|
e7c126
|
1 |
package com.iailab.module.system.controller.admin.oauth2; |
H |
2 |
|
|
3 |
import cn.hutool.core.lang.Assert; |
|
4 |
import cn.hutool.core.util.ArrayUtil; |
|
5 |
import cn.hutool.core.util.ObjectUtil; |
|
6 |
import cn.hutool.core.util.StrUtil; |
|
7 |
import com.iailab.framework.common.enums.UserTypeEnum; |
|
8 |
import com.iailab.framework.common.pojo.CommonResult; |
|
9 |
import com.iailab.framework.common.util.http.HttpUtils; |
|
10 |
import com.iailab.framework.common.util.json.JsonUtils; |
|
11 |
import com.iailab.module.system.controller.admin.oauth2.vo.open.OAuth2OpenAccessTokenRespVO; |
|
12 |
import com.iailab.module.system.controller.admin.oauth2.vo.open.OAuth2OpenAuthorizeInfoRespVO; |
|
13 |
import com.iailab.module.system.controller.admin.oauth2.vo.open.OAuth2OpenCheckTokenRespVO; |
|
14 |
import com.iailab.module.system.convert.oauth2.OAuth2OpenConvert; |
|
15 |
import com.iailab.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO; |
|
16 |
import com.iailab.module.system.dal.dataobject.oauth2.OAuth2ApproveDO; |
|
17 |
import com.iailab.module.system.dal.dataobject.oauth2.OAuth2ClientDO; |
|
18 |
import com.iailab.module.system.enums.oauth2.OAuth2GrantTypeEnum; |
|
19 |
import com.iailab.module.system.service.oauth2.OAuth2ApproveService; |
|
20 |
import com.iailab.module.system.service.oauth2.OAuth2ClientService; |
|
21 |
import com.iailab.module.system.service.oauth2.OAuth2GrantService; |
|
22 |
import com.iailab.module.system.service.oauth2.OAuth2TokenService; |
|
23 |
import com.iailab.module.system.util.oauth2.OAuth2Utils; |
|
24 |
import io.swagger.v3.oas.annotations.Operation; |
|
25 |
import io.swagger.v3.oas.annotations.Parameter; |
|
26 |
import io.swagger.v3.oas.annotations.Parameters; |
|
27 |
import io.swagger.v3.oas.annotations.tags.Tag; |
|
28 |
import lombok.extern.slf4j.Slf4j; |
|
29 |
import org.springframework.validation.annotation.Validated; |
|
30 |
import org.springframework.web.bind.annotation.*; |
|
31 |
|
|
32 |
import javax.annotation.Resource; |
|
33 |
import javax.annotation.security.PermitAll; |
|
34 |
import javax.servlet.http.HttpServletRequest; |
|
35 |
import java.util.Collections; |
|
36 |
import java.util.List; |
|
37 |
import java.util.Map; |
|
38 |
|
|
39 |
import static com.iailab.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; |
|
40 |
import static com.iailab.framework.common.exception.util.ServiceExceptionUtil.exception0; |
|
41 |
import static com.iailab.framework.common.pojo.CommonResult.success; |
|
42 |
import static com.iailab.framework.common.util.collection.CollectionUtils.convertList; |
|
43 |
import static com.iailab.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; |
|
44 |
|
|
45 |
/** |
|
46 |
* 提供给外部应用调用为主 |
|
47 |
* |
|
48 |
* 一般来说,管理后台的 /system-api/* 是不直接提供给外部应用使用,主要是外部应用能够访问的数据与接口是有限的,而管理后台的 RBAC 无法很好的控制。 |
|
49 |
* 参考大量的开放平台,都是独立的一套 OpenAPI,对应到【本系统】就是在 Controller 下新建 open 包,实现 /open-api/* 接口,然后通过 scope 进行控制。 |
|
50 |
* 另外,一个公司如果有多个管理后台,它们 client_id 产生的 access token 相互之间是无法互通的,即无法访问它们系统的 API 接口,直到两个 client_id 产生信任授权。 |
|
51 |
* |
|
52 |
* 考虑到【本系统】暂时不想做的过于复杂,默认只有获取到 access token 之后,可以访问【本系统】管理后台的 /system-api/* 所有接口,除非手动添加 scope 控制。 |
|
53 |
* scope 的使用示例,可见 {@link OAuth2UserController} 类 |
|
54 |
* |
|
55 |
* @author iailab |
|
56 |
*/ |
|
57 |
@Tag(name = "管理后台 - OAuth2.0 授权") |
|
58 |
@RestController |
|
59 |
@RequestMapping("/system/oauth2") |
|
60 |
@Validated |
|
61 |
@Slf4j |
|
62 |
public class OAuth2OpenController { |
|
63 |
|
|
64 |
@Resource |
|
65 |
private OAuth2GrantService oauth2GrantService; |
|
66 |
@Resource |
|
67 |
private OAuth2ClientService oauth2ClientService; |
|
68 |
@Resource |
|
69 |
private OAuth2ApproveService oauth2ApproveService; |
|
70 |
@Resource |
|
71 |
private OAuth2TokenService oauth2TokenService; |
|
72 |
|
|
73 |
/** |
|
74 |
* 对应 Spring Security OAuth 的 TokenEndpoint 类的 postAccessToken 方法 |
|
75 |
* |
|
76 |
* 授权码 authorization_code 模式时:code + redirectUri + state 参数 |
|
77 |
* 密码 password 模式时:username + password + scope 参数 |
|
78 |
* 刷新 refresh_token 模式时:refreshToken 参数 |
|
79 |
* 客户端 client_credentials 模式:scope 参数 |
|
80 |
* 简化 implicit 模式时:不支持 |
|
81 |
* |
|
82 |
* 注意,默认需要传递 client_id + client_secret 参数 |
|
83 |
*/ |
|
84 |
@PostMapping("/token") |
|
85 |
@PermitAll |
|
86 |
@Operation(summary = "获得访问令牌", description = "适合 code 授权码模式,或者 implicit 简化模式;在 sso.vue 单点登录界面被【获取】调用") |
|
87 |
@Parameters({ |
|
88 |
@Parameter(name = "grant_type", required = true, description = "授权类型", example = "code"), |
|
89 |
@Parameter(name = "code", description = "授权范围", example = "userinfo.read"), |
|
90 |
@Parameter(name = "redirect_uri", description = "重定向 URI", example = "https://www.baidu.com"), |
|
91 |
@Parameter(name = "state", description = "状态", example = "1"), |
|
92 |
@Parameter(name = "username", example = "tudou"), |
|
93 |
@Parameter(name = "password", example = "cai"), // 多个使用空格分隔 |
|
94 |
@Parameter(name = "scope", example = "user_info"), |
|
95 |
@Parameter(name = "refresh_token", example = "123424233"), |
|
96 |
}) |
|
97 |
public CommonResult<OAuth2OpenAccessTokenRespVO> postAccessToken(HttpServletRequest request, |
|
98 |
@RequestParam("grant_type") String grantType, |
|
99 |
@RequestParam(value = "code", required = false) String code, // 授权码模式 |
|
100 |
@RequestParam(value = "redirect_uri", required = false) String redirectUri, // 授权码模式 |
|
101 |
@RequestParam(value = "state", required = false) String state, // 授权码模式 |
|
102 |
@RequestParam(value = "username", required = false) String username, // 密码模式 |
|
103 |
@RequestParam(value = "password", required = false) String password, // 密码模式 |
|
104 |
@RequestParam(value = "scope", required = false) String scope, // 密码模式 |
|
105 |
@RequestParam(value = "refresh_token", required = false) String refreshToken) { // 刷新模式 |
|
106 |
List<String> scopes = OAuth2Utils.buildScopes(scope); |
|
107 |
// 1.1 校验授权类型 |
d9f9ba
|
108 |
OAuth2GrantTypeEnum grantTypeEnum = OAuth2GrantTypeEnum.getByGrantType(grantType); |
e7c126
|
109 |
if (grantTypeEnum == null) { |
H |
110 |
throw exception0(BAD_REQUEST.getCode(), StrUtil.format("未知授权类型({})", grantType)); |
|
111 |
} |
|
112 |
if (grantTypeEnum == OAuth2GrantTypeEnum.IMPLICIT) { |
|
113 |
throw exception0(BAD_REQUEST.getCode(), "Token 接口不支持 implicit 授权模式"); |
|
114 |
} |
|
115 |
|
|
116 |
// 1.2 校验客户端 |
|
117 |
String[] clientIdAndSecret = obtainBasicAuthorization(request); |
|
118 |
OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientIdAndSecret[0], clientIdAndSecret[1], |
|
119 |
grantType, scopes, redirectUri); |
|
120 |
|
|
121 |
// 2. 根据授权模式,获取访问令牌 |
|
122 |
OAuth2AccessTokenDO accessTokenDO; |
|
123 |
switch (grantTypeEnum) { |
|
124 |
case AUTHORIZATION_CODE: |
|
125 |
accessTokenDO = oauth2GrantService.grantAuthorizationCodeForAccessToken(client.getClientId(), code, redirectUri, state); |
|
126 |
break; |
|
127 |
case PASSWORD: |
|
128 |
accessTokenDO = oauth2GrantService.grantPassword(username, password, client.getClientId(), scopes); |
|
129 |
break; |
|
130 |
case CLIENT_CREDENTIALS: |
|
131 |
accessTokenDO = oauth2GrantService.grantClientCredentials(client.getClientId(), scopes); |
|
132 |
break; |
|
133 |
case REFRESH_TOKEN: |
|
134 |
accessTokenDO = oauth2GrantService.grantRefreshToken(refreshToken, client.getClientId()); |
|
135 |
break; |
|
136 |
default: |
|
137 |
throw new IllegalArgumentException("未知授权类型:" + grantType); |
|
138 |
} |
|
139 |
Assert.notNull(accessTokenDO, "访问令牌不能为空"); // 防御性检查 |
|
140 |
return success(OAuth2OpenConvert.INSTANCE.convert(accessTokenDO)); |
|
141 |
} |
|
142 |
|
|
143 |
@DeleteMapping("/token") |
|
144 |
@PermitAll |
|
145 |
@Operation(summary = "删除访问令牌") |
|
146 |
@Parameter(name = "token", required = true, description = "访问令牌", example = "biu") |
|
147 |
public CommonResult<Boolean> revokeToken(HttpServletRequest request, |
|
148 |
@RequestParam("token") String token) { |
|
149 |
// 校验客户端 |
|
150 |
String[] clientIdAndSecret = obtainBasicAuthorization(request); |
|
151 |
OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientIdAndSecret[0], clientIdAndSecret[1], |
|
152 |
null, null, null); |
|
153 |
|
|
154 |
// 删除访问令牌 |
|
155 |
return success(oauth2GrantService.revokeToken(client.getClientId(), token)); |
|
156 |
} |
|
157 |
|
|
158 |
/** |
|
159 |
* 对应 Spring Security OAuth 的 CheckTokenEndpoint 类的 checkToken 方法 |
|
160 |
*/ |
|
161 |
@PostMapping("/check-token") |
|
162 |
@PermitAll |
|
163 |
@Operation(summary = "校验访问令牌") |
|
164 |
@Parameter(name = "token", required = true, description = "访问令牌", example = "biu") |
|
165 |
public CommonResult<OAuth2OpenCheckTokenRespVO> checkToken(HttpServletRequest request, |
|
166 |
@RequestParam("token") String token) { |
|
167 |
// 校验客户端 |
|
168 |
String[] clientIdAndSecret = obtainBasicAuthorization(request); |
|
169 |
oauth2ClientService.validOAuthClientFromCache(clientIdAndSecret[0], clientIdAndSecret[1], |
|
170 |
null, null, null); |
|
171 |
|
|
172 |
// 校验令牌 |
|
173 |
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.checkAccessToken(token); |
|
174 |
Assert.notNull(accessTokenDO, "访问令牌不能为空"); // 防御性检查 |
|
175 |
return success(OAuth2OpenConvert.INSTANCE.convert2(accessTokenDO)); |
|
176 |
} |
|
177 |
|
|
178 |
/** |
|
179 |
* 对应 Spring Security OAuth 的 AuthorizationEndpoint 类的 authorize 方法 |
|
180 |
*/ |
|
181 |
@GetMapping("/authorize") |
|
182 |
@Operation(summary = "获得授权信息", description = "适合 code 授权码模式,或者 implicit 简化模式;在 sso.vue 单点登录界面被【获取】调用") |
|
183 |
@Parameter(name = "clientId", required = true, description = "客户端编号", example = "tudou") |
|
184 |
public CommonResult<OAuth2OpenAuthorizeInfoRespVO> authorize(@RequestParam("clientId") String clientId) { |
|
185 |
// 0. 校验用户已经登录。通过 Spring Security 实现 |
|
186 |
|
|
187 |
// 1. 获得 Client 客户端的信息 |
|
188 |
OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientId); |
|
189 |
// 2. 获得用户已经授权的信息 |
|
190 |
List<OAuth2ApproveDO> approves = oauth2ApproveService.getApproveList(getLoginUserId(), getUserType(), clientId); |
|
191 |
// 拼接返回 |
|
192 |
return success(OAuth2OpenConvert.INSTANCE.convert(client, approves)); |
|
193 |
} |
|
194 |
|
|
195 |
/** |
|
196 |
* 对应 Spring Security OAuth 的 AuthorizationEndpoint 类的 approveOrDeny 方法 |
|
197 |
* |
|
198 |
* 场景一:【自动授权 autoApprove = true】 |
|
199 |
* 刚进入 sso.vue 界面,调用该接口,用户历史已经给该应用做过对应的授权,或者 OAuth2Client 支持该 scope 的自动授权 |
|
200 |
* 场景二:【手动授权 autoApprove = false】 |
|
201 |
* 在 sso.vue 界面,用户选择好 scope 授权范围,调用该接口,进行授权。此时,approved 为 true 或者 false |
|
202 |
* |
|
203 |
* 因为前后端分离,Axios 无法很好的处理 302 重定向,所以和 Spring Security OAuth 略有不同,返回结果是重定向的 URL,剩余交给前端处理 |
|
204 |
*/ |
|
205 |
@PostMapping("/authorize") |
|
206 |
@Operation(summary = "申请授权", description = "适合 code 授权码模式,或者 implicit 简化模式;在 sso.vue 单点登录界面被【提交】调用") |
|
207 |
@Parameters({ |
|
208 |
@Parameter(name = "response_type", required = true, description = "响应类型", example = "code"), |
|
209 |
@Parameter(name = "client_id", required = true, description = "客户端编号", example = "tudou"), |
|
210 |
@Parameter(name = "scope", description = "授权范围", example = "userinfo.read"), // 使用 Map<String, Boolean> 格式,Spring MVC 暂时不支持这么接收参数 |
|
211 |
@Parameter(name = "redirect_uri", required = true, description = "重定向 URI", example = "https://www.baidu.com"), |
|
212 |
@Parameter(name = "auto_approve", required = true, description = "用户是否接受", example = "true"), |
|
213 |
@Parameter(name = "state", example = "1") |
|
214 |
}) |
|
215 |
public CommonResult<String> approveOrDeny(@RequestParam("response_type") String responseType, |
|
216 |
@RequestParam("client_id") String clientId, |
|
217 |
@RequestParam(value = "scope", required = false) String scope, |
|
218 |
@RequestParam("redirect_uri") String redirectUri, |
|
219 |
@RequestParam(value = "auto_approve") Boolean autoApprove, |
|
220 |
@RequestParam(value = "state", required = false) String state) { |
|
221 |
@SuppressWarnings("unchecked") |
|
222 |
Map<String, Boolean> scopes = JsonUtils.parseObject(scope, Map.class); |
|
223 |
scopes = ObjectUtil.defaultIfNull(scopes, Collections.emptyMap()); |
|
224 |
// 0. 校验用户已经登录。通过 Spring Security 实现 |
|
225 |
|
|
226 |
// 1.1 校验 responseType 是否满足 code 或者 token 值 |
|
227 |
OAuth2GrantTypeEnum grantTypeEnum = getGrantTypeEnum(responseType); |
|
228 |
// 1.2 校验 redirectUri 重定向域名是否合法 + 校验 scope 是否在 Client 授权范围内 |
|
229 |
OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientId, null, |
|
230 |
grantTypeEnum.getGrantType(), scopes.keySet(), redirectUri); |
|
231 |
|
|
232 |
// 2.1 假设 approved 为 null,说明是场景一 |
|
233 |
if (Boolean.TRUE.equals(autoApprove)) { |
|
234 |
// 如果无法自动授权通过,则返回空 url,前端不进行跳转 |
|
235 |
if (!oauth2ApproveService.checkForPreApproval(getLoginUserId(), getUserType(), clientId, scopes.keySet())) { |
|
236 |
return success(null); |
|
237 |
} |
|
238 |
} else { // 2.2 假设 approved 非 null,说明是场景二 |
|
239 |
// 如果计算后不通过,则跳转一个错误链接 |
|
240 |
if (!oauth2ApproveService.updateAfterApproval(getLoginUserId(), getUserType(), clientId, scopes)) { |
|
241 |
return success(OAuth2Utils.buildUnsuccessfulRedirect(redirectUri, responseType, state, |
|
242 |
"access_denied", "User denied access")); |
|
243 |
} |
|
244 |
} |
|
245 |
|
|
246 |
// 3.1 如果是 code 授权码模式,则发放 code 授权码,并重定向 |
|
247 |
List<String> approveScopes = convertList(scopes.entrySet(), Map.Entry::getKey, Map.Entry::getValue); |
|
248 |
if (grantTypeEnum == OAuth2GrantTypeEnum.AUTHORIZATION_CODE) { |
|
249 |
String authorizationCodeRedirect = getAuthorizationCodeRedirect(getLoginUserId(), client, approveScopes, redirectUri, state); |
|
250 |
return success(authorizationCodeRedirect); |
|
251 |
} |
|
252 |
// 3.2 如果是 token 则是 implicit 简化模式,则发送 accessToken 访问令牌,并重定向 |
|
253 |
return success(getImplicitGrantRedirect(getLoginUserId(), client, approveScopes, redirectUri, state)); |
|
254 |
} |
|
255 |
|
|
256 |
private static OAuth2GrantTypeEnum getGrantTypeEnum(String responseType) { |
|
257 |
if (StrUtil.equals(responseType, "code")) { |
|
258 |
return OAuth2GrantTypeEnum.AUTHORIZATION_CODE; |
|
259 |
} |
|
260 |
if (StrUtil.equalsAny(responseType, "token")) { |
|
261 |
return OAuth2GrantTypeEnum.IMPLICIT; |
|
262 |
} |
|
263 |
throw exception0(BAD_REQUEST.getCode(), "response_type 参数值只允许 code 和 token"); |
|
264 |
} |
|
265 |
|
|
266 |
private String getImplicitGrantRedirect(Long userId, OAuth2ClientDO client, |
|
267 |
List<String> scopes, String redirectUri, String state) { |
|
268 |
// 1. 创建 access token 访问令牌 |
|
269 |
OAuth2AccessTokenDO accessTokenDO = oauth2GrantService.grantImplicit(userId, getUserType(), client.getClientId(), scopes); |
|
270 |
Assert.notNull(accessTokenDO, "访问令牌不能为空"); // 防御性检查 |
|
271 |
// 2. 拼接重定向的 URL |
|
272 |
// noinspection unchecked |
|
273 |
return OAuth2Utils.buildImplicitRedirectUri(redirectUri, accessTokenDO.getAccessToken(), state, accessTokenDO.getExpiresTime(), |
|
274 |
scopes, JsonUtils.parseObject(client.getAdditionalInformation(), Map.class)); |
|
275 |
} |
|
276 |
|
|
277 |
private String getAuthorizationCodeRedirect(Long userId, OAuth2ClientDO client, |
|
278 |
List<String> scopes, String redirectUri, String state) { |
|
279 |
// 1. 创建 code 授权码 |
|
280 |
String authorizationCode = oauth2GrantService.grantAuthorizationCodeForCode(userId, getUserType(), client.getClientId(), scopes, |
|
281 |
redirectUri, state); |
|
282 |
// 2. 拼接重定向的 URL |
|
283 |
return OAuth2Utils.buildAuthorizationCodeRedirectUri(redirectUri, authorizationCode, state); |
|
284 |
} |
|
285 |
|
|
286 |
private Integer getUserType() { |
|
287 |
return UserTypeEnum.ADMIN.getValue(); |
|
288 |
} |
|
289 |
|
|
290 |
private String[] obtainBasicAuthorization(HttpServletRequest request) { |
|
291 |
String[] clientIdAndSecret = HttpUtils.obtainBasicAuthorization(request); |
|
292 |
if (ArrayUtil.isEmpty(clientIdAndSecret) || clientIdAndSecret.length != 2) { |
|
293 |
throw exception0(BAD_REQUEST.getCode(), "client_id 或 client_secret 未正确传递"); |
|
294 |
} |
|
295 |
return clientIdAndSecret; |
|
296 |
} |
|
297 |
|
|
298 |
} |