新增单账号登录限制功能,限制后台账号(admin类型)在同一端(PC、APP、H5等)无法同时登录,功能开启在配置管理中有配置开关
This commit is contained in:
parent
bbc59e44d0
commit
308b0eb9cd
|
@ -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;
|
|
@ -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, "系统异常");
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
||||
/**
|
||||
* 刷新访问令牌
|
||||
|
|
|
@ -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;
|
||||
|
@ -185,14 +218,14 @@ public class OAuth2TokenServiceImpl implements OAuth2TokenService {
|
|||
OAuth2AccessTokenDO accessTokenDO = BeanUtils.toBean(refreshTokenDO, OAuth2AccessTokenDO.class)
|
||||
.setAccessToken(refreshTokenDO.getRefreshToken());
|
||||
TenantUtils.execute(refreshTokenDO.getTenantId(),
|
||||
() -> accessTokenDO.setUserInfo(buildUserInfo(refreshTokenDO.getUserId(), refreshTokenDO.getUserType())));
|
||||
() -> accessTokenDO.setUserInfo(buildUserInfo(refreshTokenDO.getUserId(), refreshTokenDO.getUserType())));
|
||||
return accessTokenDO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载用户信息,方便 {@link cn.iocoder.yudao.framework.security.core.LoginUser} 获取到昵称、部门等信息
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @param userId 用户编号
|
||||
* @param userType 用户类型
|
||||
* @return 用户信息
|
||||
*/
|
||||
|
@ -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);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
// 调用,并断言
|
||||
|
|
Loading…
Reference in New Issue