# scaffold 项目之单点登陆 oauth

# 简介

OAuth 2.0 是一个开放标准,用于授权。它允许用户让第三方应用访问该用户在某一网站上存储的私密资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用。OAuth 2.0 专注于客户端开发者的简易性,同时为 Web 应用、桌面应用、手机和起居室设备提供专门的认证流程。

具体讲解可以移步 OAuth2.0讲解

重点: 理解 授权码模式密码模式 ,他们是最常用的两种授权模式。本项目也是基于这两个模式分别实现 SSO 单点登陆

# OAuth 2.0 授权模式选择

授权模式分为四种:

  1. 客户端模式
  2. 密码模式
  3. 授权码模式
  4. 简化模式

总结图:

使用场景

什么场景下使用客户端模式(Client Credentials)?

如果令牌拥有者是机器的情况下,那就使用客户端模式。列如说:

  • 开发了一个开发平台,提供给其他外部服务调用
  • 开发了一个 RPC 服务,提供给其他内部服务调用

实际的案例,我们接入微信公众号时,会使用 appidsecret 参数,获取 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 应用,可能需要访问用户的在线服务,如社交媒体、邮件等。
  • 由于移动设备的限制,存储客户端密钥可能不安全。

在该项目中使用了那种授权?

如上图所示,分成 外部授权内部登陆 两种方式。

  1. 红色的 外部授权 :基于【授权码模式】,实现 SSO 单点登陆,将用户授权给接入的客户端。客户端可以是内部的其他管理系统,也可以是外部的第三方系统。
  2. 绿色的 内部登陆 :管理后台的登陆接口,还是采用传统的 /admin-api/system/auth/login 账号密码登陆,并没有使用【密码模式】,另外,考虑到 OAuth2.0 使用的访问令牌 + 刷新令牌可以提供更好的安全性,所以即使是在传统的账号密码登陆,也复用了它作为令牌的实现。

# OAuth2.0 技术选型

oauth2.0 只是授权开发标准,真正实现这个标准的,一般采用 Spring Security OAuth 或者 Spring Authorization Server (SAS) 框架,前者已经废弃停止维护,市面上一般使用后者,虽说有了具体的实现,但是也有问题都是需要对源码有一定的了解:

  1. 学习成本大
  2. 排查问题困难
  3. 定制化困难

因此,可以考虑自己实现一个简单的支持 OAuth2.0 的功能

# 自己实现 OAuth2.0

# 思路

授权码的流程

  1. 客户端引导用户至认证服务器:用户访问客户端,客户端将用户重定向到授权服务器的授权端点,请求授权。这个请求会包含客户端 ID( client_id )、请求的权限范围( scope )、重定向 URI( redirect_uri )以及一个用于保护 CSRF 攻击的 state 参数等信息。
  2. 用户登录并授权:用户在授权服务器上登录并对客户端的授权请求做出响应。如果用户同意授权,授权服务器将用户重定向回客户端指定的重定向 URI,并附带一个授权码( code )。
  3. 客户端申请访问令牌:客户端使用上一步获得的授权码,向授权服务器申请访问令牌。这个过程通常在客户端的后端服务器上完成,以确保客户端密钥( client_secret )的安全。
  4. 授权服务器发放访问令牌:授权服务器核对授权码和重定向 URI,确认无误后,向客户端发送访问令牌( access token )和可能的刷新令牌( refresh token )。
  5. 客户端使用访问令牌访问资源:客户端使用获得的访问令牌向资源服务器请求受保护的资源。资源服务器验证访问令牌的有效性,如果验证成功,则提供访问的资源。

因此我们需要记录 客户端申请信息授权码 ,还要保存 访问令牌刷新令牌 ,还有访问资源权限的 访问域 这五张表

密码模式

授权服务器,和现在市面上的接入企业微信类似,给接入的客户端颁发 token 访问令牌,之后客户端的令牌校验和刷新都是调用第三方的。

# 表结构

# 表的说明

  1. system_oauth2_client(客户端)

    保存接入的客户端信息,主要是保存接入 客户端的名称密钥授权类型(密码模式或者授权码模式,可多选)重定向地址 让系统识别。

  2. system_oauth2_code(授权码)

    客户端校验通过后返回的授权码,该表记录授权码信息

  3. system_oauth2_access_token(访问令牌)

    客户端通过授权码获取访问令牌,该表记录访问令牌信息

  4. system_oauth2_refresh_token(刷新令牌)

    客户端通过授权码获取刷新令牌,该表记录刷新令牌信息

  5. system_oauth2_approve(访问域,授权信息,或者是批准范围)

    记录客户端授权的信息,能范围的资源和范围等

# 代码部分

通过上面的思路我们需要 5 个接口

  1. 获取授权码(申请授权)的接口(对应 Spring Security OAuth 的 AuthorizationEndpoint 类的 approveOrDeny 方法)
  2. 获取访问令牌等信息的接口(包括刷新令牌、授权范围、过期时间等)(对应 Spring Security OAuth 的 TokenEndpoint 类的 postAccessToken 方法)
  3. 校验访问令牌 (对应 Spring Security OAuth 的 CheckTokenEndpoint 类的 checkToken 方法)
  4. 删除访问令牌

授权码接口

/**
     * 对应 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));
    }