sa-token改造

This commit is contained in:
chendt
2023-04-24 11:30:37 +08:00
parent 4912c2567e
commit 55f0a6b548
10 changed files with 157 additions and 257 deletions

View File

@@ -24,14 +24,9 @@
<tbody>
<tr>
<td>Spring Boot</td>
<td>2.1.6.RELEASE</td>
<td>3.0.4</td>
<td>MVC核心框架</td>
</tr>
<tr>
<td>Spring Security oauth2</td>
<td>2.1.5.RELEASE</td>
<td>认证和授权框架</td>
</tr>
<tr>
<td>MyBatis</td>
<td>3.5.0</td>
@@ -39,22 +34,17 @@
</tr>
<tr>
<td>MyBatisPlus</td>
<td>3.1.0</td>
<td>3.5.3.1</td>
<td>基于mybatis使用lambda表达式的</td>
</tr>
<tr>
<td>Swagger-UI</td>
<td>2.9.2</td>
<td>4.0.0</td>
<td>文档生产工具</td>
</tr>
<tr>
<td>Hibernator-Validator</td>
<td>6.0.17.Final</td>
<td>验证框架</td>
</tr>
<tr>
<td>redisson</td>
<td>3.10.6</td>
<td>3.19.3</td>
<td>对redis进行封装集成分布式锁等</td>
</tr>
<tr>
@@ -64,19 +54,9 @@
</tr>
<tr>
<td>log4j2</td>
<td>2.11.2</td>
<td>2.17.2</td>
<td>更快的log日志工具</td>
</tr>
<tr>
<td>fst</td>
<td>2.57</td>
<td>更快的序列化和反序列化工具</td>
</tr>
<tr>
<td>orika</td>
<td>1.5.4</td>
<td>更快的bean复制工具</td>
</tr>
<tr>
<td>lombok</td>
<td>1.18.8</td>
@@ -84,13 +64,13 @@
</tr>
<tr>
<td>hutool</td>
<td>4.5.0</td>
<td>5.8.15</td>
<td>更适合国人的java工具集</td>
</tr>
<tr>
<td>swagger-bootstrap</td>
<td>1.9.3</td>
<td>基于swagger更便于国人使用的swagger ui</td>
<td>xxl-job</td>
<td>2.3.1</td>
<td>定时任务</td>
</tr>
</tbody>
</table>
@@ -112,7 +92,7 @@
<tbody>
<tr>
<td>jdk</td>
<td>1.8+</td>
<td>17</td>
</tr>
<tr>
<td>mysql</td>

View File

@@ -33,11 +33,13 @@
<aliyun-dysmsapi.version>1.1.0</aliyun-dysmsapi.version>
<mybatis-plus.version>3.5.3.1</mybatis-plus.version>
<redisson.version>3.19.3</redisson.version>
<transmittable-thread-local.version>2.12.1</transmittable-thread-local.version>
<transmittable-thread-local.version>2.14.2</transmittable-thread-local.version>
<log4j.version>2.19.0</log4j.version>
<knife4j.version>4.0.0</knife4j.version>
<xxl-job.version>2.3.1</xxl-job.version>
<spring-cloud-commons.version>4.0.1</spring-cloud-commons.version>
<satoken.version>1.34.0</satoken.version>
<fastjson.version>1.2.83</fastjson.version>
</properties>
<dependencyManagement>

View File

@@ -25,3 +25,14 @@ mybatis-plus:
field-strategy: NOT_NULL
# 默认数据库表下划线命名
table-underline: true
sa-token:
# token名称 (同时也是cookie名称)
token-name: Authorization
# 是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录)
is-concurrent: true
# 在多人登录同一账号时是否共用一个token(不共用,避免登出时导致其他用户也登出)
is-share: false
# token风格(默认可取值uuid、simple-uuid、random-32、random-64、random-128、tik)
token-style: uuid
# 是否输出操作日志
is-log: false

View File

@@ -29,3 +29,14 @@ mybatis-plus:
management:
server:
add-application-context-header: false
sa-token:
# token名称 (同时也是cookie名称)
token-name: Authorization
# 是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录)
is-concurrent: true
# 在多人登录同一账号时是否共用一个token(不共用,避免登出时导致其他用户也登出)
is-share: false
# token风格(默认可取值uuid、simple-uuid、random-32、random-64、random-128、tik)
token-style: uuid
# 是否输出操作日志
is-log: false

View File

@@ -30,4 +30,9 @@ public interface OauthCacheNames {
* 根据uid获取保存的token key缓存使用的key
*/
String UID_TO_ACCESS = OAUTH_TOKEN_PREFIX + "uid_to_access:";
/**
* 保存token的用户信息使用的key
*/
String USER_INFO = OAUTH_TOKEN_PREFIX + "user_info:";
}

View File

@@ -106,7 +106,7 @@ public class ServerResponseEntity<T> implements Serializable {
public ServerResponseEntity() {
// 版本号
this.version = "mall4j.v230410";
this.version = "mall4j.v230424";
}
public static <T> ServerResponseEntity<T> success(T data) {

View File

@@ -28,6 +28,23 @@
<artifactId>captcha</artifactId>
<version>1.3.0</version>
</dependency>
<!-- Sa-Token 权限认证在线文档https://sa-token.cc -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>${satoken.version}</version>
</dependency>
<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-dao-redis-jackson</artifactId>
<version>${satoken.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>${fastjson.version}</version>
</dependency>
</dependencies>
</project>

View File

@@ -9,6 +9,7 @@
*/
package com.yami.shop.security.common.filter;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;
import com.yami.shop.common.exception.YamiShopBindException;
@@ -22,6 +23,7 @@ import com.yami.shop.security.common.util.AuthUserContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
@@ -51,6 +53,9 @@ public class AuthFilter implements Filter {
@Autowired
private TokenStore tokenStore;
@Value("${sa-token.token-name}")
private String tokenName;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
@@ -72,7 +77,7 @@ public class AuthFilter implements Filter {
}
}
String accessToken = req.getHeader("Authorization");
String accessToken = req.getHeader(tokenName);
// 也许需要登录不登陆也能用的uri
boolean mayAuth = pathMatcher.match(AuthConfigAdapter.MAYBE_AUTH_URI, requestUri);
@@ -82,6 +87,13 @@ public class AuthFilter implements Filter {
try {
// 如果有token就要获取token
if (StrUtil.isNotBlank(accessToken)) {
// 校验登录,并从缓存中取出用户信息
try {
StpUtil.checkLogin();
} catch (Exception e) {
httpHandler.printServerResponseToWeb(ServerResponseEntity.fail(ResponseEnum.UNAUTHORIZED));
return;
}
userInfoInToken = tokenStore.getUserInfoByAccessToken(accessToken, true);
}
else if (!mayAuth) {

View File

@@ -33,6 +33,10 @@ public class PasswordManager {
public String passwordSignKey;
public String decryptPassword(String data) {
// 在使用oracle的JDK时JAR包必须签署特殊的证书才能使用。
// 解决方案 1.使用openJDK或者非oracle的JDK建议 2.添加证书
// hutool的aes报错可以打开下面那段代码
// SecureUtil.disableBouncyCastle();
AES aes = new AES(passwordSignKey.getBytes(StandardCharsets.UTF_8));
String decryptStr;
String decryptPassword;

View File

@@ -9,34 +9,26 @@
*/
package com.yami.shop.security.common.manager;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.IdUtil;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.symmetric.AES;
import com.alibaba.fastjson.JSON;
import com.yami.shop.common.constants.OauthCacheNames;
import com.yami.shop.common.response.ResponseEnum;
import com.yami.shop.common.exception.YamiShopBindException;
import com.yami.shop.common.util.PrincipalUtil;
import com.yami.shop.common.response.ResponseEnum;
import com.yami.shop.security.common.bo.TokenInfoBO;
import com.yami.shop.security.common.bo.UserInfoInTokenBO;
import com.yami.shop.security.common.enums.SysTypeEnum;
import com.yami.shop.security.common.vo.TokenInfoVO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* token管理 1. 登陆返回token 2. 刷新token 3. 清除用户过去token 4. 校验token
@@ -49,96 +41,49 @@ public class TokenStore {
private static final Logger logger = LoggerFactory.getLogger(TokenStore.class);
/**
* 用于aes签名的key16位
*/
@Value("${auth.token.signKey:-mall4j--mall4j-}")
public String tokenSignKey;
private final RedisTemplate<String, Object> redisTemplate;
private final RedisSerializer<Object> redisSerializer;
private final StringRedisTemplate stringRedisTemplate;
public TokenStore(RedisTemplate<String, Object> redisTemplate,
StringRedisTemplate stringRedisTemplate, GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer) {
public TokenStore(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
this.redisSerializer = genericJackson2JsonRedisSerializer;
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 将用户的部分信息存储在token并返回token信息
* @param userInfoInToken 用户在token中的信息
* @return token信息
* 以Sa-Token技术生成token并返回token信息
* @param userInfoInToken
* @return
*/
public TokenInfoBO storeAccessToken(UserInfoInTokenBO userInfoInToken) {
public TokenInfoBO storeAccessSaToken(UserInfoInTokenBO userInfoInToken) {
// token生成
int timeoutSecond = getExpiresIn(userInfoInToken.getSysType());
String uid = this.getUid(userInfoInToken.getSysType().toString(), userInfoInToken.getUserId());
StpUtil.login(uid, timeoutSecond);
String token = StpUtil.getTokenValue();
// 用户信息存入缓存
String keyName = OauthCacheNames.USER_INFO + token;
redisTemplate.delete(keyName);
redisTemplate.opsForValue().set(
keyName,
JSON.toJSONString(userInfoInToken),
timeoutSecond,
TimeUnit.SECONDS
);
// 数据封装返回(token不用加密)
TokenInfoBO tokenInfoBO = new TokenInfoBO();
String accessToken = IdUtil.simpleUUID();
String refreshToken = IdUtil.simpleUUID();
tokenInfoBO.setUserInfoInToken(userInfoInToken);
tokenInfoBO.setExpiresIn(getExpiresIn(userInfoInToken.getSysType()));
String uidToAccessKeyStr = getUserIdToAccessKey(getApprovalKey(userInfoInToken));
String accessKeyStr = getAccessKey(accessToken);
String refreshToAccessKeyStr = getRefreshToAccessKey(refreshToken);
// 一个用户会登陆很多次每次登陆的token都会存在 uid_to_access里面
// 但是每次保存都会更新这个key的时间而key里面的token有可能会过期过期就要移除掉
List<byte[]> existsAccessTokensBytes = new ArrayList<>();
// 新的token数据
existsAccessTokensBytes.add((accessToken + StrUtil.COLON + refreshToken).getBytes(StandardCharsets.UTF_8));
Long size = redisTemplate.opsForSet().size(uidToAccessKeyStr);
if (size != null && size != 0) {
List<String> tokenInfoBoList = stringRedisTemplate.opsForSet().pop(uidToAccessKeyStr, size);
if (tokenInfoBoList != null) {
for (String accessTokenWithRefreshToken : tokenInfoBoList) {
String[] accessTokenWithRefreshTokenArr = accessTokenWithRefreshToken.split(StrUtil.COLON);
String accessTokenData = accessTokenWithRefreshTokenArr[0];
if (BooleanUtil.isTrue(stringRedisTemplate.hasKey(getAccessKey(accessTokenData)))) {
existsAccessTokensBytes.add(accessTokenWithRefreshToken.getBytes(StandardCharsets.UTF_8));
}
}
}
}
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
long expiresIn = tokenInfoBO.getExpiresIn();
byte[] uidKey = uidToAccessKeyStr.getBytes(StandardCharsets.UTF_8);
byte[] refreshKey = refreshToAccessKeyStr.getBytes(StandardCharsets.UTF_8);
byte[] accessKey = accessKeyStr.getBytes(StandardCharsets.UTF_8);
connection.sAdd(uidKey, ArrayUtil.toArray(existsAccessTokensBytes, byte[].class));
// 通过uid + sysType 保存access_token当需要禁用用户的时候可以根据uid + sysType 禁用用户
connection.expire(uidKey, expiresIn);
// 通过refresh_token获取用户的access_token从而刷新token
connection.setEx(refreshKey, expiresIn, accessToken.getBytes(StandardCharsets.UTF_8));
// 通过access_token保存用户的租户id用户iduid
connection.setEx(accessKey, expiresIn, Objects.requireNonNull(redisSerializer.serialize(userInfoInToken)));
return null;
});
// 返回给前端是加密的token
tokenInfoBO.setAccessToken(encryptToken(accessToken,userInfoInToken.getSysType()));
tokenInfoBO.setRefreshToken(encryptToken(refreshToken,userInfoInToken.getSysType()));
tokenInfoBO.setExpiresIn(timeoutSecond);
tokenInfoBO.setAccessToken(token);
tokenInfoBO.setRefreshToken(token);
return tokenInfoBO;
}
/**
* 计算过期时间(单位:秒)
* @param sysType
* @return
*/
private int getExpiresIn(int sysType) {
// 3600秒
int expiresIn = 3600;
// 普通用户token过期时间 1小时
if (Objects.equals(sysType, SysTypeEnum.ORDINARY.value())) {
expiresIn = expiresIn * 24 * 30;
@@ -160,20 +105,12 @@ public class TokenStore {
if (StrUtil.isBlank(accessToken)) {
throw new YamiShopBindException(ResponseEnum.UNAUTHORIZED,"accessToken is blank");
}
String realAccessToken;
if (needDecrypt) {
realAccessToken = decryptToken(accessToken);
String keyName = OauthCacheNames.USER_INFO + accessToken;
Object redisCache = redisTemplate.opsForValue().get(keyName);
if (redisCache == null) {
throw new YamiShopBindException(ResponseEnum.UNAUTHORIZED,"登录过期,请重新登录");
}
else {
realAccessToken = accessToken;
}
UserInfoInTokenBO userInfoInTokenBO = (UserInfoInTokenBO) redisTemplate.opsForValue()
.get(getAccessKey(realAccessToken));
if (userInfoInTokenBO == null) {
throw new YamiShopBindException(ResponseEnum.UNAUTHORIZED,"accessToken 已过期");
}
return userInfoInTokenBO;
return JSON.parseObject(redisCache.toString(), UserInfoInTokenBO.class);
}
/**
@@ -185,106 +122,43 @@ public class TokenStore {
if (StrUtil.isBlank(refreshToken)) {
throw new YamiShopBindException(ResponseEnum.UNAUTHORIZED,"refreshToken is blank");
}
String realRefreshToken = decryptToken(refreshToken);
String accessToken = stringRedisTemplate.opsForValue().get(getRefreshToAccessKey(realRefreshToken));
if (StrUtil.isBlank(accessToken)) {
throw new YamiShopBindException(ResponseEnum.UNAUTHORIZED,"refreshToken 已过期");
}
UserInfoInTokenBO userInfoInTokenBO = getUserInfoByAccessToken(accessToken,
false);
// 删除旧的refresh_token
stringRedisTemplate.delete(getRefreshToAccessKey(realRefreshToken));
// 删除旧的access_token
stringRedisTemplate.delete(getAccessKey(accessToken));
// 删除旧token
UserInfoInTokenBO userInfoInTokenBO = getUserInfoByAccessToken(refreshToken, false);
this.deleteCurrentToken(refreshToken);
// 保存一份新的token
return storeAccessToken(userInfoInTokenBO);
return storeAccessSaToken(userInfoInTokenBO);
}
/**
* 删除全部的token
* 删除指定用户的全部的token
*/
public void deleteAllToken(String sysType, String userId) {
String uidKey = getUserIdToAccessKey(getApprovalKey(sysType, userId));
Long size = redisTemplate.opsForSet().size(uidKey);
if (size == null || size == 0) {
return;
}
List<String> tokenInfoBoList = stringRedisTemplate.opsForSet().pop(uidKey, size);
if (CollUtil.isEmpty(tokenInfoBoList)) {
return;
}
for (String accessTokenWithRefreshToken : tokenInfoBoList) {
String[] accessTokenWithRefreshTokenArr = accessTokenWithRefreshToken.split(StrUtil.COLON);
String accessToken = accessTokenWithRefreshTokenArr[0];
String refreshToken = accessTokenWithRefreshTokenArr[1];
redisTemplate.delete(getRefreshToAccessKey(refreshToken));
redisTemplate.delete(getAccessKey(accessToken));
}
redisTemplate.delete(uidKey);
}
private static String getApprovalKey(UserInfoInTokenBO userInfoInToken) {
return getApprovalKey(userInfoInToken.getSysType().toString(), userInfoInToken.getUserId());
}
private static String getApprovalKey(String sysType, String userId) {
return userId == null? sysType : sysType + StrUtil.COLON + userId;
}
private String encryptToken(String accessToken,Integer sysType) {
AES aes = new AES(tokenSignKey.getBytes(StandardCharsets.UTF_8));
return aes.encryptBase64(accessToken + System.currentTimeMillis() + sysType);
}
private String decryptToken(String data) {
AES aes = new AES(tokenSignKey.getBytes(StandardCharsets.UTF_8));
String decryptStr;
String decryptToken;
try {
decryptStr = aes.decryptStr(data);
decryptToken = decryptStr.substring(0,32);
// 创建token的时间token使用时效性防止攻击者通过一堆的尝试找到aes的密码虽然aes是目前几乎最好的加密算法
long createTokenTime = Long.parseLong(decryptStr.substring(32,45));
// 系统类型
int sysType = Integer.parseInt(decryptStr.substring(45));
// token的过期时间
int expiresIn = getExpiresIn(sysType);
long second = 1000L;
if (System.currentTimeMillis() - createTokenTime > expiresIn * second) {
throw new YamiShopBindException(ResponseEnum.UNAUTHORIZED,"token error");
// 删除用户缓存
String uid = this.getUid(sysType, userId);
List<String> tokens = StpUtil.getTokenValueListByLoginId(uid);
if (!CollectionUtils.isEmpty(tokens)) {
List<String> keyNames = new ArrayList<>();
for (String token : tokens) {
keyNames.add(OauthCacheNames.USER_INFO + token);
}
redisTemplate.delete(keyNames);
}
catch (Exception e) {
throw new YamiShopBindException(ResponseEnum.UNAUTHORIZED,"token error");
}
// 防止解密后的token是脚本从而对redis进行攻击uuid只能是数字和小写字母
if (!PrincipalUtil.isSimpleChar(decryptToken)) {
throw new YamiShopBindException(ResponseEnum.UNAUTHORIZED,"token error");
}
return decryptToken;
}
public String getAccessKey(String accessToken) {
return OauthCacheNames.ACCESS + accessToken;
}
public String getUserIdToAccessKey(String approvalKey) {
return OauthCacheNames.UID_TO_ACCESS + approvalKey;
}
public String getRefreshToAccessKey(String refreshToken) {
return OauthCacheNames.REFRESH_TO_ACCESS + refreshToken;
// 移除token
StpUtil.logout(userId);
}
/**
* 生成token并返回token展示信息
* @param userInfoInToken
* @return
*/
public TokenInfoVO storeAndGetVo(UserInfoInTokenBO userInfoInToken) {
TokenInfoBO tokenInfoBO = storeAccessToken(userInfoInToken);
if (!userInfoInToken.getEnabled()){
// 用户已禁用,请联系客服
throw new YamiShopBindException("yami.user.disabled");
}
TokenInfoBO tokenInfoBO = storeAccessSaToken(userInfoInToken);
// 数据封装返回
TokenInfoVO tokenInfoVO = new TokenInfoVO();
tokenInfoVO.setAccessToken(tokenInfoBO.getAccessToken());
tokenInfoVO.setRefreshToken(tokenInfoBO.getRefreshToken());
@@ -292,41 +166,25 @@ public class TokenStore {
return tokenInfoVO;
}
/**
* 删除当前登录的token
* @param accessToken 令牌
*/
public void deleteCurrentToken(String accessToken) {
String decryptToken = decryptToken(accessToken);
// 删除用户缓存
String keyName = OauthCacheNames.USER_INFO + accessToken;
redisTemplate.delete(keyName);
// 移除token
StpUtil.logoutByTokenValue(accessToken);
}
UserInfoInTokenBO userInfoInToken = getUserInfoByAccessToken(accessToken, true);
String uidKey = getUserIdToAccessKey(getApprovalKey(userInfoInToken.getSysType().toString(), userInfoInToken.getUserId()));
Long size = redisTemplate.opsForSet().size(uidKey);
if (size == null || size == 0) {
return;
}
List<String> tokenInfoBoList = stringRedisTemplate.opsForSet().pop(uidKey, size);
if (CollUtil.isEmpty(tokenInfoBoList)) {
return;
}
String dbAccessToken = null;
String dbRefreshToken = null;
List<byte[]> list = new ArrayList<>();
for (String accessTokenWithRefreshToken : tokenInfoBoList) {
String[] accessTokenWithRefreshTokenArr = accessTokenWithRefreshToken.split(StrUtil.COLON);
dbAccessToken = accessTokenWithRefreshTokenArr[0];
if (decryptToken.equals(dbAccessToken)) {
dbRefreshToken = accessTokenWithRefreshTokenArr[1];
redisTemplate.delete(getRefreshToAccessKey(dbRefreshToken));
redisTemplate.delete(getAccessKey(dbAccessToken));
continue;
}
list.add(accessTokenWithRefreshToken.getBytes(StandardCharsets.UTF_8));
}
if (CollUtil.isNotEmpty(list)) {
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
connection.sAdd(uidKey.getBytes(StandardCharsets.UTF_8), ArrayUtil.toArray(list, byte[].class));
return null;
});
}
/**
* 生成各系统唯一uid
* @param sysType 系统类型
* @param userId 用户id
* @return
*/
private String getUid(String sysType, String userId) {
return sysType + ":" + userId;
}
}