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