引言
在现代应用中,一个账号在多个设备上的同时登录可能带来安全隐患。为了解决这个问题,许多应用实现了单设备登录,确保同一个用户只能在一个设备上登录。当用户在新的设备上登录时,旧设备会被强制下线。
本文将介绍如何使用 Spring Boot 和 Redis 来实现单设备登录功能。
效果图
在线访问地址: https://www.coderman.club/#/dashboard
思路
userId:xxx (被覆盖)
userId:yyy
- 用户登录时,新的 token 会覆盖 Redis 中的旧 token,确保每次登录都是最新的设备。
- 接口访问时,通过拦截器对 token 进行验证,确保同一时间只有一个有效会话。
- 如果 token 不匹配或过期,则拦截请求,返回未授权的响应。
代码实现
/*** 权限拦截器* @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*/@Resourceprivate RescService rescApi;/*** 用户api*/@Resourceprivate UserService userApi;/*** 是否单设备登录校验*/private static final boolean isOneDeviceLogin = true;@PostConstructpublic 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);}
}