引言
在现代应用中,一个账号在多个设备上的同时登录可能带来安全隐患。为了解决这个问题,许多应用实现了单设备登录,确保同一个用户只能在一个设备上登录。当用户在新的设备上登录时,旧设备会被强制下线。
本文将介绍如何使用 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
*/
@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);
}
}