最近接到一个需求,需要实现一个账号只能在一台设备登录,并且兼容SSO单点登录以及断连超时的。
设计方案:
登录的时候会产生两个token,一个长期token,一个短期token,并且把它记录到redis里面。短期token设置过期时间为三分钟。每次访问接口时都会在请求头上携带短期token。同时写了一个过滤器,在每次进入接口前会验证token的合法性。这样就解决了单设备和单点登录。
详细解释一下,就是用户在每次登录时都会生成一个不同的token,并且在redis记录的时候是以用户ID作为key,token作为value存储,如果在第二个客户端登录时生成的新token会覆盖旧token,这样之前的客户端发起请求时就会无效。
断连超时:第一个方案:登录的时候都会获取客户端的IP地址,用WebSocket进行IP直连。
第二个方案由客户端发起http请求(发送心跳包),并且请求的时候携带短期token,这里基于安全性考虑,每次在进行心跳请求的时候会验证该token的合法性,如果合法的话,进行刷新生成一个新的token,并且返回给客户端,客户端保存新的token,每次请求携带新的token。商议以后用的是该方案。
详细说明一下实现过程:
首先在登录的时候先在redis上面存储数据,并且设置好过期时间,因为每个数据过期时间不一样,所以我每一条数据都是单独存放。验证登录的代码就不贴了,直接贴存redis代码:
stringRedisTemplate.opsForValue().set(redisUeKey + ":accessToken:", accessTokenDO.getAccessToken(), 60 * 24, TimeUnit.SECONDS);
stringRedisTemplate.opsForValue().set(redisUeKey + ":refreshToken:", accessTokenDO.getRefreshToken(), 60 * 60 * 24 * 30, TimeUnit.SECONDS);
redis存放好以后。如果用户断线的话,又有两个方案供选择,
第一个方案:写一个定时任务,每隔相应的时间就去redis里面读取哪些键值对还存在,我认为不可取。
第二个方案,也是我用的方案:写一个redis的过期键监听器,去监控哪些键过期。
在使用第二个方案之前首先得在redis的配置文件里面,把这个功能开启。notify-keyspace-events 对应的值改为 Ex。
监听器:在redis的配置类里面创建监听。
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer() {
RedisMessageListenerContainer redisMessageListenerContainer = new RedisMessageListenerContainer();
redisMessageListenerContainer.setConnectionFactory(redisConnectionFactory);
return redisMessageListenerContainer;
}
创建好监听以后,写监听的逻辑:
@Slf4j
@Component
public class RedisKeyExpirationListener extends KeyExpirationEventMessageListener {
@Value("${spring.rabbitmq.host}")
private String host;
@Value("${spring.rabbitmq.username}")
private String username;
@Value("${spring.rabbitmq.password}")
private String password;
@Value("${spring.rabbitmq.virtual-host}")
private String virtualHost;
@Resource
private StringRedisTemplate stringRedisTemplate;
public RedisKeyExpirationListener(RedisMessageListenerContainer listenerContainer) {
super(listenerContainer);
}
/***
* @Author Feng
* @Date 2024/6/18 17:19
* @Description 监听redis过期键
* @Param [message, pattern]
* @Return void
* @Since version 1.0
*/
@Override
public void onMessage(Message message, byte[] pattern) {
//获得失效的key
String expiredKey = new String(message.getBody());
String[] parts = expiredKey.split(":");
if (!"accessToken".equals(parts[2])){
return;
}
String extractedString = parts[1];
log.warn("用户: " + extractedString + "断连");
String queueName = stringRedisTemplate.opsForValue().get("UE:" + extractedString + ":queueName:");
if (queueName == null){
log.info("断连用户没有创建queue");
return;
}
stringRedisTemplate.delete("UE:" + extractedString + ":queueName:");
try {
Connection connection = createConnection(host, username, password, virtualHost);
Channel channel = connection.createChannel();
channel.queueDelete(queueName);
} catch (IOException | TimeoutException e) {
throw new RuntimeException(e);
}
log.info("断连用户queue删除成功: " + queueName);
int i = userService.updateUserOffline(extractedString);
if (i == 1){
log.warn("用户: " + extractedString + "断连状态修改成功");
}else {
log.error("用户: " + extractedString + "断连状态修改失败");
}
}
}
因为我这里夹杂了rabbitMQ需要实时删除,参考的话可以不用管。
这里就已经监听成功。并且如果用户断线成功修改用户的状态。
然后是单设备登录以及单点登录:
首先先写一个过滤器:每次请求的时候都去请求头里面获取该token验证token的合法性。
package com.example.linyun_project.filter;
import org.springframework.data.redis.core.StringRedisTemplate;
import javax.annotation.Resource;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
/**
* @description:
* @author: Feng
* @time: 2024/6/13 14:45
*/
@WebFilter(urlPatterns = "/*")
public class TokenFilter implements Filter {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
//校验用户登录状态
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
//取出token
String token = request.getHeader("accessToken");
//取出userId
String userId = request.getHeader("userId");
if ("".equals(userId)){
PrintWriter printWriter = response.getWriter();
printWriter.write("未登录");
printWriter.flush();
printWriter.close();
// 退出方法,不放行
return;
}
if ("".equals(token)){
PrintWriter printWriter = response.getWriter();
printWriter.write("请重新登录");
printWriter.flush();
printWriter.close();
return;
}
//检查携带的token是否和redis的token一致,如果一致的话就放行
String accessToken = stringRedisTemplate.opsForValue().get("UE:" + userId + ":accessToken:");
if (accessToken == null){
PrintWriter printWriter = response.getWriter();
printWriter.write("断连超时");
printWriter.flush();
printWriter.close();
return;
}
if (!accessToken.equals(token)){
PrintWriter printWriter = response.getWriter();
printWriter.write("该账号已在其他地方登录");
printWriter.flush();
printWriter.close();
return;
}
filterChain.doFilter(servletRequest, servletResponse);
}
}
这样三个问题得到全部解决。单独说一下,因为登录和这些操作不是在一台服务器上面,所以我用的是匹配全部路径,如果要选择性修改路径的话,可以自行根据需求进行修改。