Sa-Token核心功能解剖三(OAuth2.0认证、分布式会话、参数签名 )
文章目录
- 概要
- 功能结构图
- 11.OAuth2.0认证
- 12.分布式会话
- 13.参数签名
概要
Sa-Token核心功能解剖,功能列表:
- OAuth2.0认证 —— 轻松搭建 OAuth2.0 服务,支持openid模式 。
- 分布式会话 —— 提供共享数据中心分布式会话方案。
- 参数签名 —— 提供跨系统API调用签名校验模块,防参数篡改,防请求重放。
功能结构图
11.OAuth2.0认证
轻松搭建 OAuth2.0 服务,支持openid模式 。
4. OAuth2.0 四种模式
基于不同的使用场景,OAuth2.0设计了四种模式:
- 授权码(Authorization Code):OAuth2.0 标准授权步骤,Server 端向 Client 端下放 Code 码,Client 端再用 Code 码换取授权 Access-Token。
- 隐藏式(Implicit):无法使用授权码模式时的备用选择,Server 端使用 URL 重定向方式直接将 Access-Token 下放到 Client 端页面。
- 密码式(Password):Client 端直接拿着用户的账号密码换取授权 Access-Token。
- 客户端凭证(Client Credentials):Server 端针对 Client 级别的 Token,代表应用自身的资源授权。
前提:修改hosts文件(C:\windows\system32\drivers\etc\hosts),添加以下IP映射,方便我们进行测试
127.0.0.1 sa-oauth-server.com
127.0.0.1 sa-oauth-client.com
OAuth2-Server端:sa-token-demo-oauth2-server 源码链接
OAuth2-Client端: sa-token-demo-oauth2-client 源码链接
一、Server 认证端(sa-token-demo-oauth2-server端)、认证中心
1.application.yml
server:
port: 8000
sa-token:
token-name: satoken
is-log: true
jwt-secret-key: saxsaxsaxsax
# OAuth2.0 配置
oauth2-server:
enable-authorization-code: true
# 是否全局开启 Implicit 模式
enable-implicit: true
# 是否全局开启密码模式
enable-password: true
# 是否全局开启客户端模式
enable-client-credentials: true
# 定义哪些 scope 是高级权限,多个用逗号隔开
# higher-scope: openid,userid
# 定义哪些 scope 是低级权限,多个用逗号隔开
# lower-scope: userinfo
spring:
#根据部署调整
redis:
ip: 127.0.0.1
port: 6379
password: ''
database: 1
2、启动日志
2025-03-31 15:49:01.001 INFO 20716 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8000 (http)
Sa-Token-OAuth2 Server端启动成功,配置如下:
SaOAuth2ServerConfig{enableAuthorizationCode=true, enableImplicit=true, enablePassword=true, enableClientCredentials=true, isNewRefresh=false, codeTimeout=300, accessTokenTimeout=7200, refreshTokenTimeout=2592000, clientTokenTimeout=7200, lowerClientTokenTimeout=-1, openidDigestPrefix='openid_default_digest_prefix, unionidDigestPrefix='unionid_default_digest_prefix, higherScope='null, lowerScope='null, mode4ReturnAccessToken='false, hideStatusField='false, oidc='SaOAuth2OidcConfig{iss='null', idTokenTimeout=600}}
- 统一认证登录(接收:客户端访问登录、OAuth模式访问 等请求)
I) 登录认证请求:
- http://sa-oauth-server.com:8000/oauth2/doLogin?name=sa&pwd=123456
SaOAuth2ServerController: Sa-Token-OAuth2 Server 认证端 Controller
@RestController
public class SaOAuth2ServerController {
// OAuth2-Server 端:处理所有 OAuth2 相关请求
@RequestMapping("/oauth2/*")
public Object request() {
System.out.println("------- 进入请求: " + SaHolder.getRequest().getUrl());
return SaOAuth2ServerProcessor.instance.dister();
}
............
}
}
- Sa-Token OAuth2 请求处理器:处理 Server 端请求, 路由分发
public class SaOAuth2ServerProcessor {
public static SaOAuth2ServerProcessor instance = new SaOAuth2ServerProcessor();
/**
* 处理 Server 端请求, 路由分发
* @return 处理结果
*/
public Object dister() {
// 获取变量
SaRequest req = SaHolder.getRequest();
// ------------------ 路由分发 ------------------
// doLogin 登录接口
if(req.isPath(Api.doLogin)) {
return doLogin();
}
}
-------------->
/**
* doLogin 登录接口
* @return 处理结果
*/
public Object doLogin() {
// 获取变量
SaRequest req = SaHolder.getRequest();
SaOAuth2ServerConfig cfg = SaOAuth2Manager.getServerConfig();
return cfg.doLoginHandle.apply(req.getParam(Param.name), req.getParam(Param.pwd));
}
SaOAuth2ServerController类 完成登录
@RestController
public class SaOAuth2ServerController {
// Sa-Token OAuth2 定制化配置
@Autowired
public void configOAuth2Server(SaOAuth2ServerConfig oauth2Server) {
// 登录处理函数
oauth2Server.doLoginHandle = (name, pwd) -> {
if("sa".equals(name) && "123456".equals(pwd)) {
StpUtil.login(10001);
return SaResult.ok().set("satoken", StpUtil.getTokenValue());
}
return SaResult.error("账号名或密码错误");
};
}
登录完成,callback到 客户端主页:http://sa-oauth-client.com:8002
其它 过程:
II)授权[http://sa-oauth-server.com:8000/oauth2/authorize?response_type=code&client_id=1001&redirect_uri=http://sa-oauth-client.com:8002/]
public Object dister() {
// 获取变量
SaRequest req = SaHolder.getRequest();
// ------------------ 路由分发 ------------------
// 模式一:Code授权码 || 模式二:隐藏式
if(req.isPath(Api.authorize)) {
return authorize();
}
------------>
/**
* 模式一:Code授权码 / 模式二:隐藏式
* @return 处理结果
*/
public Object authorize() {
// 获取变量
SaRequest req = SaHolder.getRequest();
SaResponse res = SaHolder.getResponse();
SaOAuth2ServerConfig cfg = SaOAuth2Manager.getServerConfig();
SaOAuth2DataGenerate dataGenerate = SaOAuth2Manager.getDataGenerate();
SaOAuth2Template oauth2Template = SaOAuth2Manager.getTemplate();
String responseType = req.getParamNotNull(Param.response_type);
// 3、构建请求 Model
RequestAuthModel ra = SaOAuth2Manager.getDataResolver().readRequestAuthModel(req, SaOAuth2Manager.getStpLogic().getLoginId());
// 6、判断:如果此次申请的Scope,该用户尚未授权,则转到授权页面
boolean isNeedCarefulConfirm = oauth2Template.isNeedCarefulConfirm(ra.loginId, ra.clientId, ra.scopes);
if(isNeedCarefulConfirm) {
return cfg.confirmView.apply(ra.clientId, ra.scopes);
}
}
第一次该用户尚未授权,则转到授权页面。
III)确认授权:用户在WEB页 同意授权 [/oauth2/doConfirm]
public Object dister() {
// 获取变量
SaRequest req = SaHolder.getRequest();
// doConfirm 确认授权接口
if(req.isPath(Api.doConfirm)) {
return doConfirm();
}
------------>
/**
* doConfirm 确认授权接口
* @return 处理结果
*/
public Object doConfirm() {
// 获取变量
SaRequest req = SaHolder.getRequest();
String clientId = req.getParamNotNull(Param.client_id);
Object loginId = SaOAuth2Manager.getStpLogic().getLoginId();
String scope = req.getParamNotNull(Param.scope);
List<String> scopes = SaOAuth2Manager.getDataConverter().convertScopeStringToList(scope);
SaOAuth2DataGenerate dataGenerate = SaOAuth2Manager.getDataGenerate();
SaOAuth2Template oauth2Template = SaOAuth2Manager.getTemplate();
// 确认授权
oauth2Template.saveGrantScope(clientId, loginId, scopes);
.......
// -------- 情况2:需要返回最终的 redirect_uri 地址
// s3、构建请求 Model
RequestAuthModel ra = SaOAuth2Manager.getDataResolver().readRequestAuthModel(req, loginId);
// 7、判断授权类型,构建不同的重定向地址
// 如果是 授权码式,则:开始重定向授权,下放code
if(ResponseType.code.equals(ra.responseType)) {
CodeModel codeModel = dataGenerate.generateCode(ra);
String redirectUri = dataGenerate.buildRedirectUri(ra.redirectUri, codeModel.code, ra.state);
return SaResult.ok().set(Param.redirect_uri, redirectUri);
}............
}
------------>
/**
* 持久化:用户授权记录
* @param clientId 应用id
* @param loginId 账号id
* @param scopes 权限列表
*/
default void saveGrantScope(String clientId, Object loginId, List<String> scopes) {
if( ! SaFoxUtil.isEmpty(scopes)) {
long ttl = checkClientModel(clientId).getAccessTokenTimeout();
String value = SaOAuth2Manager.getDataConverter().convertScopeListToString(scopes);
getSaTokenDao().set(splicingGrantScopeKey(clientId, loginId), value, ttl);
}
}
------------>
/**
* 拼接key:用户授权记录
* @param clientId 应用id
* @param loginId 账号id
* @return key
*/
default String splicingGrantScopeKey(String clientId, Object loginId) {
return getSaTokenConfig().getTokenName() + ":oauth2:grant-scope:" + clientId + ":" + loginId;
}
` Redis 存储 用户授权 [satoken:oauth2:grant-scope:clientId:loginId, scopes集合]`
------------>
/**
* 构建Model:Code授权码
* @param ra 请求参数Model
* @return 授权码Model
*/
@Override
public CodeModel generateCode(RequestAuthModel ra) {
SaOAuth2Dao dao = SaOAuth2Manager.getDao();
// 删除旧Code
dao.deleteCode(dao.getCodeValue(ra.clientId, ra.loginId));
// 生成新Code
String codeValue = SaOAuth2Strategy.instance.createCodeValue.execute(ra.clientId, ra.loginId, ra.scopes);
CodeModel cm = new CodeModel(codeValue, ra.clientId, ra.scopes, ra.loginId, ra.redirectUri, ra.getNonce());
// 保存新Code
dao.saveCode(cm);
dao.saveCodeIndex(cm);
// 保存code-nonce
dao.saveCodeNonceIndex(cm);
// 返回
return cm;
}
public interface SaOAuth2Dao {
//save 数据:持久化:Code-Model
default void saveCode(CodeModel c) {
if(c == null) {
return;
}
getSaTokenDao().setObject(splicingCodeSaveKey(c.code), c, SaOAuth2Manager.getServerConfig().getCodeTimeout());
}
}
-----------
/**
* 拼接key:Code持久化
* @param code 授权码
* @return key
*/
default String splicingCodeSaveKey(String code) {
return getSaTokenConfig().getTokenName() + ":oauth2:code:" + code;
}
`存储 code 授权码 [satoken:oauth2:code:code值(60位), CodeModel对象]`
执行完成后,String redirectUri = dataGenerate.buildRedirectUri(ra.redirectUri, codeModel.code, ra.state);
重定向: http://sa-oauth-client.com:8002/code=60位串
V)Code码获取 Access-Token
public Object dister() {
...........
// Code 换 Access-Token || 模式三:密码式
if(req.isPath(Api.token)) {
return token();
}
---------------
public Object token() {
AccessTokenModel accessTokenModel = SaOAuth2Strategy.instance.grantTypeAuth.apply(SaHolder.getRequest());
return SaOAuth2Manager.getDataResolver().buildAccessTokenReturnValue(accessTokenModel);
}
--------------
/**
* 根据 scope 信息对一个 AccessTokenModel 进行加工处理
*/
public SaOAuth2GrantTypeAuthFunction grantTypeAuth = (req) -> {
String grantType = req.getParamNotNull(SaOAuth2Consts.Param.grant_type);
SaOAuth2GrantTypeHandlerInterface grantTypeHandler = grantTypeHandlerMap.get(grantType);
...................
// 看看全局是否开启了此 grantType
........
// 调用 处理器
return grantTypeHandler.getAccessToken(req, clientIdAndSecretModel.getClientId(), scopes);
};
authorization_code grant_type 处理器
public class AuthorizationCodeGrantTypeHandler implements SaOAuth2GrantTypeHandlerInterface {
@Override
public String getHandlerGrantType() {
return GrantType.authorization_code;
}
@Override
public AccessTokenModel getAccessToken(SaRequest req, String clientId, List<String> scopes) {
.............
// 构建 Access-Token、返回
AccessTokenModel accessTokenModel = SaOAuth2Manager.getDataGenerate().generateAccessToken(code);
return accessTokenModel;
}
}
Sa-Token OAuth2 数据构建器,默认实现类SaOAuth2DataGenerateDefaultImpl :
public class SaOAuth2DataGenerateDefaultImpl implements SaOAuth2DataGenerate {
............
/**
* 构建Model:Access-Token
* @param code 授权码
* @return AccessToken Model
*/
@Override
public AccessTokenModel generateAccessToken(String code) {
SaOAuth2Dao dao = SaOAuth2Manager.getDao();
SaOAuth2DataConverter dataConverter = SaOAuth2Manager.getDataConverter();
.............
// 3、生成token
AccessTokenModel at = dataConverter.convertCodeToAccessToken(cm);
SaOAuth2Strategy.instance.workAccessTokenByScope.accept(at);
RefreshTokenModel rt = dataConverter.convertAccessTokenToRefreshToken(at);
at.refreshToken = rt.refreshToken;
at.refreshExpiresTime = rt.expiresTime;
// 4、保存token
dao.saveAccessToken(at);
dao.saveAccessTokenIndex(at);
dao.saveRefreshToken(rt);
dao.saveRefreshTokenIndex(rt);
// 5、删除此Code
dao.deleteCode(code);
dao.deleteCodeIndex(cm.clientId, cm.loginId);
// 6、返回 Access-Token
return at;
}
二、客户端(sa-token-demo-oauth2-client端)
1、application.yml
server:
port: 8002
sa-token.token-name: satoken-client
2、启动日志
2025-03-31 15:59:54.366 INFO 21036 --- [ restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8002 (http) with context
Sa-Token-OAuth Client端启动成功
-------------------- Sa-Token-OAuth2 示例 --------------------
首先在host文件 (C:\windows\system32\drivers\etc\hosts) 添加以下内容:
127.0.0.1 sa-oauth-server.com
127.0.0.1 sa-oauth-client.com
再从浏览器访问:
http://sa-oauth-client.com:8002
3.浏览器访问:http://sa-oauth-client.com:8002
未登录 —> 先登录(被 服务端拦截,进入 服务端【4. 统一认证登录】
http://sa-oauth-server.com:8000/oauth2/doLogin?name=sa&pwd=123456
登录成功后,开始授权。
- 选择 模式一:授权码(Authorization Code) 方式 认证
- 点我开始授权登录(静默授权)----> 访问 认证中心(Server 认证端):
http://sa-oauth-server.com:8000/oauth2/authorize?response_type=code
&client_id=1001&redirect_uri=http://sa-oauth-client.com:8002/
- response_type:code 即模式一:授权码
- redirect_uri: 认证成功后的callback 主页(一般为子系统home主页)
2) 认证中心【 II)确认授权】:用户 同意授权后 ,可进行Token和用户信息等获取
3)code码获取Token和用户信息
http://sa-oauth-server.com:8000/oauth2/token?grant_type=authorization_code&client_id=1001&client_secret=aaaa-bbbb-cccc-dddd-eeee&code={code}
进入 认证中心【V)Code码获取 Access-Token】
12.分布式会话
提供共享数据中心分布式会话方案。
- 需求场景
微服务架构下的第一个难题便是数据同步,单机版的Session在分布式环境下一般不能正常工作,为此我们需要对框架做一些特定的处理。
首先我们要明白,分布式环境下为什么Session会失效?因为用户在一个节点对会话做出的更改无法实时同步到其它的节点, 这就导致一个很严重的问题:如果用户在节点一上已经登录成功,那么当下一次的请求落在节点二上时,对节点二来讲,此用户仍然是未登录状态。 - 解决方案
要怎么解决这个问题呢?目前的主流方案有四种:
- Session同步:只要一个节点的数据发生了改变,就强制同步到其它所有节点
- Session粘滞:通过一定的算法,保证一个用户的所有请求都稳定的落在一个节点之上,对这个用户来讲,就好像还是在访问一个单机版的服务
- 建立会话中心:将Session存储在缓存中间件,使每个节点都变成无状态服务,例如Redis
- 颁发无状态token:放弃Session机制,将用户数据写入令牌,使会话数据做到令牌自解释,例如jwt
- 方案选择
方案一:性能消耗太大,不太考虑
方案二:需要从网关处动手,与框架无关
方案三:Sa-Token 整合Redis非常简单,详见章节:集成 Redis
方案四:详见官方仓库中 Sa-Token 整合jwt的示例
13.参数签名
提供跨系统API调用签名校验模块,防参数篡改,防请求重放。
涉及跨系统接口调用时,我们容易碰到以下安全问题:
请求身份被伪造。
请求参数被篡改。
请求被抓包,然后重放攻击。
解决思路:
1.使用摘要算法和密钥(secretKey:由双方制定32位字符串)生成参数签名
2.签名算法 追加 nonce 随机字符串,存储在缓存-redis保存15分钟 (防止被抓包,多次重放攻击)
3.签名算法 追加timestamp 时间戳,将请求的有效性限定在一个有限时间范围内,例如 15分钟。
客户端请求签名,传递参数以及签名sign值过程:
// 声明变量---根据业务接口 自定义
long userId = 10001;
long money = 1000;
String nonce = SaFoxUtil.getRandomString(32); // 随机32位字符串
long timestamp = System.currentTimeMillis(); // 系统当前时间戳
String secretKey = "xxxxxxxxxxxxxxxxxxxx";
// 计算 sign 参数
String sign = md5("money=" + money + "&userId=" + userId + "&nonce=" + nonce + "×tamp =" + timestamp + "&key=" + secretKey);
// 将 sign 拼接在请求地址后面
String res = HttpUtil.request("http://b.com/api/addMoney?userId=" + userId + "&nonce=" + nonce + "×tamp =" + timestamp + "&money=" + money + "&sign=" + sign);
服务验证签名:解析参数,匹配签名sign值
@RequestMapping("addMoney")
public SaResult addMoney(long userId, long money, String nonce, long timestamp, String sign) {
// 1、检查timestamp是否超出允许的范围[此处需要取绝对值,防止系统A与系统B服务器时钟不一致]
long timestampDisparity = Math.abs(System.currentTimeMillis() - timestamp);
if(timestampDisparity > 1000 * 60 * 15) {
return SaResult.error("timestamp 时间差超出允许的范围,请求无效");
}
// 2、检查此 nonce 是否已被使用过,防止被抓包,多次重放攻击
if(CacheUtil.get("nonce_" + nonce) != null) {
return SaResult.error("此 nonce 已被使用过了,请求无效");
}
//在 B 系统,使用同样的算法、同样的密钥,计算出 sign2,与传入的 sign 进行比对
String sign2 = md5("money=" + money + "&userId=" + userId + "&nonce=" + nonce + "×tamp =" + timestamp + "&key=" + secretKey);
if( ! sign2.equals(sign)) {
return SaResult.error("无效 sign,无法响应请求");
}
// 2、业务代码
// ...
// 3、返回
return SaResult.ok();
}