java每日精进 3.08 OAUTH 2.0

server/2025/3/11 2:05:28/

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 首页 。

实现过程:
 

java">@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 登录页

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

java">@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));}
java">@Overridepublic 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

java">@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检测是否存在此客户端以及客户端信息是否正确,正确则返回客户端信息类,以便于之后生成访问令牌和刷新令牌;

java">@Overridepublic 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;}
java">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;}
java">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访问令牌的步骤;

java">/*** 对应 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略)

获取用户已授权信息:

java">@Overridepublic 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;}
java">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 {/*** 编号,数据库自增*/@TableIdprivate 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;}

最后返回授权信息即可;


http://www.ppmy.cn/server/174062.html

相关文章

卡尔曼滤波算法从理论到实践:在STM32中的嵌入式实现

摘要&#xff1a;卡尔曼滤波&#xff08;Kalman Filter&#xff09;是传感器数据融合领域的经典算法&#xff0c;在姿态解算、导航定位等嵌入式场景中广泛应用。本文将从公式推导、代码实现、参数调试三个维度深入解析卡尔曼滤波&#xff0c;并给出基于STM32硬件的完整工程案例…

多终端支持!PC+移动端体育直播系统源码

如果你正在寻找支持 PC 和移动端的体育直播系统源码&#xff0c;本方案将帮你快速搭建一个兼容多终端的直播平台&#xff0c;支持赛事直播、实时比分、聊天室互动&#xff0c;并且可低成本变现&#xff01; &#x1f525; 技术架构 组件技术方案后端Spring Boot WebSocket R…

软件开发过程总揽

开发模型 传统开发模型 瀑布模型 #mermaid-svg-yDNBSwh3gDYETWou {font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-yDNBSwh3gDYETWou .error-icon{fill:#552222;}#mermaid-svg-yDNBSwh3gDYETWou .error-text{fill:#…

【虚拟仿真】Unity3D中实现激光/射线的发射/折射/反射的效果(3D版)

推荐阅读 CSDN主页GitHub开源地址Unity3D插件分享QQ群:398291828小红书小破站大家好,我是佛系工程师☆恬静的小魔龙☆,不定时更新Unity开发技巧,觉得有用记得一键三连哦。

Spring Cloud Gateway 笔记

Spring Cloud Gateway 笔记 简介 Spring Cloud Gateway 是基于 Spring 5、Spring Boot 2 和 Project Reactor 的 API 网关&#xff0c;提供动态路由、安全、监控和弹性等功能。 核心特性&#xff1a;异步非阻塞模型、高性能、支持动态配置、丰富的断言&#xff08;Predicate&…

多线程-JUC

简介 juc&#xff0c;java.util.concurrent包的简称&#xff0c;java1.5时引入。juc中提供了一系列的工具&#xff0c;可以更好地支持高并发任务 juc中提供的工具 可重入锁 ReentrantLock 可重入锁&#xff1a;ReentrantLock&#xff0c;可重入是指当一个线程获取到锁之后&…

linux 内网渗透后的痕迹清理

&#xff08;⚠️本文仅用于授权渗透测试与防御研究&#xff0c;禁止非法用途&#xff09; 0x00为什么要清理痕迹&#xff1f; 渗透后残留的日志、历史记录、文件时间戳等可能暴露攻击路径&#xff0c;导致溯源或防御反制。专业清理可降低风险&#xff0c;但需注意&#xff1a…

Android WebSocket工具类:重连、心跳、消息队列一站式解决方案

依赖库 使用 OkHttp 的WebSocket支持。 在 build.gradle 中添加依赖&#xff1a; implementation com.squareup.okhttp3:okhttp:4.9.3WebSocket工具类实现 import okhttp3.*; import android.os.Handler; import android.os.Looper; import android.util.Log;import java.ut…