新增单账号登录限制功能,限制后台账号(admin类型)在同一端(PC、APP、H5等)无法同时登录,功能开启在配置管理中有配置开关

This commit is contained in:
panda 2025-04-24 11:13:02 +08:00
parent bbc59e44d0
commit 308b0eb9cd
18 changed files with 310 additions and 22 deletions

11
sql/mysql/singlelogin.sql Normal file
View File

@ -0,0 +1,11 @@
-- by panda 25.04.23
-- 单账号登录新增字段
SET FOREIGN_KEY_CHECKS=0;
-- 登录来源
ALTER TABLE `ruoyi-vue-pro`.`system_oauth2_refresh_token` ADD COLUMN `login_from_type` int NULL DEFAULT NULL COMMENT '登录来源' AFTER `expires_time`;
-- 失效原因,用于判断通知前台便于准确提示
ALTER TABLE `ruoyi-vue-pro`.`system_oauth2_refresh_token` ADD COLUMN `failure_reason` int NULL DEFAULT NULL COMMENT '失效原因' AFTER `login_from_type`;
-- 配置表中新增单账号登录的开关
INSERT INTO `ruoyi-vue-pro`.`infra_config` (`id`, `category`, `type`, `name`, `config_key`, `value`, `visible`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (13, 'biz', 2, '单账户登录', 'oauth2.loginFromType.enable', 'true', b'1', '一个账号只能在某端一次登录有效,前者登录的会被后者踢掉', '1', '2025-04-23 15:36:35', '1', '2025-04-23 18:08:44', b'0');
SET FOREIGN_KEY_CHECKS = 1;

View File

@ -25,7 +25,7 @@ public interface GlobalErrorCodeConstants {
ErrorCode METHOD_NOT_ALLOWED = new ErrorCode(405, "请求方法不正确");
ErrorCode LOCKED = new ErrorCode(423, "请求失败,请稍后重试"); // 并发请求不允许
ErrorCode TOO_MANY_REQUESTS = new ErrorCode(429, "请求过于频繁,请稍后重试");
ErrorCode LOGIN_OUT_FOR_SINGLE = new ErrorCode(499, "该账号已在其他地方登录,被迫下线");
// ========== 服务端错误段 ==========
ErrorCode INTERNAL_SERVER_ERROR = new ErrorCode(500, "系统异常");

View File

@ -0,0 +1,52 @@
package cn.iocoder.yudao.module.system.enums.logger;
import cn.hutool.core.util.ArrayUtil;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
* 登录来源的类型枚举用来表示用户从那端登录用于单账号登录互踢
* 一个来源只能登录一次前者会被踢下
* 前提是在配置管理中开启了单账户登录
* by panda 25.04.23
*/
@Getter
@AllArgsConstructor
public enum LoginFromTypeEnum implements ArrayValuable<Integer> {
LOGIN_PC(100,"PC"), // 从PC端登录
LOGIN_APP(110,"APP"), // 从APP端登录
LOGIN_H5(120,"H5"), // 从H5端登录
LOGIN_MINI(130,"MINI"), // 从小程序登录
;
public static final Integer[] ARRAYS = Arrays.stream(values()).map(LoginFromTypeEnum::getType).toArray(Integer[]::new);
/**
* 登录来源类型
*/
private final Integer type;
private final String name;
@Override
public Integer[] array() {
return ARRAYS;
}
public static String getTypeName(Integer type) {
return Arrays.stream(values()).filter(item -> item.getType().equals(type)).findFirst().map(LoginFromTypeEnum::getName).orElse("");
}
public static LoginFromTypeEnum valueOf(Integer type) {
return ArrayUtil.firstMatch(item -> item.getType().equals(type),
values());
}
public static LoginFromTypeEnum getDefault() {
return LOGIN_PC;
}
}

View File

@ -0,0 +1,44 @@
package cn.iocoder.yudao.module.system.enums.logger;
import cn.hutool.core.util.ArrayUtil;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
* 登录退出的类型
* by panda 25.04.23
*/
@Getter
@AllArgsConstructor
public enum LoginOutReasonEnum implements ArrayValuable<Integer> {
LOGIN_SINGLE(100,"Single"), // 单账号登录被踢下线
LOGIN_OTHER(900,"other"), // 其他
;
public static final Integer[] ARRAYS = Arrays.stream(values()).map(LoginOutReasonEnum::getCode).toArray(Integer[]::new);
/**
* 登录来源类型
*/
private final Integer code;
private final String name;
@Override
public Integer[] array() {
return ARRAYS;
}
public static String getTypeName(Integer type) {
return Arrays.stream(values()).filter(item -> item.getCode().equals(type)).findFirst().map(LoginOutReasonEnum::getName).orElse("");
}
public static LoginOutReasonEnum valueOf(Integer type) {
return ArrayUtil.firstMatch(item -> item.getCode().equals(type),
values());
}
}

View File

@ -5,6 +5,7 @@ import cn.iocoder.yudao.module.system.api.oauth2.dto.OAuth2AccessTokenCheckRespD
import cn.iocoder.yudao.module.system.api.oauth2.dto.OAuth2AccessTokenCreateReqDTO;
import cn.iocoder.yudao.module.system.api.oauth2.dto.OAuth2AccessTokenRespDTO;
import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
import cn.iocoder.yudao.module.system.enums.logger.LoginFromTypeEnum;
import cn.iocoder.yudao.module.system.service.oauth2.OAuth2TokenService;
import org.springframework.stereotype.Service;
@ -24,7 +25,7 @@ public class OAuth2TokenApiImpl implements OAuth2TokenApi {
@Override
public OAuth2AccessTokenRespDTO createAccessToken(OAuth2AccessTokenCreateReqDTO reqDTO) {
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken(
reqDTO.getUserId(), reqDTO.getUserType(), reqDTO.getClientId(), reqDTO.getScopes());
reqDTO.getUserId(), reqDTO.getUserType(), reqDTO.getClientId(), reqDTO.getScopes(), LoginFromTypeEnum.LOGIN_PC);//auth 只存在PC端所以固定 by panda 25.04.23;
return BeanUtils.toBean(accessTokenDO, OAuth2AccessTokenRespDTO.class);
}

View File

@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.system.controller.admin.auth.vo;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.system.enums.logger.LoginFromTypeEnum;
import cn.iocoder.yudao.module.system.enums.social.SocialTypeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
@ -54,4 +55,8 @@ public class AuthLoginReqVO extends CaptchaVerificationReqVO {
return socialType == null || StrUtil.isNotEmpty(socialState);
}
// ========== 登录来源判断 by panda 25.04.23默认PC端==========
@Schema(description = "登录来源,参见 LoginFromTypeEnum 枚举", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@InEnum(LoginFromTypeEnum.class)
private Integer loginFromType;
}

View File

@ -1,6 +1,8 @@
package cn.iocoder.yudao.module.system.controller.admin.auth.vo;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.system.enums.logger.LoginFromTypeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
@ -26,4 +28,9 @@ public class AuthRegisterReqVO extends CaptchaVerificationReqVO {
@NotEmpty(message = "密码不能为空")
@Length(min = 4, max = 16, message = "密码长度为 4-16 位")
private String password;
// ========== 登录来源判断 by panda 25.04.23默认PC端==========
@Schema(description = "登录来源,参见 LoginFromTypeEnum 枚举", requiredMode = Schema.RequiredMode.REQUIRED, example = "1",defaultValue = "100")
@InEnum(LoginFromTypeEnum.class)
private Integer loginFromType;
}

View File

@ -1,6 +1,8 @@
package cn.iocoder.yudao.module.system.controller.admin.auth.vo;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.framework.common.validation.Mobile;
import cn.iocoder.yudao.module.system.enums.logger.LoginFromTypeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
@ -25,4 +27,8 @@ public class AuthSmsLoginReqVO {
@NotEmpty(message = "验证码不能为空")
private String code;
// ========== 登录来源判断 by panda 25.04.23默认PC端==========
@Schema(description = "登录来源,参见 LoginFromTypeEnum 枚举", requiredMode = Schema.RequiredMode.REQUIRED, example = "1",defaultValue = "100")
@InEnum(LoginFromTypeEnum.class)
private Integer loginFromType;
}

View File

@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.system.controller.admin.auth.vo;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.system.enums.logger.LoginFromTypeEnum;
import cn.iocoder.yudao.module.system.enums.social.SocialTypeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
@ -31,4 +32,8 @@ public class AuthSocialLoginReqVO {
@NotEmpty(message = "state 不能为空")
private String state;
// ========== 登录来源判断 by panda 25.04.23默认PC端==========
@Schema(description = "登录来源,参见 LoginFromTypeEnum 枚举", requiredMode = Schema.RequiredMode.REQUIRED, example = "1",defaultValue = "100")
@InEnum(LoginFromTypeEnum.class)
private Integer loginFromType;
}

View File

@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.system.dal.dataobject.oauth2;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import cn.iocoder.yudao.module.system.enums.logger.LoginFromTypeEnum;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
@ -60,4 +61,16 @@ public class OAuth2RefreshTokenDO extends TenantBaseDO {
*/
private LocalDateTime expiresTime;
/**
* 登录来源 by panda 25.04.23
*
* 枚举 {@link LoginFromTypeEnum}
*/
private Integer loginFromType;
/**
* 失效原因 by panda 25.04.23
*
* 枚举 {@link cn.iocoder.yudao.module.system.enums.logger.LoginOutReasonEnum}
*/
private Integer failureReason;
}

View File

@ -3,8 +3,10 @@ package cn.iocoder.yudao.module.system.dal.mysql.oauth2;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2RefreshTokenDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface OAuth2RefreshTokenMapper extends BaseMapperX<OAuth2RefreshTokenDO> {
@ -19,4 +21,12 @@ public interface OAuth2RefreshTokenMapper extends BaseMapperX<OAuth2RefreshToken
return selectOne(OAuth2RefreshTokenDO::getRefreshToken, refreshToken);
}
/**
* 查询被删除的刷新令牌用于校验被踢下线的reason
* by panda 25.04.25
* @param refreshToken 刷新token
* @return 刷新token信息
*/
@Select("SELECT * FROM system_oauth2_refresh_token WHERE refresh_token = #{refreshToken} AND deleted = 1")
OAuth2RefreshTokenDO selectHasDeleteByAccessToken(String refreshToken);
}

View File

@ -67,6 +67,17 @@ public interface RedisKeyConstants {
*/
String OAUTH2_ACCESS_TOKEN = "oauth2_access_token:%s";
/**
/**
* 访问令牌的缓存,用户登录来源
* <p>
* KEY 格式oauth2_login_from_type:{token}
* VALUE 数据类型String 访问令牌信息 {@link OAuth2AccessTokenDO}
* <p>
* 由于动态过期时间使用 RedisTemplate 操作
*/
String OAUTH2_LOGIN_FROM_TYPE = "oauth2_login_from_type:%s";
/**
* 站内信模版的缓存
* <p>

View File

@ -0,0 +1,49 @@
package cn.iocoder.yudao.module.system.dal.redis.oauth2;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
import jakarta.annotation.Resource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.TimeUnit;
import static cn.iocoder.yudao.module.system.dal.redis.RedisKeyConstants.OAUTH2_ACCESS_TOKEN;
import static cn.iocoder.yudao.module.system.dal.redis.RedisKeyConstants.OAUTH2_LOGIN_FROM_TYPE;
/**
* {@link OAuth2AccessTokenDO} RedisDAO
* 登录来源的缓存标记 by panda 25.04.23
* 1. 缓存标记的key为OAUTH2_LOGIN_FROM_TYPE userId + "_" + loginFromTypeName
* @author 芋道源码
*/
@Repository
public class OAuth2LoginFromTypeRedisDAO {
@Resource
private StringRedisTemplate stringRedisTemplate;
public String get(String loginFromTypeName, OAuth2AccessTokenDO accessTokenDO) {
String redisKey = formatKey(accessTokenDO.getUserId()+"_"+loginFromTypeName);
return stringRedisTemplate.opsForValue().get(redisKey);
}
public void set(String loginFromTypeName, OAuth2AccessTokenDO accessTokenDO) {
String redisKey = formatKey(accessTokenDO.getUserId()+"_"+loginFromTypeName);
long time = LocalDateTimeUtil.between(LocalDateTime.now(), accessTokenDO.getExpiresTime(), ChronoUnit.SECONDS);
if (time > 0) {
stringRedisTemplate.opsForValue().set(redisKey, accessTokenDO.getAccessToken(), time, TimeUnit.SECONDS);
}
}
private static String formatKey(String userIdLoginFromTypeName) {
return String.format(OAUTH2_LOGIN_FROM_TYPE, userIdLoginFromTypeName);
}
}

View File

@ -15,6 +15,7 @@ import cn.iocoder.yudao.module.system.controller.admin.auth.vo.*;
import cn.iocoder.yudao.module.system.convert.auth.AuthConvert;
import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
import cn.iocoder.yudao.module.system.enums.logger.LoginFromTypeEnum;
import cn.iocoder.yudao.module.system.enums.logger.LoginLogTypeEnum;
import cn.iocoder.yudao.module.system.enums.logger.LoginResultEnum;
import cn.iocoder.yudao.module.system.enums.oauth2.OAuth2ClientConstants;
@ -110,7 +111,7 @@ public class AdminAuthServiceImpl implements AdminAuthService {
reqVO.getSocialType(), reqVO.getSocialCode(), reqVO.getSocialState()));
}
// 创建 Token 令牌记录登录日志
return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME);
return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME,LoginFromTypeEnum.valueOf(reqVO.getLoginFromType()));
}
@Override
@ -143,7 +144,7 @@ public class AdminAuthServiceImpl implements AdminAuthService {
}
// 创建 Token 令牌记录登录日志
return createTokenAfterLoginSuccess(user.getId(), reqVO.getMobile(), LoginLogTypeEnum.LOGIN_MOBILE);
return createTokenAfterLoginSuccess(user.getId(), reqVO.getMobile(), LoginLogTypeEnum.LOGIN_MOBILE,LoginFromTypeEnum.valueOf(reqVO.getLoginFromType()));
}
private void createLoginLog(Long userId, String username,
@ -181,7 +182,7 @@ public class AdminAuthServiceImpl implements AdminAuthService {
}
// 创建 Token 令牌记录登录日志
return createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL);
return createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL,LoginFromTypeEnum.valueOf(reqVO.getLoginFromType()));
}
@VisibleForTesting
@ -206,12 +207,12 @@ public class AdminAuthServiceImpl implements AdminAuthService {
return captchaService.verification(captchaVO);
}
private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, LoginLogTypeEnum logType) {
private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, LoginLogTypeEnum logType,LoginFromTypeEnum loginFromType) {
// 插入登陆日志
createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS);
// 创建访问令牌
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken(userId, getUserType().getValue(),
OAuth2ClientConstants.CLIENT_ID_DEFAULT, null);
OAuth2ClientConstants.CLIENT_ID_DEFAULT, null,loginFromType);
// 构建返回结果
return AuthConvert.INSTANCE.convert(accessTokenDO);
}
@ -271,7 +272,7 @@ public class AdminAuthServiceImpl implements AdminAuthService {
Long userId = userService.registerUser(registerReqVO);
// 3. 创建 Token 令牌记录登录日志
return createTokenAfterLoginSuccess(userId, registerReqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME);
return createTokenAfterLoginSuccess(userId, registerReqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME,LoginFromTypeEnum.valueOf(registerReqVO.getLoginFromType()));
}
@VisibleForTesting

View File

@ -8,6 +8,7 @@ import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2CodeDO;
import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
import cn.iocoder.yudao.module.system.enums.ErrorCodeConstants;
import cn.iocoder.yudao.module.system.enums.logger.LoginFromTypeEnum;
import cn.iocoder.yudao.module.system.service.auth.AdminAuthService;
import org.springframework.stereotype.Service;
@ -34,7 +35,7 @@ public class OAuth2GrantServiceImpl implements OAuth2GrantService {
@Override
public OAuth2AccessTokenDO grantImplicit(Long userId, Integer userType,
String clientId, List<String> scopes) {
return oauth2TokenService.createAccessToken(userId, userType, clientId, scopes);
return oauth2TokenService.createAccessToken(userId, userType, clientId, scopes, LoginFromTypeEnum.LOGIN_PC);//auth 只存在PC端所以固定 by panda 25.04.23
}
@Override
@ -66,7 +67,7 @@ public class OAuth2GrantServiceImpl implements OAuth2GrantService {
// 创建访问令牌
return oauth2TokenService.createAccessToken(codeDO.getUserId(), codeDO.getUserType(),
codeDO.getClientId(), codeDO.getScopes());
codeDO.getClientId(), codeDO.getScopes(), LoginFromTypeEnum.LOGIN_PC);//auth 只存在PC端所以固定 by panda 25.04.23;
}
@Override
@ -76,7 +77,7 @@ public class OAuth2GrantServiceImpl implements OAuth2GrantService {
Assert.notNull(user, "用户不能为空!"); // 防御性编程
// 创建访问令牌
return oauth2TokenService.createAccessToken(user.getId(), UserTypeEnum.ADMIN.getValue(), clientId, scopes);
return oauth2TokenService.createAccessToken(user.getId(), UserTypeEnum.ADMIN.getValue(), clientId, scopes, LoginFromTypeEnum.LOGIN_PC);//auth 只存在PC端所以固定 by panda 25.04.23;
}
@Override

View File

@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.system.service.oauth2;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.token.OAuth2AccessTokenPageReqVO;
import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
import cn.iocoder.yudao.module.system.enums.logger.LoginFromTypeEnum;
import java.util.List;
@ -25,9 +26,10 @@ public interface OAuth2TokenService {
* @param userType 用户类型
* @param clientId 客户端编号
* @param scopes 授权范围
* @param loginFromType 登录来源 by panda 25.04.23
* @return 访问令牌的信息
*/
OAuth2AccessTokenDO createAccessToken(Long userId, Integer userType, String clientId, List<String> scopes);
OAuth2AccessTokenDO createAccessToken(Long userId, Integer userType, String clientId, List<String> scopes, LoginFromTypeEnum loginFromType);
/**
* 刷新访问令牌

View File

@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.system.service.oauth2;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.ObjectUtil;
@ -10,9 +11,11 @@ import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstant
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.date.DateUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.common.util.spring.SpringUtils;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import cn.iocoder.yudao.module.infra.api.config.ConfigApi;
import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.token.OAuth2AccessTokenPageReqVO;
import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2ClientDO;
@ -21,6 +24,9 @@ import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
import cn.iocoder.yudao.module.system.dal.mysql.oauth2.OAuth2AccessTokenMapper;
import cn.iocoder.yudao.module.system.dal.mysql.oauth2.OAuth2RefreshTokenMapper;
import cn.iocoder.yudao.module.system.dal.redis.oauth2.OAuth2AccessTokenRedisDAO;
import cn.iocoder.yudao.module.system.dal.redis.oauth2.OAuth2LoginFromTypeRedisDAO;
import cn.iocoder.yudao.module.system.enums.logger.LoginFromTypeEnum;
import cn.iocoder.yudao.module.system.enums.logger.LoginOutReasonEnum;
import cn.iocoder.yudao.module.system.service.user.AdminUserService;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
@ -32,6 +38,8 @@ import java.util.Collections;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception0;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
@ -57,12 +65,28 @@ public class OAuth2TokenServiceImpl implements OAuth2TokenService {
@Lazy // 懒加载避免循环依赖
private AdminUserService adminUserService;
@Resource
private ConfigApi configApi;
@Resource
private OAuth2LoginFromTypeRedisDAO oAuth2LoginFromTypeRedisDAO;
/**
* 创建访问令牌新增了LoginFromType用来做互踢
*
* @param userId 用户编号
* @param userType 用户类型
* @param clientId 客户端编号
* @param scopes 授权范围
* @param loginFromType 登录来源 by panda 25.04.23
* @return 访问令牌
*/
@Override
@Transactional(rollbackFor = Exception.class)
public OAuth2AccessTokenDO createAccessToken(Long userId, Integer userType, String clientId, List<String> scopes) {
public OAuth2AccessTokenDO createAccessToken(Long userId, Integer userType, String clientId, List<String> scopes, LoginFromTypeEnum loginFromType) {
OAuth2ClientDO clientDO = oauth2ClientService.validOAuthClientFromCache(clientId);
// 创建刷新令牌
OAuth2RefreshTokenDO refreshTokenDO = createOAuth2RefreshToken(userId, userType, clientDO, scopes);
OAuth2RefreshTokenDO refreshTokenDO = createOAuth2RefreshToken(userId, userType, clientDO, scopes, loginFromType);
// 创建访问令牌
return createOAuth2AccessToken(refreshTokenDO, clientDO);
}
@ -70,8 +94,14 @@ public class OAuth2TokenServiceImpl implements OAuth2TokenService {
@Override
@Transactional(rollbackFor = Exception.class)
public OAuth2AccessTokenDO refreshAccessToken(String refreshToken, String clientId) {
// 对踢下线的做处理 by panda 25.04.23
OAuth2RefreshTokenDO refreshTokenDO = oauth2RefreshTokenMapper.selectHasDeleteByAccessToken(refreshToken);
if (refreshTokenDO != null && ObjectUtil.equals(refreshTokenDO.getFailureReason(), LoginOutReasonEnum.LOGIN_SINGLE.getCode())) {
throw exception(GlobalErrorCodeConstants.LOGIN_OUT_FOR_SINGLE);//被迫下线
}
// 查询访问令牌
OAuth2RefreshTokenDO refreshTokenDO = oauth2RefreshTokenMapper.selectByRefreshToken(refreshToken);
refreshTokenDO = oauth2RefreshTokenMapper.selectByRefreshToken(refreshToken);
if (refreshTokenDO == null) {
throw exception0(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), "无效的刷新令牌");
}
@ -166,16 +196,19 @@ public class OAuth2TokenServiceImpl implements OAuth2TokenService {
.setRefreshToken(refreshTokenDO.getRefreshToken())
.setExpiresTime(LocalDateTime.now().plusSeconds(clientDO.getAccessTokenValiditySeconds()));
accessTokenDO.setTenantId(TenantContextHolder.getTenantId()); // 手动设置租户编号避免缓存到 Redis 的时候无对应的租户编号
//校验用户的token互踢操作by panda 25.04.23
checkLoginFromType(refreshTokenDO.getLoginFromType(), accessTokenDO);
oauth2AccessTokenMapper.insert(accessTokenDO);
// 记录到 Redis
oauth2AccessTokenRedisDAO.set(accessTokenDO);
return accessTokenDO;
}
private OAuth2RefreshTokenDO createOAuth2RefreshToken(Long userId, Integer userType, OAuth2ClientDO clientDO, List<String> scopes) {
private OAuth2RefreshTokenDO createOAuth2RefreshToken(Long userId, Integer userType, OAuth2ClientDO clientDO, List<String> scopes, LoginFromTypeEnum loginFromType) {
OAuth2RefreshTokenDO refreshToken = new OAuth2RefreshTokenDO().setRefreshToken(generateRefreshToken())
.setUserId(userId).setUserType(userType)
.setClientId(clientDO.getClientId()).setScopes(scopes)
.setLoginFromType(ObjectUtil.isEmpty(loginFromType) ? LoginFromTypeEnum.getDefault().getType() : loginFromType.getType())//by panda 25.04.23 添加登录来源用来互踢,默认为PC
.setExpiresTime(LocalDateTime.now().plusSeconds(clientDO.getRefreshTokenValiditySeconds()));
oauth2RefreshTokenMapper.insert(refreshToken);
return refreshToken;
@ -216,4 +249,40 @@ public class OAuth2TokenServiceImpl implements OAuth2TokenService {
return IdUtil.fastSimpleUUID();
}
/**
* 校验登录来源用来做互踢
* 1.开启互踢功能
* 2.当前用户在loginFromType 上是否已经存在token
* 3.如果存在踢掉之前的
* 4.更新失效原因用于刷新token是判断是否是被踢下线便于返回前端做正确提示
* 5.更新当前登录来源的缓存
*
* @param loginFromType 登录来源 by panda 25.04.23
* 枚举 {@link LoginFromTypeEnum}
* @param accessTokenDO 最新的访问令牌取里面的用户来判断同时缓存
*/
private void checkLoginFromType(Integer loginFromType, OAuth2AccessTokenDO accessTokenDO) {
// 是否开启了互踢
if (ObjectUtil.equal(Convert.toBool(configApi.getConfigValueByKey("oauth2.loginFromType.enable")), true)) {
//当前用户在loginFromType 上是否已经存在token
String loginTypeToken = oAuth2LoginFromTypeRedisDAO.get(LoginFromTypeEnum.getTypeName(loginFromType), accessTokenDO);
//校验下当前缓存的token是否真实存在否则就不一致,这种情况出现在先开启单账户功能使用后关闭了然后使用但在redis有效期内再开启时redis中有缓存(概率很小但是还是考虑进去否则异常)
OAuth2AccessTokenDO oldAccessTokenDO = getAccessToken(loginTypeToken);
if (ObjectUtil.isEmpty(oldAccessTokenDO)) {
//没有登录过直接保存
oAuth2LoginFromTypeRedisDAO.set(LoginFromTypeEnum.getTypeName(loginFromType), accessTokenDO);
} else {
//先查出老的token信息一定要先更新后删除否则先删除了就查不到了
OAuth2RefreshTokenDO oldRefreshTokenDo = oauth2RefreshTokenMapper.selectByRefreshToken(oldAccessTokenDO.getRefreshToken());
//更新失效原因用于刷新token时判断是否是被踢下线便于返回前端做正确提示
if (ObjectUtil.isNotEmpty(oldRefreshTokenDo))
oauth2RefreshTokenMapper.updateById(oldRefreshTokenDo.setFailureReason(LoginOutReasonEnum.LOGIN_SINGLE.getCode()));
//删除token让其失效达到踢下线
SpringUtils.getBean(OAuth2TokenService.class).removeAccessToken(loginTypeToken);
//更新当前登录来源的缓存
oAuth2LoginFromTypeRedisDAO.set(LoginFromTypeEnum.getTypeName(loginFromType), accessTokenDO);
}
}
}
}

View File

@ -10,6 +10,7 @@ import cn.iocoder.yudao.module.system.api.social.dto.SocialUserRespDTO;
import cn.iocoder.yudao.module.system.controller.admin.auth.vo.*;
import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
import cn.iocoder.yudao.module.system.enums.logger.LoginFromTypeEnum;
import cn.iocoder.yudao.module.system.enums.logger.LoginLogTypeEnum;
import cn.iocoder.yudao.module.system.enums.logger.LoginResultEnum;
import cn.iocoder.yudao.module.system.enums.sms.SmsSceneEnum;
@ -164,7 +165,7 @@ public class AdminAuthServiceImplTest extends BaseDbUnitTest {
// mock 缓存登录用户到 Redis
OAuth2AccessTokenDO accessTokenDO = randomPojo(OAuth2AccessTokenDO.class, o -> o.setUserId(1L)
.setUserType(UserTypeEnum.ADMIN.getValue()));
when(oauth2TokenService.createAccessToken(eq(1L), eq(UserTypeEnum.ADMIN.getValue()), eq("default"), isNull()))
when(oauth2TokenService.createAccessToken(eq(1L), eq(UserTypeEnum.ADMIN.getValue()), eq("default"), isNull(),eq(LoginFromTypeEnum.LOGIN_PC)))
.thenReturn(accessTokenDO);
// 调用并校验
@ -206,7 +207,7 @@ public class AdminAuthServiceImplTest extends BaseDbUnitTest {
// 准备参数
String mobile = randomString();
String code = randomString();
AuthSmsLoginReqVO reqVO = new AuthSmsLoginReqVO(mobile, code);
AuthSmsLoginReqVO reqVO = new AuthSmsLoginReqVO(mobile, code,LoginFromTypeEnum.LOGIN_PC.getType());
// mock 方法验证码
doNothing().when(smsCodeApi).useSmsCode((argThat(smsCodeUseReqDTO -> {
assertEquals(mobile, smsCodeUseReqDTO.getMobile());
@ -220,7 +221,7 @@ public class AdminAuthServiceImplTest extends BaseDbUnitTest {
// mock 缓存登录用户到 Redis
OAuth2AccessTokenDO accessTokenDO = randomPojo(OAuth2AccessTokenDO.class, o -> o.setUserId(1L)
.setUserType(UserTypeEnum.ADMIN.getValue()));
when(oauth2TokenService.createAccessToken(eq(1L), eq(UserTypeEnum.ADMIN.getValue()), eq("default"), isNull()))
when(oauth2TokenService.createAccessToken(eq(1L), eq(UserTypeEnum.ADMIN.getValue()), eq("default"), isNull(),eq(LoginFromTypeEnum.LOGIN_PC)))
.thenReturn(accessTokenDO);
// 调用并断言
@ -248,7 +249,7 @@ public class AdminAuthServiceImplTest extends BaseDbUnitTest {
// mock 缓存登录用户到 Redis
OAuth2AccessTokenDO accessTokenDO = randomPojo(OAuth2AccessTokenDO.class, o -> o.setUserId(1L)
.setUserType(UserTypeEnum.ADMIN.getValue()));
when(oauth2TokenService.createAccessToken(eq(1L), eq(UserTypeEnum.ADMIN.getValue()), eq("default"), isNull()))
when(oauth2TokenService.createAccessToken(eq(1L), eq(UserTypeEnum.ADMIN.getValue()), eq("default"), isNull(),eq(LoginFromTypeEnum.LOGIN_PC)))
.thenReturn(accessTokenDO);
// 调用并断言