- 前端生成浏览器唯一标识
clientUniqueId(localStorage 永久缓存) - 网页端单设备登录,换浏览器 / 电脑直接挤下线
- WebSocket 实时推送踢下线,不用等下次接口请求
- 网页 + App 隔离互不冲突
- SpringBoot 完整版、可直接复制上线
一、第一步:用户表新增字段
sql
-- 网页端
web_client_id varchar(128) comment '前端浏览器唯一ID',
web_token varchar(128) comment '网页登录token',
-- 手机端
app_client_id varchar(128),
app_token varchar(128)
二、前端全套代码(Vue / 原生 JS 通用)
1. 生成浏览器唯一 ID(全局初始化)
js
// 浏览器唯一指纹,本机永久不变
function getClientUniqueId() {
let clientId = localStorage.getItem("clientUniqueId");
if (!clientId) {
// 生成uuid
clientId = uuid();
localStorage.setItem("clientUniqueId", clientId);
}
return clientId;
}
// uuid工具
function uuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
})
}
// 全局唯一ID
const clientUniqueId = getClientUniqueId();
2. 请求拦截器,每次接口自动携带请求头
js
// axios 举例
axios.interceptors.request.use(config => {
config.headers["client-unique-id"] = clientUniqueId;
return config;
})
3. 登录成功后,连接 WebSocket
js
let socket = null;
// 登录成功后调用
function connectWebSocket(userId) {
// 后端ws地址
const wsUrl = `ws://localhost:8080/ws/kick?userId=${userId}`;
socket = new WebSocket(wsUrl);
// 监听后端推送消息
socket.onmessage = (res) => {
if (res.data === "KICK_OUT") {
// 被挤下线
alert("账号已在其他设备登录,您已被迫下线!");
// 清空本地缓存
localStorage.removeItem("token");
// 跳登录页
location.href = "/login";
// 关闭连接
socket.close();
}
}
// 连接关闭重连(可选)
socket.onclose = () => {
setTimeout(() => connectWebSocket(userId), 5000);
}
}
4. 登录接口携带参数
js
axios.post("/login",{
username: "xxx",
password: "xxx",
clientUniqueId: clientUniqueId
}).then(res=>{
if(res.code === 200){
// 保存token
localStorage.setItem("token",res.data.token);
// 建立ws连接
connectWebSocket(res.data.userId);
}
})
三、后端 WebSocket 全套配置(SpringBoot)
1. 引入依赖
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
2. WebSocket 配置类
java
运行
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
3. WebSocket 服务端(核心踢人推送)
java
运行
import org.springframework.stereotype.Component;
import javax.websocket.OnClose;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
@ServerEndpoint("/ws/kick")
public class KickWebSocketServer {
// 存放在线用户:userId -> 会话
private static final Map<Long, Session> USER_SESSION_MAP = new ConcurrentHashMap<>();
private Session session;
private Long userId;
@OnOpen
public void onOpen(Session session, @PathParam("userId") Long userId) {
this.session = session;
this.userId = userId;
// 覆盖旧连接
USER_SESSION_MAP.put(userId, session);
}
@OnClose
public void onClose() {
USER_SESSION_MAP.remove(userId);
}
// 给指定用户发送踢下线消息
public static void sendKickMsg(Long userId) {
Session session = USER_SESSION_MAP.get(userId);
if (session != null && session.isOpen()) {
try {
session.getBasicRemote().sendText("KICK_OUT");
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
四、后端登录业务核心代码(挤下线逻辑)
1. 登录接口伪代码
java
运行
@Autowired
private UserMapper userMapper;
@PostMapping("/login")
public Result login(@RequestBody UserLoginDTO dto){
// 1. 校验账号密码
User user = userMapper.selectByUserName(dto.getUsername());
if(user == null || !user.getPassword().equals(dto.getPassword())){
return Result.fail("账号密码错误");
}
// 2. 前端传的当前浏览器唯一ID
String currentClientId = dto.getClientUniqueId();
// 3. 判断:已有网页登录 且 不是当前设备
if (org.springframework.util.StringUtils.hasText(user.getWebClientId())
&& !user.getWebClientId().equals(currentClientId)) {
// ✅ 关键:给旧设备实时推送踢下线
KickWebSocketServer.sendKickMsg(user.getId());
}
// 4. 生成新token,覆盖设备ID
String newWebToken = UUID.randomUUID().toString().replace("-","");
user.setWebToken(newWebToken);
user.setWebClientId(currentClientId);
userMapper.updateById(user);
// 5. 返回数据给前端
Map<String,Object> resMap = new HashMap<>();
resMap.put("token",newWebToken);
resMap.put("userId",user.getId());
return Result.success(resMap);
}
五、你之前的拦截器 最终完整安全版
java
运行
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
// 1. 获取请求头
String token = request.getHeader("auth");
String clientUniqueId = request.getHeader("client-unique-id");
// 2. 基础非空校验
if (TextUtil.isEmpty(token)) {
throw new BizException("登录凭证不能为空");
}
if (TextUtil.isEmpty(clientUniqueId)) {
throw new BizException("设备标识不能为空");
}
// 3. 解析用户
String userName = TokenUtil.findKey(token);
User user = userService.findUserByUserName(userName);
if(user == null){
throw new BizException("用户不存在");
}
// 4. 校验设备
if(
!clientUniqueId.equals(user.getWebClientId())
){
return Result.fail(401,"账号已在其他设备登录,请重新登录");
}
// 存入当前用户
request.setAttribute("curUser",user);
return true;
}
六、整体流程闭环
- 第一次打开网页 → 前端生成唯一 ID 存
localStorage - 登录 → 后端绑定
web_client_id+ 生成新 token - 同账号换电脑 / 换浏览器 登录:
- 后端对比设备 ID 不一致
- WebSocket 给旧设备发
KICK_OUT - 旧页面立刻弹窗、清缓存、跳登录
- 同电脑重启浏览器:
- 唯一 ID 不变 → 正常登录、不会被挤
- 网页 / App 各自一套字段,互不影响
七、补充说明
- 不需要定时任务、不需要过期时间
- 不需要后端读取任何硬件信息,完全浏览器前端控制
- WebSocket 断开 / 重连 不影响正常接口使用
- 无痕浏览器会生成新 ID,判定为新设备,合理安全
8、工具类及实体类补充
1、UserLoginDTO 登录接收参数实体
java
运行
import lombok.Data;
@Data
public class UserLoginDTO {
/**
* 用户名
*/
private String username;
/**
* 密码
*/
private String password;
/**
* 前端浏览器唯一设备ID
*/
private String clientUniqueId;
}
2、顺便补全你需要的 BizException(直接复制)
java
运行
public class BizException extends RuntimeException {
private Integer code;
private String msg;
public BizException(String msg) {
super(msg);
this.code = 400;
this.msg = msg;
}
public BizException(Integer code, String msg) {
super(msg);
this.code = code;
this.msg = msg;
}
public Integer getCode() {
return code;
}
public String getMsg() {
return msg;
}
}
3、全局异常处理器 GlobalExceptionHandler(配套使用)
java
运行
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public Result<?> bizExceptionHandler(BizException e){
return Result.fail(e.getCode(), e.getMsg());
}
@ExceptionHandler(Exception.class)
public Result<?> exceptionHandler(Exception e){
e.printStackTrace();
return Result.fail(500, "系统异常,请稍后重试");
}
}
4.前端 JS 一行生成浏览器唯一 ID(直接复制)
js
// 没有就生成,有就读取
let clientUniqueId = localStorage.getItem("clientUniqueId");
if (!clientUniqueId) {
// 随机唯一指纹
clientUniqueId = uuidv4() + "_" + Math.random().toString(36).slice(2);
localStorage.setItem("clientUniqueId", clientUniqueId);
}
// 通用uuid方法
function uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}