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);
    }
}
相关推荐
杨了个杨898227 分钟前
memcached部署
qt·websocket·memcached
汤姆yu3 小时前
基于springboot的尿毒症健康管理系统
java·spring boot·后端
暮色妖娆丶3 小时前
Spring 源码分析 单例 Bean 的创建过程
spring boot·后端·spring
biyezuopinvip4 小时前
基于Spring Boot的企业网盘的设计与实现(任务书)
java·spring boot·后端·vue·ssm·任务书·企业网盘的设计与实现
惊讶的猫5 小时前
redis分片集群
数据库·redis·缓存·分片集群·海量数据存储·高并发写
JavaGuide5 小时前
一款悄然崛起的国产规则引擎,让业务编排效率提升 10 倍!
java·spring boot
期待のcode5 小时前
Redis的主从复制与集群
运维·服务器·redis
jiunian_cn5 小时前
【Redis】渐进式遍历
数据库·redis·缓存
figo10tf5 小时前
Spring Boot项目集成Redisson 原始依赖与 Spring Boot Starter 的流程
java·spring boot·后端
zhangyi_viva5 小时前
Spring Boot(七):Swagger 接口文档
java·spring boot·后端