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 跳转 SSO 登录:客户端访问 index.html 时,如果未登录,会跳转到被接入方的 sso.vue 页面进行单点登录(SSO)。
- 1.2 申请 code 授权码:在被接入方的 sso.vue 页面,向被接入方的 OAuth2OpenController 中的 /system/oauth2/authorize 接口申请 code 授权码。
- 1.3 返回 code 授权码:被接入方处理请求后,返回 code 授权码。
- 1.4 跳转回去,附带 code 授权码:携带获取到的 code 授权码,跳转回客户端的 callback.html 页面 。
第二步:获得访问令牌(紫线流程)
- 2.1 提交 code 授权码获得访问令牌:客户端在 callback.html 页面,将 code 授权码提交给自身的 LoginController 中的 /login-by-code 接口。
- 2.2 获得访问令牌:LoginController 通过 /code 授权码,向被接入方的 OAuth2OpenController 中的 /system/oauth2/token 接口请求访问令牌。
- 2.3 返回访问令牌:被接入方处理请求后,返回访问令牌。
- 2.4 返回访问令牌:LoginController 将获取到的访问令牌返回给 callback.html 页面。
- 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;
}
最后返回授权信息即可;