SpringBoot基于Redis+WebSocket 实现账号单设备登录.

引言

在现代应用中,一个账号在多个设备上的同时登录可能带来安全隐患。为了解决这个问题,许多应用实现了单设备登录,确保同一个用户只能在一个设备上登录。当用户在新的设备上登录时,旧设备会被强制下线。

本文将介绍如何使用 Spring Boot 和 Redis 来实现单设备登录功能。

效果图

在线访问地址: https://www.coderman.club/#/dashboard

思路

userId:xxx (被覆盖)

userId:yyy

  1. 用户登录时,新的 token 会覆盖 Redis 中的旧 token,确保每次登录都是最新的设备。
  2. 接口访问时,通过拦截器对 token 进行验证,确保同一时间只有一个有效会话。
  3. 如果 token 不匹配或过期,则拦截请求,返回未授权的响应。

代码实现

java 复制代码
/**
 * 权限拦截器
 * @author coderman
 */
@Aspect
@Component
@Order(value = AopConstant.AUTH_ASPECT_ORDER)
@Lazy(value = false)
@Slf4j
public class AuthAspect {

    /**
     * 白名单接口
     */
    public static List<String> whiteListUrl = new ArrayList<>();
    /**
     * 资源url与功能关系
     */
    public static Map<String, Set<Integer>> systemAllResourceMap = new HashMap<>();
    /**
     * 无需拦截的url且有登录信息
     */
    public static List<String> unFilterHasLoginInfoUrl = new ArrayList<>();
    /**
     * 资源api
     */
    @Resource
    private RescService rescApi;
    /**
     * 用户api
     */
    @Resource
    private UserService userApi;
    /**
     * 是否单设备登录校验
     */
    private static final boolean isOneDeviceLogin = true;

    @PostConstruct
    public void init() {
        this.refreshSystemAllRescMap();
    }

    /**
     * 刷新系统资源
     */
    public void refreshSystemAllRescMap() {
        systemAllResourceMap = this.rescApi.getSystemAllRescMap(null).getResult();
    }


    @Pointcut("(execution(* com.coderman..controller..*(..)))")
    public void pointcut() {
    }


    @Around("pointcut()")
    public Object around(ProceedingJoinPoint point) throws Throwable {

        Cache<String, AuthUserVO> tokenCache = CacheUtil.getInstance().getTokenCache();
        Cache<Integer, String> deviceCache = CacheUtil.getInstance().getDeviceCache();

        HttpServletRequest request = HttpContextUtil.getHttpServletRequest();
        String path = request.getServletPath();

        // 白名单直接放行
        if (whiteListUrl.contains(path)) {

            return point.proceed();
        }

        // 访问令牌
        String token = AuthUtil.getToken();
        if (StringUtils.isBlank(token)) {
            throw new BusinessException(ResultConstant.RESULT_CODE_401, "会话已过期, 请重新登录");
        }
        // 系统不存在的资源直接返回
        if (!systemAllResourceMap.containsKey(path) && !unFilterHasLoginInfoUrl.contains(path)) {
            throw new BusinessException(ResultConstant.RESULT_CODE_404, "您访问的接口不存在!");
        }

        // 用户信息
        AuthUserVO authUserVO = null;
        try {
            authUserVO = tokenCache.get(token, () -> {
                log.debug("尝试从redis中获取用户信息结果.token:{}", token);
                return userApi.getUserByToken(token);
            });
        } catch (Exception ignore) {
        }

        if (authUserVO == null || System.currentTimeMillis() > authUserVO.getExpiredTime()) {
            tokenCache.invalidate(token);
            throw new BusinessException(ResultConstant.RESULT_CODE_401, "会话已过期, 请重新登录");
        }

        // 单设备校验
        if (isOneDeviceLogin) {
            Integer userId = authUserVO.getUserId();
            String deviceToken = StringUtils.EMPTY;
            try {
                deviceToken = deviceCache.get(userId, () -> {
                    log.debug("尝试从redis中获取设备信息结果.userId:{}", userId);
                    return userApi.getTokenByUserId(userId);
                });
            } catch (Exception ignore) {
            }
            if (StringUtils.isNotBlank(deviceToken) && !StringUtils.equals(deviceToken, token)) {
                deviceCache.invalidate(userId);
                throw new BusinessException(ResultConstant.RESULT_CODE_401, "账号已在其他设备上登录!");
            }
        }

        // 不需要过滤的url且有登入信息,设置会话后直接放行
        if (unFilterHasLoginInfoUrl.contains(path)) {
            AuthUtil.setCurrent(authUserVO);
            return point.proceed();
        }

        // 验证用户权限
        List<Integer> myRescIds = authUserVO.getRescIdList();
        Set<Integer> rescIds = Sets.newHashSet();
        if (CollectionUtils.isNotEmpty(systemAllResourceMap.get(path))) {
            rescIds = new HashSet<>(systemAllResourceMap.get(path));
        }

        if (CollectionUtils.isNotEmpty(myRescIds)) {
            for (Integer rescId : rescIds) {
                if (myRescIds.contains(rescId)) {
                    AuthUtil.setCurrent(authUserVO);
                    return point.proceed();
                }
            }

        }

        throw new BusinessException(ResultConstant.RESULT_CODE_403, "接口无权限");
    }

    @RedisChannelListener(channelName = RedisConstant.CHANNEL_REFRESH_RESC)
    public void refreshRescListener(String msgContent) {

        log.warn("doRefreshResc start - > {}", msgContent);
        this.refreshSystemAllRescMap();
        log.warn("doRefreshResc end - > {}", msgContent);
    }

    @RedisChannelListener(channelName = RedisConstant.CHANNEL_REFRESH_SESSION_CACHE, clazz = AuthUserVO.class)
    public void refreshSessionCache(AuthUserVO logoutUser) {

        String token = logoutUser.getAccessToken();
        Integer userId = logoutUser.getUserId();

        log.warn("doUserLogout start - > {}", token);

        // 清除会话缓存
        Cache<String, AuthUserVO> tokenCache = CacheUtil.getInstance().getTokenCache();
        tokenCache.invalidate(token);

        // 清除设备缓存
        Cache<Integer, String> deviceCache = CacheUtil.getInstance().getDeviceCache();
        deviceCache.invalidate(userId);

        log.warn("doUserLogout end - > {}", token);
    }
}
相关推荐
用户3169353811831 天前
Java连接Redis
redis
用户3521802454752 天前
当 Prompt 学会"热更新":Spring Boot × Nacos3 AI 实战
java·spring boot·ai编程
昵称为空C2 天前
手撸一个动态 SQL 执行引擎:不重启服务,在线增删改查任意数据库
spring boot·后端
霸道流氓气质3 天前
领域驱动设计(DDD)在 Spring Boot 微服务中的实践指南
运维·spring boot·微服务
于先生吖3 天前
SpringBoot对接大模型开发AI命理测算系统:八字排盘与AI解析接口源码全解
人工智能·spring boot·后端
小小工匠3 天前
Redis - 事务机制:能实现 ACID 属性吗
数据结构·redis·性能优化·并发·持久化
Flittly3 天前
【AgentScope Java新手村系列】(10)实战-多Agent天气助手
java·spring boot·spring
星落zx3 天前
Spring Boot 多模型集成:优雅调用全球主流大模型
人工智能·spring boot·chatgpt
一杯奶茶¥3 天前
水果销售网站 CRM客户信息管理系统 超市管理系 酒店管理系统 健身房管理系统 在线音乐网站 校园招聘系统
java·vue.js·spring boot·mysql·spring·java项目