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);
    }
}
相关推荐
小蒜学长8 小时前
校园社团招新网站的设计与实现(代码+数据库+LW)
java·数据库·spring boot·后端·校园社团招新网站
卓怡学长10 小时前
w192基于springboot“考研情报站”微信小程序设计与实现
java·spring boot·spring·微信小程序·intellij-idea
ShineWinsu11 小时前
对于Redis:Hash类型的解析
c++·redis·分布式·缓存·面试·hash·哈希表
.Hypocritical.11 小时前
Redis从入门到实战:核心原理、场景落地与避坑指南
数据库·redis·缓存
用户62228840481911 小时前
Redis 分布式锁:从 2.8 之前到 2.8+,一篇讲透
redis
程序猿乐锅13 小时前
【黑马点评 | 第十一篇】关注 Feed 流实现
java·数据库·redis·分布式·后端·缓存·maven
web打印社区13 小时前
JS 静默打印怎么做:纯前端为什么不行,以及最小可跑通写法
开发语言·前端·javascript·websocket·网络协议·http·pdf
ShineWinsu14 小时前
对于Redis:List类型的解析
数据库·c++·redis·链表·缓存·面试·list
ym hyd 11114 小时前
宝宝早教服务系统源码 Java+SpringBoot+Vue3 前后分离
java·开发语言·spring boot
XS03010615 小时前
Redis 学习指南
数据库·redis·缓存