# scaffold 项目之单点登陆 oauth
# 简介
OAuth 2.0 是一个开放标准,用于授权。它允许用户让第三方应用访问该用户在某一网站上存储的私密资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用。OAuth 2.0 专注于客户端开发者的简易性,同时为 Web 应用、桌面应用、手机和起居室设备提供专门的认证流程。
具体讲解可以移步 OAuth2.0讲解
重点: 理解 授权码模式
和 密码模式
,他们是最常用的两种授权模式。本项目也是基于这两个模式分别实现 SSO 单点登陆
# OAuth 2.0 授权模式选择
授权模式分为四种:
- 客户端模式
- 密码模式
- 授权码模式
- 简化模式
总结图:
使用场景
什么场景下使用客户端模式(Client Credentials)?
如果令牌拥有者是机器的情况下,那就使用客户端模式。列如说:
- 开发了一个开发平台,提供给其他外部服务调用
- 开发了一个 RPC 服务,提供给其他内部服务调用
实际的案例,我们接入微信公众号时,会使用 appid
和 secret
参数,获取 Access token
访问令牌。
什么场景下使用密码模式(Resource Owner Password Credentials)?
接入的 Client 客户端,是属于自己的情况下,可以使用密码模式。例如说:
- 客户端是你自己的 App 或者网页,然后授权服务也是你公司的
不过,如果客户端是第三方的情况下,使用密码模式的话,该客户端是可以拿到用户的账号、密码信息,存在安全风险,此时可以考虑使用授权码或简化模式。
什么场景下使用授权码模式(Authorization Code)?
接入的 Client 客户端,是属于第三方的情况下,可以使用授权码模式。列如说:
- 客户端是你自己公司的 App 或者网页,作为第三方,接入微信、QQ、钉钉等进行 OAuth2.0 登陆
当然,如果客户端是自己的情况下,也可以采用授权码模式。例如说:
- 客户端是腾讯旗下的各种游戏,可以使用微信、QQ 等等进行 OAuth2.0 登陆
- 客户端是公式内的各种管理后台(ERP、OA、CRM 等),跳转到统一的 SSO 单点登陆,使用授权码模式进行授权
什么场景下使用简化模式(Implicit)?
简化模式,简化的是授权码模式的流程的第二步,差异在于:
- 授权码模式:授权完成后,获得的是 code 授权码,需要 Server Side 服务端使用该授权码,再向授权服务器获取 Access Token 访问令牌
- 简化模式:授权完成后,ClientSide 客户端直接获得 Access Token 访问令牌
移动应用的例子:
- 移动设备上的原生应用,如 iOS 或 Android 应用,可能需要访问用户的在线服务,如社交媒体、邮件等。
- 由于移动设备的限制,存储客户端密钥可能不安全。
在该项目中使用了那种授权?
如上图所示,分成 外部授权
和 内部登陆
两种方式。
- 红色的
外部授权
:基于【授权码模式】,实现 SSO 单点登陆,将用户授权给接入的客户端。客户端可以是内部的其他管理系统,也可以是外部的第三方系统。 - 绿色的
内部登陆
:管理后台的登陆接口,还是采用传统的/admin-api/system/auth/login
账号密码登陆,并没有使用【密码模式】,另外,考虑到 OAuth2.0 使用的访问令牌 + 刷新令牌可以提供更好的安全性,所以即使是在传统的账号密码登陆,也复用了它作为令牌的实现。
# OAuth2.0 技术选型
oauth2.0 只是授权开发标准,真正实现这个标准的,一般采用 Spring Security OAuth
或者 Spring Authorization Server
(SAS) 框架,前者已经废弃停止维护,市面上一般使用后者,虽说有了具体的实现,但是也有问题都是需要对源码有一定的了解:
- 学习成本大
- 排查问题困难
- 定制化困难
因此,可以考虑自己实现一个简单的支持 OAuth2.0 的功能
# 自己实现 OAuth2.0
# 思路
授权码的流程
- 客户端引导用户至认证服务器:用户访问客户端,客户端将用户重定向到授权服务器的授权端点,请求授权。这个请求会包含客户端 ID(
client_id
)、请求的权限范围(scope
)、重定向 URI(redirect_uri
)以及一个用于保护 CSRF 攻击的state
参数等信息。 - 用户登录并授权:用户在授权服务器上登录并对客户端的授权请求做出响应。如果用户同意授权,授权服务器将用户重定向回客户端指定的重定向 URI,并附带一个授权码(
code
)。 - 客户端申请访问令牌:客户端使用上一步获得的授权码,向授权服务器申请访问令牌。这个过程通常在客户端的后端服务器上完成,以确保客户端密钥(
client_secret
)的安全。 - 授权服务器发放访问令牌:授权服务器核对授权码和重定向 URI,确认无误后,向客户端发送访问令牌(
access token
)和可能的刷新令牌(refresh token
)。 - 客户端使用访问令牌访问资源:客户端使用获得的访问令牌向资源服务器请求受保护的资源。资源服务器验证访问令牌的有效性,如果验证成功,则提供访问的资源。
因此我们需要记录 客户端申请信息
, 授权码
,还要保存 访问令牌
和 刷新令牌
,还有访问资源权限的 访问域
这五张表
密码模式
授权服务器,和现在市面上的接入企业微信类似,给接入的客户端颁发 token 访问令牌,之后客户端的令牌校验和刷新都是调用第三方的。
# 表结构
# 表的说明
system_oauth2_client(客户端)
保存接入的客户端信息,主要是保存接入
客户端的名称
、密钥
、授权类型(密码模式或者授权码模式,可多选)
、重定向地址
让系统识别。system_oauth2_code(授权码)
客户端校验通过后返回的授权码,该表记录授权码信息
system_oauth2_access_token(访问令牌)
客户端通过授权码获取访问令牌,该表记录访问令牌信息
system_oauth2_refresh_token(刷新令牌)
客户端通过授权码获取刷新令牌,该表记录刷新令牌信息
system_oauth2_approve(访问域,授权信息,或者是批准范围)
记录客户端授权的信息,能范围的资源和范围等
# 代码部分
通过上面的思路我们需要 5 个接口
- 获取授权码(申请授权)的接口(对应 Spring Security OAuth 的 AuthorizationEndpoint 类的 approveOrDeny 方法)
- 获取访问令牌等信息的接口(包括刷新令牌、授权范围、过期时间等)(对应 Spring Security OAuth 的 TokenEndpoint 类的 postAccessToken 方法)
- 校验访问令牌 (对应 Spring Security OAuth 的 CheckTokenEndpoint 类的 checkToken 方法)
- 删除访问令牌
授权码接口
/** | |
* 对应 Spring Security OAuth 的 AuthorizationEndpoint 类的 approveOrDeny 方法 | |
* <p> | |
* <li> | |
* 场景一:【自动授权 autoApprove = true】 | |
* 刚进入 sso.vue 界面,调用该接口,用户历史已经给该应用做过对应的授权,或者 OAuth2Client 支持该 scope 的自动授权 | |
* <li> | |
* 场景二:【手动授权 autoApprove = false】 | |
* 在 sso.vue 界面,用户选择好 scope 授权范围,调用该接口,进行授权。此时,approved 为 true 或者 false | |
* <p> | |
* 因为前后端分离,Axios 无法很好的处理 302 重定向,所以和 Spring Security OAuth 略有不同,返回结果是重定向的 URL,剩余交给前端处理 | |
*/ | |
@PostMapping("/authorize") | |
@Operation(summary = "申请授权", description = "适合 code 授权码模式,或者 implicit 简化模式;在 sso.vue 单点登录界面被【提交】调用") | |
@Parameters({ | |
@Parameter(name = "response_type", required = true, description = "响应类型", example = "code"), | |
@Parameter(name = "client_id", required = true, description = "客户端编号", example = "tudou"), | |
// 使用 Map<String, Boolean> 格式,Spring MVC 暂时不支持这么接收参数 | |
@Parameter(name = "scope", description = "授权范围", example = "userinfo.read"), | |
@Parameter(name = "redirect_uri", required = true, description = "重定向 URI", example = "https://www.baidu.cn"), | |
@Parameter(name = "auto_approve", required = true, description = "用户是否接受", example = "true"), | |
@Parameter(name = "state", example = "1") | |
}) | |
// 避免 Post 请求被记录操作日志 | |
@OperateLog(enable = false) | |
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()); | |
// 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)); | |
} |
获取 token 接口
/** | |
* 对应 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") | |
@PermitAll | |
@Operation(summary = "获得访问令牌", description = "适合 code 授权码模式,或者 implicit 简化模式;在 sso.vue 单点登录界面被【获取】调用") | |
@Parameters({ | |
@Parameter(name = "grant_type", required = true, description = "授权类型", example = "code"), | |
@Parameter(name = "code", description = "授权范围", example = "userinfo.read"), | |
@Parameter(name = "redirect_uri", description = "重定向 URI", example = "https://www.baidu.cn"), | |
@Parameter(name = "state", description = "状态", example = "1"), | |
@Parameter(name = "username", example = "tudou"), | |
// 多个使用空格分隔 | |
@Parameter(name = "password", example = "cai"), | |
@Parameter(name = "scope", example = "user_info"), | |
@Parameter(name = "refresh_token", example = "123424233"), | |
}) | |
@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); | |
// 1.1 校验授权类型 | |
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 授权模式"); | |
} | |
// 1.2 校验客户端 | |
String[] clientIdAndSecret = obtainBasicAuthorization(request); | |
OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientIdAndSecret[0], clientIdAndSecret[1], | |
grantType, scopes, redirectUri); | |
// 2. 根据授权模式,获取访问令牌 | |
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)); | |
} |
校验 token
/** | |
* 对应 Spring Security OAuth 的 CheckTokenEndpoint 类的 checkToken 方法 | |
*/ | |
@PostMapping("/check-token") | |
@PermitAll | |
@Operation(summary = "校验访问令牌") | |
@Parameter(name = "token", required = true, description = "访问令牌", example = "biu") | |
// 避免 Post 请求被记录操作日志 | |
@OperateLog(enable = false) | |
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)); | |
} |