java每日精进 3.08 OAUTH 2.0

发布于:2025-03-09 ⋅ 阅读:(12) ⋅ 点赞:(0)

1.OAuth 2.0 是什么

系统之间的用户授权;

授权模式有三种:

  • 客户端模式(Client Credentials Grant)
    • 适用场景:认证主体是机器,主要用于没有前端的后端应用或者守护进程等场景,比如微服务之间的调用。在这种模式下,客户端通过向授权服务器提供自己的客户端 ID 和客户端密钥来获取访问令牌。
    • 流程:客户端直接向授权服务器的令牌端点发送请求,请求中包含客户端 ID、客户端密钥等信息。授权服务器验证通过后,直接返回访问令牌,客户端使用该令牌访问受保护资源。
  • 密码模式(Resource Owner Password Credentials Grant)
    • 适用场景:适用于高度信任的客户端(腾讯的app都可以使用QQ或微信登陆),比如设备的原生应用。用户直接将自己的用户名和密码提供给客户端,客户端使用这些信息去获取访问令牌。
    • 流程:客户端先请求用户输入用户名和密码,然后将这些信息连同客户端 ID、客户端密钥一起发送给授权服务器。授权服务器验证用户名和密码的正确性,以及客户端的合法性后,返回访问令牌。不过,这种模式因为需要客户端处理用户密码,存在一定的安全风险。
  • 授权码模式(Authorization Code Grant)
    • 适用场景:是功能最完整、流程最严密的授权模式,适用于有前端的 Web 应用。它能很好地保护用户的凭据,并提供了刷新令牌的机制。
    • 流程:客户端先引导用户到授权服务器的授权页面,用户在该页面输入自己的登录信息进行授权。授权服务器验证通过后,返回一个授权码给客户端。客户端再使用这个授权码,连同客户端 ID、客户端密钥等信息,向授权服务器的令牌端点请求访问令牌。授权服务器验证授权码的有效性后,返回访问令牌和刷新令牌(可选)。
    • 授权码模式的简化模式通常指的是隐式授权模式(Implicit Grant),它是 OAuth 2.0 协议中一种较为简化的授权流程,主要用于一些特殊场景;

    • 隐式授权模式适用于纯前端应用,尤其是那些运行在浏览器中的 JavaScript 应用。由于这类应用无法安全地存储客户端密钥(因为代码是公开的,容易被获取),所以隐式授权模式去掉了授权码这一中间环节,直接返回访问令牌,以简化流程。

2.密码模式实现单点登录

接入方无需提供后端接口,被接入方无需提供前端界面


执行过程

客户端(接入方)与 被接入方 之间的交互流程,

客户端部分包含两个文件页面:index.html 和 login.html。流程步骤如下:
1.1:若未登录,客户端从 index.html 跳转至 login.html;
1.2:在 login.html 页面,使用账号 + 密码进行登录,向 被接入方 的 OAuth2OpenController 中的 /system/oauth2/token 接口发起请求以获得访问令牌;
1.3:被接入方 返回访问令牌;
1.4:获得令牌后跳转回 index.html 首页 。

实现过程:
 

@Schema(description = "管理后台 - OAuth2 客户端创建/修改 Request VO")
@Data
public class OAuth2ClientSaveReqVO {

    @Schema(description = "编号", example = "1024")
    private Long id;

    @Schema(description = "客户端编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "tudou")
    @NotNull(message = "客户端编号不能为空")
    private String clientId;

    @Schema(description = "客户端密钥", requiredMode = Schema.RequiredMode.REQUIRED, example = "fan")
    @NotNull(message = "客户端密钥不能为空")
    private String secret;

    @Schema(description = "应用名", requiredMode = Schema.RequiredMode.REQUIRED, example = "土豆")
    @NotNull(message = "应用名不能为空")
    private String name;

    @Schema(description = "应用图标", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://www.iocoder.cn/xx.png")
    @NotNull(message = "应用图标不能为空")
    @URL(message = "应用图标的地址不正确")
    private String logo;

    @Schema(description = "应用描述", example = "我是一个应用")
    private String description;

    @Schema(description = "状态,参见 CommonStatusEnum 枚举", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "状态不能为空")
    private Integer status;

    @Schema(description = "访问令牌的有效期", requiredMode = Schema.RequiredMode.REQUIRED, example = "8640")
    @NotNull(message = "访问令牌的有效期不能为空")
    private Integer accessTokenValiditySeconds;

    @Schema(description = "刷新令牌的有效期", requiredMode = Schema.RequiredMode.REQUIRED, example = "8640000")
    @NotNull(message = "刷新令牌的有效期不能为空")
    private Integer refreshTokenValiditySeconds;

    @Schema(description = "可重定向的 URI 地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://www.iocoder.cn")
    @NotNull(message = "可重定向的 URI 地址不能为空")
    private List<@NotEmpty(message = "重定向的 URI 不能为空") @URL(message = "重定向的 URI 格式不正确") String> redirectUris;

    @Schema(description = "授权类型,参见 OAuth2GrantTypeEnum 枚举", requiredMode = Schema.RequiredMode.REQUIRED, example = "password")
    @NotNull(message = "授权类型不能为空")
    private List<String> authorizedGrantTypes;

    @Schema(description = "授权范围", example = "user_info")
    private List<String> scopes;

    @Schema(description = "自动通过的授权范围", example = "user_info")
    private List<String> autoApproveScopes;

    @Schema(description = "权限", example = "system:user:query")
    private List<String> authorities;

    @Schema(description = "资源", example = "1024")
    private List<String> resourceIds;

    @Schema(description = "附加信息", example = "{yunai: true}")
    private String additionalInformation;

    @AssertTrue(message = "附加信息必须是 JSON 格式")
    public boolean isAdditionalInformationJson() {
        return StrUtil.isEmpty(additionalInformation) || JsonUtils.isJson(additionalInformation);
    }

}

有这些信息,

2.访问接入方首页

进入接入方的 index.html 首页。因为暂未登录

可以点击「跳转」按钮

跳转到 login.html 登录页

<!-- 情况一:未登录:1)跳转 后端 的 SSO 登录页 -->
<div id="noLoginDiv" style="display: none">
	您未登录,点击 <a href="#" onclick="passwordLogin()">跳转 </a> 账号密码登录
</div>
/**
     * 账号密码登录
     */
    function login() {
      const clientId = 'by-password'; // 可以改写成,你的 clientId
      const clientSecret = 'test'; // 可以改写成,你的 clientSecret
      const grantType = 'password'; // 密码模式

      // 账号 + 密码
      const username = $('#username').val();
      const password = $('#password').val();
      if (username.length === 0 || password.length === 0) {
        alert('账号或密码未输入');
        return;
      }

      // 发起请求
      $.ajax({
        url: "http://127.0.0.1:48080/admin-api/system/oauth2/token?"
          // 客户端
          + "client_id=" + clientId
          + "&client_secret=" + clientSecret
          // 密码模式的参数
          + "&grant_type=" + grantType
          + "&username=" + username
          + "&password=" + password
          + '&scope=user.read user.write',
        method: 'POST',
        headers: {
          'tenant-id': '1', // 多租户编号,写死
        },
        success: function (result) {
          if (result.code !== 0) {
            alert('登录失败,原因:' + result.msg)
            return;
          }
          // 设置到 localStorage 中
          localStorage.setItem('ACCESS-TOKEN', result.data.access_token);
          localStorage.setItem('REFRESH-TOKEN', result.data.refresh_token);

          // 提示登录成功
          alert('登录成功!点击确认,跳转回首页');
          window.location.href = '/index.html';
        }
      });

点击登录发送Url,携带客户端必要验证信息:
http://127.0.0.1:48080/admin-api/system/oauth2/token?client_id=yudao-sso-demo-by-password&client_secret=test&grant_type=password&username=admin&password=admin123&scope=user.read%20user.write

@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.iocoder.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"),
    })
    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.getByGrantType(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));
    }
@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);
    }

根据用户名密码以及客户端信息和权限范围返回Token

@Override
    @Transactional(rollbackFor = Exception.class)
    public OAuth2AccessTokenDO createAccessToken(Long userId, Integer userType, String clientId, List<String> scopes) {
        OAuth2ClientDO clientDO = oauth2ClientService.validOAuthClientFromCache(clientId);
        // 创建刷新令牌
        OAuth2RefreshTokenDO refreshTokenDO = createOAuth2RefreshToken(userId, userType, clientDO, scopes);
        // 创建访问令牌
        return createOAuth2AccessToken(refreshTokenDO, clientDO);
    }

根据clientid检测是否存在此客户端以及客户端信息是否正确,正确则返回客户端信息类,以便于之后生成访问令牌和刷新令牌;

@Override
    public OAuth2ClientDO validOAuthClientFromCache(String clientId, String clientSecret, String authorizedGrantType,
                                                    Collection<String> scopes, String redirectUri) {
        // 校验客户端存在、且开启
        OAuth2ClientDO client = getSelf().getOAuth2ClientFromCache(clientId);
        if (client == null) {
            throw exception(OAUTH2_CLIENT_NOT_EXISTS);
        }
        if (CommonStatusEnum.isDisable(client.getStatus())) {
            throw exception(OAUTH2_CLIENT_DISABLE);
        }

        // 校验客户端密钥
        if (StrUtil.isNotEmpty(clientSecret) && ObjectUtil.notEqual(client.getSecret(), clientSecret)) {
            throw exception(OAUTH2_CLIENT_CLIENT_SECRET_ERROR);
        }
        // 校验授权方式
        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;
    }
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()).setScopes(scopes)
                .setExpiresTime(LocalDateTime.now().plusSeconds(clientDO.getRefreshTokenValiditySeconds()));
        oauth2RefreshTokenMapper.insert(refreshToken);
        return refreshToken;
    }
private OAuth2AccessTokenDO createOAuth2AccessToken(OAuth2RefreshTokenDO refreshTokenDO, OAuth2ClientDO clientDO) {
        OAuth2AccessTokenDO accessTokenDO = new OAuth2AccessTokenDO().setAccessToken(generateAccessToken())
                .setUserId(refreshTokenDO.getUserId()).setUserType(refreshTokenDO.getUserType())
                .setUserInfo(buildUserInfo(refreshTokenDO.getUserId(), refreshTokenDO.getUserType()))
                .setClientId(clientDO.getClientId()).setScopes(refreshTokenDO.getScopes())
                .setRefreshToken(refreshTokenDO.getRefreshToken())
                .setExpiresTime(LocalDateTime.now().plusSeconds(clientDO.getAccessTokenValiditySeconds()));
        accessTokenDO.setTenantId(TenantContextHolder.getTenantId()); // 手动设置租户编号,避免缓存到 Redis 的时候,无对应的租户编号
        oauth2AccessTokenMapper.insert(accessTokenDO);
        // 记录到 Redis 中
        oauth2AccessTokenRedisDAO.set(accessTokenDO);
        return accessTokenDO;
    }


 

可以看到,再次请求接口时会带上token,然后进行验证;

3.授权码模式实现单点登录

这张图展示了客户端(接入方)与被接入方之间基于 OAuth2 授权码模式的登录及获取访问令牌的流程,具体如下:

第一步:获得 code 授权码(红线流程)

  1. 1.1 跳转 SSO 登录:客户端访问 index.html 时,如果未登录,会跳转到被接入方的 sso.vue 页面进行单点登录(SSO)。
  2. 1.2 申请 code 授权码:在被接入方的 sso.vue 页面,向被接入方的 OAuth2OpenController 中的 /system/oauth2/authorize 接口申请 code 授权码。
  3. 1.3 返回 code 授权码:被接入方处理请求后,返回 code 授权码。
  4. 1.4 跳转回去,附带 code 授权码:携带获取到的 code 授权码,跳转回客户端的 callback.html 页面 。

第二步:获得访问令牌(紫线流程)

  1. 2.1 提交 code 授权码获得访问令牌:客户端在 callback.html 页面,将 code 授权码提交给自身的 LoginController 中的 /login-by-code 接口。
  2. 2.2 获得访问令牌:LoginController 通过 /code 授权码,向被接入方的 OAuth2OpenController 中的 /system/oauth2/token 接口请求访问令牌。
  3. 2.3 返回访问令牌:被接入方处理请求后,返回访问令牌。
  4. 2.4 返回访问令牌:LoginController 将获取到的访问令牌返回给 callback.html 页面。
  5. 2.5 跳转回首页:客户端获得访问令牌后,跳转回 index.html 首页,完成登录和授权流程 。
     

相比于账号密码,此授权方式多了一步从被接入方后端获取授权码再根据授权码获取Token访问令牌的步骤;

/**
     * 对应 Spring Security OAuth 的 AuthorizationEndpoint 类的 authorize 方法
     */
    @GetMapping("/authorize")
    @Operation(summary = "获得授权信息", description = "适合 code 授权码模式,或者 implicit 简化模式;在 sso.vue 单点登录界面被【获取】调用")
    @Parameter(name = "clientId", required = true, description = "客户端编号", example = "tudou")
    public CommonResult<OAuth2OpenAuthorizeInfoRespVO> authorize(@RequestParam("clientId") String clientId) {
        // 0. 校验用户已经登录。通过 Spring Security 实现

        // 1. 获得 Client 客户端的信息
        OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientId);
        // 2. 获得用户已经授权的信息
        List<OAuth2ApproveDO> approves = oauth2ApproveService.getApproveList(getLoginUserId(), getUserType(), clientId);
        // 拼接返回
        return success(OAuth2OpenConvert.INSTANCE.convert(client, approves));
    }

获取客户端信息(和账号密码模式相同,so略)

获取用户已授权信息:

@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;
    }
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.time.LocalDateTime;

/**
 * OAuth2 批准 DO
 *
 * 用户在 sso.vue 界面时,记录接受的 scope 列表
 */
@TableName(value = "system_oauth2_approve", autoResultMap = true)
@KeySequence("system_oauth2_approve_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 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 LocalDateTime expiresTime;

}

最后返回授权信息即可;