基于Ruoyi的同一token跨系统访问,后端单点登录并且鉴权方案
- 需求场景以及先决条件
- 默认方案
- 改造思路
- 改造代码,一共4个类需要变更
- 完整需要修改的代码
需求场景以及先决条件
- 同一环境下的多个ruoyi项目,各自使用相同的一组用户(我这里用的是LDAP的登录,不影响本文),但是每个权限拥有各自项目的权限.
- 希望一个前端登录的token,可以跨越不同的后端同时使用,即一个token访问所有系统,每个系统的权限,系统内部自行判断.
默认方案
- 默认情况下,ruoyi框架的设计是使用redis存储一个uuid作为token的key,用户信息作为value,存入到redis中的,例如
login_tokens:27e3c1ed-dee7-4495-99c7-d175beb4f0b1
{“@type”:“com.ruoyi.framework.security.LoginUser”,“browser”:“Firefox 13”,“deptId”:100,“expireTime”:1731988245275,“ipaddr”:“127.0.0.1”,“loginLocation”:“内网IP”,“loginTime”:1731984645275,“os”:“Windows 10”,“permissions”:Set[“:😗”],“token”:“27e3c1ed-dee7-4495-99c7-d175beb4f0b1”,“…省略以下”}
- 且每一个项目使用一个redis的db,互相独立
- 前端持有的jwt,是以uuid生成的令牌,即
Bearer eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6IjI3ZTNjMWVkLWRlZTctNDQ5NS05OWM3LWQxNzViZWI0ZjBiMSJ9.Ec0a7hRsyAL2ehZfiXWox0aczb58rKpARVipWfhbuir6sPrNsCArbbF47zDA2Mtmf1CHBdyodJx1bsWZrjLiKw
等价于
login_tokens:27e3c1ed-dee7-4495-99c7-d175beb4f0b1
改造思路
- 使用一个db,例如db0,所有的项目都多建立一个redis连接,连到db0.实现redis的部分共享
- 读取到token存在,即认为用户已登录
- 读取对应的程序的token,如果不存在,就是系统内部登录,创建一个对应token.
- 示例如下,指示一个token: 27e… 同时登录到了report和spc_mini2个系统,第一个key存储的值是username,其他的都是自己用的user信息,即权限信息等; 3和7是各自系统的其他redis缓存
改造代码,一共4个类需要变更
- 新建数据源RedisCacheUser
- 配置数据源在RedisConfig
@Bean
@SuppressWarnings(value = { "unchecked", "rawtypes" })
public RedisTemplate<String, Object> redisTemplateDb0(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
// 使用原工厂配置,创建一个新的,但是指向其他的db
LettuceConnectionFactory originalLettuceFactory = (LettuceConnectionFactory) connectionFactory;
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName(originalLettuceFactory.getHostName());
redisStandaloneConfiguration.setPort(originalLettuceFactory.getPort());
redisStandaloneConfiguration.setPassword(originalLettuceFactory.getPassword());
LettuceConnectionFactory clonedFactory = new LettuceConnectionFactory(redisStandaloneConfiguration);
clonedFactory.setDatabase(0);
clonedFactory.afterPropertiesSet();
template.setConnectionFactory(clonedFactory);
FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer(Object.class);
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
serializer.setObjectMapper(mapper);
// 使用StringRedisSerializer来序列化和反序列化redis的key值
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
// Hash的key也采用StringRedisSerializer的序列化方式
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
- 修改登录和获取用户的方式,就是存入到redis的key
/**
* 获取用户身份信息
*
* @return 用户信息
*/
public LoginUser getLoginUser(HttpServletRequest request) {
// 获取请求携带的令牌
String token = getToken(request);
if (StringUtils.isNotEmpty(token)) {
try {
Claims claims = parseToken(token);
// 解析对应的权限以及用户信息
String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
String userKey = getTokenKey(uuid);
LoginUser user = redisCache.getCacheObject(userKey);
if (user == null)
{
String userKeyUser = getTokenUser(uuid);
String username = redisCache.getCacheObject(userKeyUser);
user = (LoginUser) userDetailsService.loadUserByUsername(username);
user.setToken(uuid);
setUserAgent(user);
refreshToken(user);
}
return user;
} catch (Exception e) {
}
}
return null;
}
完整需要修改的代码
package com.ruoyi.framework.config;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
/**
* redis配置
*
* @author ruoyi
*/
@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport
{
@Bean
@Primary
@SuppressWarnings(value = { "unchecked", "rawtypes" })
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory)
{
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer(Object.class);
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
serializer.setObjectMapper(mapper);
// 使用StringRedisSerializer来序列化和反序列化redis的key值
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
// Hash的key也采用StringRedisSerializer的序列化方式
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
@Bean
public DefaultRedisScript<Long> limitScript()
{
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(limitScriptText());
redisScript.setResultType(Long.class);
return redisScript;
}
/**
* 限流脚本
*/
private String limitScriptText()
{
return "local key = KEYS[1]\n" +
"local count = tonumber(ARGV[1])\n" +
"local time = tonumber(ARGV[2])\n" +
"local current = redis.call('get', key);\n" +
"if current and tonumber(current) > count then\n" +
" return tonumber(current);\n" +
"end\n" +
"current = redis.call('incr', key)\n" +
"if tonumber(current) == 1 then\n" +
" redis.call('expire', key, time)\n" +
"end\n" +
"return tonumber(current);";
}
@Bean
@SuppressWarnings(value = { "unchecked", "rawtypes" })
public RedisTemplate<String, Object> redisTemplateDb0(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
// 使用原工厂配置,创建一个新的,但是指向其他的db
LettuceConnectionFactory originalLettuceFactory = (LettuceConnectionFactory) connectionFactory;
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName(originalLettuceFactory.getHostName());
redisStandaloneConfiguration.setPort(originalLettuceFactory.getPort());
redisStandaloneConfiguration.setPassword(originalLettuceFactory.getPassword());
LettuceConnectionFactory clonedFactory = new LettuceConnectionFactory(redisStandaloneConfiguration);
clonedFactory.setDatabase(0);
clonedFactory.afterPropertiesSet();
template.setConnectionFactory(clonedFactory);
FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer(Object.class);
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
serializer.setObjectMapper(mapper);
// 使用StringRedisSerializer来序列化和反序列化redis的key值
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
// Hash的key也采用StringRedisSerializer的序列化方式
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
}
package com.ruoyi.framework.redis;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.BoundSetOperations;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* spring redis 工具类
*
* @author ruoyi
**/
@SuppressWarnings(value = { "unchecked", "rawtypes" })
@Component
public class RedisCacheUser
{
@Autowired
@Qualifier("redisTemplateDb0")
public RedisTemplate redisTemplate;
/**
* 缓存基本的对象,Integer、String、实体类等
*
* @param key 缓存的键值
* @param value 缓存的值
*/
public <T> void setCacheObject(final String key, final T value)
{
redisTemplate.opsForValue().set(key, value);
}
/**
* 缓存基本的对象,Integer、String、实体类等
*
* @param key 缓存的键值
* @param value 缓存的值
* @param timeout 时间
* @param timeUnit 时间颗粒度
*/
public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit)
{
redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
}
/**
* 设置有效时间
*
* @param key Redis键
* @param timeout 超时时间
* @return true=设置成功;false=设置失败
*/
public boolean expire(final String key, final long timeout)
{
return expire(key, timeout, TimeUnit.SECONDS);
}
/**
* 设置有效时间
*
* @param key Redis键
* @param timeout 超时时间
* @param unit 时间单位
* @return true=设置成功;false=设置失败
*/
public boolean expire(final String key, final long timeout, final TimeUnit unit)
{
return redisTemplate.expire(key, timeout, unit);
}
/**
* 获得缓存的基本对象。
*
* @param key 缓存键值
* @return 缓存键值对应的数据
*/
public <T> T getCacheObject(final String key)
{
ValueOperations<String, T> operation = redisTemplate.opsForValue();
return operation.get(key);
}
/**
* 删除单个对象
*
* @param key
*/
public boolean deleteObject(final String key)
{
return redisTemplate.delete(key);
}
/**
* 删除集合对象
*
* @param collection 多个对象
* @return
*/
public long deleteObject(final Collection collection)
{
return redisTemplate.delete(collection);
}
/**
* 缓存List数据
*
* @param key 缓存的键值
* @param values 待缓存的List数据
* @return 缓存的对象
*/
public <T> long setCacheList(final String key, final List<T> dataList)
{
Long count = redisTemplate.opsForList().rightPushAll(key, dataList);
return count == null ? 0 : count;
}
/**
* 获得缓存的list对象
*
* @param key 缓存的键值
* @return 缓存键值对应的数据
*/
public <T> List<T> getCacheList(final String key)
{
return redisTemplate.opsForList().range(key, 0, -1);
}
/**
* 缓存Set
*
* @param key 缓存键值
* @param dataSet 缓存的数据
* @return 缓存数据的对象
*/
public <T> BoundSetOperations<String, T> setCacheSet(final String key, final Set<T> dataSet)
{
BoundSetOperations<String, T> setOperation = redisTemplate.boundSetOps(key);
Iterator<T> it = dataSet.iterator();
while (it.hasNext())
{
setOperation.add(it.next());
}
return setOperation;
}
/**
* 获得缓存的set
*
* @param key
* @return
*/
public <T> Set<T> getCacheSet(final String key)
{
return redisTemplate.opsForSet().members(key);
}
/**
* 缓存Map
*
* @param key
* @param dataMap
*/
public <T> void setCacheMap(final String key, final Map<String, T> dataMap)
{
if (dataMap != null) {
redisTemplate.opsForHash().putAll(key, dataMap);
}
}
/**
* 获得缓存的Map
*
* @param key
* @return
*/
public <T> Map<String, T> getCacheMap(final String key)
{
return redisTemplate.opsForHash().entries(key);
}
/**
* 往Hash中存入数据
*
* @param key Redis键
* @param hKey Hash键
* @param value 值
*/
public <T> void setCacheMapValue(final String key, final String hKey, final T value)
{
redisTemplate.opsForHash().put(key, hKey, value);
}
/**
* 获取Hash中的数据
*
* @param key Redis键
* @param hKey Hash键
* @return Hash中的对象
*/
public <T> T getCacheMapValue(final String key, final String hKey)
{
HashOperations<String, String, T> opsForHash = redisTemplate.opsForHash();
return opsForHash.get(key, hKey);
}
/**
* 获取多个Hash中的数据
*
* @param key Redis键
* @param hKeys Hash键集合
* @return Hash对象集合
*/
public <T> List<T> getMultiCacheMapValue(final String key, final Collection<Object> hKeys)
{
return redisTemplate.opsForHash().multiGet(key, hKeys);
}
/**
* 获得缓存的基本对象列表
*
* @param pattern 字符串前缀
* @return 对象列表
*/
public Collection<String> keys(final String pattern)
{
return redisTemplate.keys(pattern);
}
}
package com.ruoyi.framework.security.service;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import javax.servlet.http.HttpServletRequest;
import com.ruoyi.framework.redis.RedisCacheUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.utils.ServletUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.ip.AddressUtils;
import com.ruoyi.common.utils.ip.IpUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
import com.ruoyi.framework.redis.RedisCache;
import com.ruoyi.framework.security.LoginUser;
import eu.bitwalker.useragentutils.UserAgent;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
/**
* token验证处理
*
* @author ruoyi
*/
@Component
public class TokenService
{
// 令牌自定义标识
@Value("${token.header}")
private String header;
// 令牌秘钥
@Value("${token.secret}")
private String secret;
// 令牌有效期(默认30分钟)
@Value("${token.ExpireTime}")
private int ExpireTime;
@Value("${ruoyi.name}")
private String projectName;
protected static final long MILLIS_SECOND = 1000;
protected static final long MILLIS_MINUTE = 60 * MILLIS_SECOND;
private static final Long MILLIS_MINUTE_TEN = 20 * 60 * 1000L;
@Autowired
private RedisCacheUser redisCache;
@Autowired
private UserDetailsService userDetailsService;
/**
* 获取用户身份信息
*
* @return 用户信息
*/
public LoginUser getLoginUser(HttpServletRequest request) {
// 获取请求携带的令牌
String token = getToken(request);
if (StringUtils.isNotEmpty(token)) {
try {
Claims claims = parseToken(token);
// 解析对应的权限以及用户信息
String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
String userKey = getTokenKey(uuid);
LoginUser user = redisCache.getCacheObject(userKey);
if (user == null)
{
String userKeyUser = getTokenUser(uuid);
String username = redisCache.getCacheObject(userKeyUser);
user = (LoginUser) userDetailsService.loadUserByUsername(username);
user.setToken(uuid);
setUserAgent(user);
refreshToken(user);
}
return user;
} catch (Exception e) {
}
}
return null;
}
/**
* 设置用户身份信息
*/
public void setLoginUser(LoginUser loginUser) {
if (StringUtils.isNotNull(loginUser) && StringUtils.isNotEmpty(loginUser.getToken())) {
refreshToken(loginUser);
}
}
/**
* 删除用户身份信息
*/
public void delLoginUser(String token) {
if (StringUtils.isNotEmpty(token)) {
String userKey = getTokenKey(token);
redisCache.deleteObject(userKey);
}
}
/**
* 创建令牌
*
* @param loginUser 用户信息
* @return 令牌
*/
public String createToken(LoginUser loginUser) {
String token = IdUtils.fastUUID();
loginUser.setToken(token);
setUserAgent(loginUser);
refreshToken(loginUser);
Map<String, Object> claims = new HashMap<>();
claims.put(Constants.LOGIN_USER_KEY, token);
return createToken(claims);
}
/**
* 验证令牌有效期,相差不足20分钟,自动刷新缓存
*
* @param token 令牌
* @return 令牌
*/
public void verifyToken(LoginUser loginUser) {
long ExpireTime = loginUser.getExpireTime();
long currentTime = System.currentTimeMillis();
if (ExpireTime - currentTime <= MILLIS_MINUTE_TEN) {
refreshToken(loginUser);
}
}
/**
* 刷新令牌有效期
*
* @param loginUser 登录信息
*/
public void refreshToken(LoginUser loginUser) {
loginUser.setLoginTime(System.currentTimeMillis());
loginUser.setExpireTime(loginUser.getLoginTime() + ExpireTime * MILLIS_MINUTE);
// 根据uuid将loginUser缓存
String userKey = getTokenKey(loginUser.getToken());
redisCache.setCacheObject(userKey, loginUser, ExpireTime, TimeUnit.MINUTES);
//再保存一个key,来存储用户名称
String userKeyUser = getTokenUser(loginUser.getToken());
redisCache.setCacheObject(userKeyUser, loginUser.getUsername(), ExpireTime, TimeUnit.MINUTES);
}
/**
* 设置用户代理信息
*
* @param loginUser 登录信息
*/
public void setUserAgent(LoginUser loginUser) {
UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));
String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
loginUser.setIpaddr(ip);
loginUser.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
loginUser.setBrowser(userAgent.getBrowser().getName());
loginUser.setOs(userAgent.getOperatingSystem().getName());
}
/**
* 从数据声明生成令牌
*
* @param claims 数据声明
* @return 令牌
*/
private String createToken(Map<String, Object> claims) {
String token = Jwts.builder()
.setClaims(claims)
.signWith(SignatureAlgorithm.HS512, secret).compact();
return token;
}
/**
* 从令牌中获取数据声明
*
* @param token 令牌
* @return 数据声明
*/
private Claims parseToken(String token) {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}
/**
* 从令牌中获取用户名
*
* @param token 令牌
* @return 用户名
*/
public String getUsernameFromToken(String token) {
Claims claims = parseToken(token);
return claims.getSubject();
}
/**
* 获取请求token
*
* @param request
* @return token
*/
private String getToken(HttpServletRequest request) {
String token = request.getHeader(header);
if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX)) {
token = token.replace(Constants.TOKEN_PREFIX, "");
}
return token;
}
private String getTokenKey(String uuid) {
return Constants.LOGIN_TOKEN_KEY + projectName + "." + uuid;
}
private String getTokenUser(String uuid) {
return Constants.LOGIN_TOKEN_KEY + uuid;
}
}
package com.ruoyi.project.monitor.controller;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import com.ruoyi.framework.redis.RedisCacheUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.aspectj.lang.annotation.Log;
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.framework.redis.RedisCache;
import com.ruoyi.framework.security.LoginUser;
import com.ruoyi.framework.web.controller.BaseController;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.framework.web.page.TableDataInfo;
import com.ruoyi.project.monitor.domain.SysUserOnline;
import com.ruoyi.project.system.service.ISysUserOnlineService;
/**
* 在线用户监控
*
* @author ruoyi
*/
@RestController
@RequestMapping("/monitor/online")
public class SysUserOnlineController extends BaseController {
@Autowired
private ISysUserOnlineService userOnlineService;
@Autowired
private RedisCacheUser redisCache;
@PreAuthorize("@ss.hasPermi('monitor:online:list')")
@GetMapping("/list")
public TableDataInfo list(String ipaddr, String userName) {
Collection<String> keys = redisCache.keys(Constants.LOGIN_TOKEN_KEY + "*");
List<SysUserOnline> userOnlineList = new ArrayList<SysUserOnline>();
for (String key : keys) {
try {
LoginUser user = redisCache.getCacheObject(key);
if (StringUtils.isNotEmpty(ipaddr) && StringUtils.isNotEmpty(userName)) {
if (StringUtils.equals(ipaddr, user.getIpaddr()) && StringUtils.equals(userName, user.getUsername())) {
userOnlineList.add(userOnlineService.selectOnlineByInfo(ipaddr, userName, user));
}
} else if (StringUtils.isNotEmpty(ipaddr)) {
if (StringUtils.equals(ipaddr, user.getIpaddr())) {
userOnlineList.add(userOnlineService.selectOnlineByIpaddr(ipaddr, user));
}
} else if (StringUtils.isNotEmpty(userName) && StringUtils.isNotNull(user.getUser())) {
if (StringUtils.equals(userName, user.getUsername())) {
userOnlineList.add(userOnlineService.selectOnlineByUserName(userName, user));
}
} else {
userOnlineList.add(userOnlineService.loginUserToUserOnline(user));
}
} catch (ClassCastException ignored) {
continue;
}
}
Collections.reverse(userOnlineList);
userOnlineList.removeAll(Collections.singleton(null));
return getDataTable(userOnlineList);
}
/**
* 强退用户
*/
@PreAuthorize("@ss.hasPermi('monitor:online:forceLogout')")
@Log(title = "在线用户", businessType = BusinessType.FORCE)
@DeleteMapping("/{tokenId}")
public AjaxResult forceLogout(@PathVariable String tokenId) {
redisCache.deleteObject(Constants.LOGIN_TOKEN_KEY + tokenId);
return AjaxResult.success();
}
}