SpringBoot + 登录设备管理 + 强制踢下线:用户可查看并登出其他设备会话

导语

你是否遇到过这样的场景:

  • 在公司电脑登录后,回家想用家里电脑登录,却发现账号在别处登录
  • 手机丢失后,担心账号被他人滥用,却无法远程踢出设备
  • 发现账号有异常登录行为,却不知道如何处理

传统的登录系统往往只支持单设备登录,或者无法让用户主动管理登录设备。今天,我们就来聊聊如何通过SpringBoot实现一个完善的登录设备管理系统,让用户可以查看所有登录设备并强制踢下线。


一、为什么需要登录设备管理?

1.1 安全需求分析

账号安全威胁

在多设备时代,账号安全面临诸多挑战:

  • 设备丢失:手机、电脑丢失后,账号可能被他人滥用
  • 账号共享:多人共用账号,难以追踪登录行为
  • 异常登录:黑客盗号后异地登录
  • 忘记登出:在公共电脑登录后忘记退出

用户需求

  • 查看所有已登录设备
  • 识别异常登录行为
  • 远程踢出可疑设备
  • 保护账号安全

1.2 传统方案的局限

单设备登录

// 传统方案:只允许一个设备登录
if (isUserLoggedIn(userId)) {
    // 强制踢出之前的登录
    logoutPreviousSession(userId);
}
// 创建新会话
createSession(userId);

问题:

  • 用户无法在多个设备同时使用
  • 无法查看历史登录记录
  • 无法主动管理登录设备

Session方案

// 传统Session方案
HttpSession session = request.getSession();
session.setAttribute("userId", userId);

问题:

  • 依赖服务器内存,难以扩展
  • 无法跨服务器共享
  • 无法主动踢出指定设备

二、系统架构设计

2.1 整体架构

┌─────────────────────────────────────────────────────────────┐
│                    登录设备管理系统                           │
├─────────────────────────────────────────────────────────────┤
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │  用户认证    │  │  设备识别    │  │    会话管理          │  │
│  └─────────────┘  └─────────────┘  └─────────────────────┘  │
├─────────────────────────────────────────────────────────────┤
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │  JWT Token  │  │ Redis缓存   │  │   数据库持久化        │  │
│  └─────────────┘  └─────────────┘  └─────────────────────┘  │
├─────────────────────────────────────────────────────────────┤
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │  登录日志    │  │  异常检测    │  │   安全告警           │  │
│  └─────────────┘  └─────────────┘  └─────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

2.2 核心组件

1. JWT Token

  • 无状态认证
  • 包含用户ID、设备ID、会话ID
  • 支持Token过期和刷新

2. Redis缓存

  • 存储会话状态
  • 快速验证Token有效性
  • 支持分布式部署

3. 数据库持久化

  • 存储设备信息
  • 记录登录历史
  • 支持查询统计

2.3 数据模型

用户表(users)

CREATE TABLE users (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(255) UNIQUE NOT NULL,
    password VARCHAR(255) NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    create_time DATETIME NOT NULL
);

设备会话表(device_sessions)

CREATE TABLE device_sessions (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    session_id VARCHAR(255) UNIQUE NOT NULL,
    device_id VARCHAR(255) NOT NULL,
    device_name VARCHAR(255),
    device_type VARCHAR(50),
    ip_address VARCHAR(50),
    login_time DATETIME NOT NULL,
    last_active_time DATETIME,
    status VARCHAR(50) NOT NULL
);

三、实战:实现核心功能

3.1 设备识别

核心思路: 通过User-Agent和IP地址识别设备信息

@Component
public class DeviceUtil {

    public Map<String, String> getDeviceInfo() {
        Map<String, String> deviceInfo = new HashMap<>();
        
        HttpServletRequest request = getCurrentRequest();
        if (request != null) {
            String userAgent = request.getHeader("User-Agent");
            deviceInfo.put("userAgent", userAgent);
            deviceInfo.put("deviceName", parseDeviceName(userAgent));
            deviceInfo.put("deviceType", parseDeviceType(userAgent));
            deviceInfo.put("ipAddress", getClientIpAddress(request));
        }
        
        return deviceInfo;
    }

    private String parseDeviceName(String userAgent) {
        if (userAgent.contains("iPhone")) {
            return "iPhone";
        } else if (userAgent.contains("iPad")) {
            return "iPad";
        } else if (userAgent.contains("Android")) {
            return userAgent.contains("Mobile") ? "Android Phone" : "Android Tablet";
        } else if (userAgent.contains("Windows")) {
            return "Windows PC";
        } else if (userAgent.contains("Mac")) {
            return "Mac";
        } else {
            return "Unknown Device";
        }
    }
}

3.2 登录流程

核心逻辑:

  1. 验证用户名密码
  2. 识别设备信息
  3. 检查并发登录数
  4. 创建会话记录
  5. 生成JWT Token
  6. 缓存到Redis
@Service
public class AuthService {

    @Transactional
    public Map<String, Object> login(String username, String password) {
        // 1. 验证用户
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new RuntimeException("用户名或密码错误"));

        if (!passwordEncoder.matches(password, user.getPassword())) {
            throw new RuntimeException("用户名或密码错误");
        }

        // 2. 识别设备
        Map<String, String> deviceInfo = deviceUtil.getDeviceInfo();
        String deviceId = deviceUtil.generateDeviceId();
        String sessionId = UUID.randomUUID().toString().replace("-", "");

        // 3. 检查并发登录数
        List<DeviceSession> activeSessions = sessionRepository
            .findActiveSessionsByUserId(user.getId());
        
        if (activeSessions.size() >= maxConcurrent) {
            // 踢出最旧的会话
            DeviceSession oldestSession = activeSessions.get(activeSessions.size() - 1);
            kickOutSession(oldestSession.getSessionId());
        }

        // 4. 创建会话记录
        DeviceSession session = new DeviceSession();
        session.setUserId(user.getId());
        session.setSessionId(sessionId);
        session.setDeviceId(deviceId);
        session.setDeviceName(deviceInfo.get("deviceName"));
        session.setDeviceType(deviceInfo.get("deviceType"));
        session.setIpAddress(deviceInfo.get("ipAddress"));
        session.setStatus("ACTIVE");
        sessionRepository.save(session);

        // 5. 生成JWT Token
        String token = jwtUtil.generateToken(user.getId(), username, deviceId, sessionId);

        // 6. 缓存到Redis
        String tokenKey = tokenPrefix + token;
        redisTemplate.opsForValue().set(tokenKey, sessionId, timeout, TimeUnit.MILLISECONDS);

        // 返回结果
        Map<String, Object> result = new HashMap<>();
        result.put("token", token);
        result.put("sessionId", sessionId);
        result.put("deviceName", deviceInfo.get("deviceName"));
        
        return result;
    }
}

3.3 踢出设备

核心逻辑:

  1. 验证用户权限
  2. 更新会话状态
  3. 清除Redis缓存
  4. 记录操作日志
@Service
public class DeviceSessionService {

    @Transactional
    public boolean kickOutDevice(Long userId, String sessionId) {
        // 1. 查找会话
        DeviceSession session = sessionRepository.findBySessionId(sessionId)
                .orElseThrow(() -> new RuntimeException("会话不存在"));

        // 2. 验证权限
        if (!session.getUserId().equals(userId)) {
            throw new RuntimeException("无权操作此会话");
        }

        // 3. 更新状态
        session.setStatus("KICKED");
        session.setLastActiveTime(LocalDateTime.now());
        sessionRepository.save(session);

        // 4. 清除Redis缓存
        String userKey = userPrefix + userId + ":" + sessionId;
        Object tokenObj = redisTemplate.opsForValue().get(userKey);
        if (tokenObj != null) {
            String tokenKey = tokenPrefix + tokenObj.toString();
            redisTemplate.delete(tokenKey);
        }
        redisTemplate.delete(userKey);

        log.info("用户 {} 踢出设备: {}", userId, session.getDeviceName());

        return true;
    }

    @Transactional
    public void kickOutAllDevices(Long userId, String currentSessionId) {
        List<DeviceSession> sessions = sessionRepository.findActiveSessionsByUserId(userId);
        
        for (DeviceSession session : sessions) {
            if (!session.getSessionId().equals(currentSessionId)) {
                session.setStatus("KICKED");
                session.setLastActiveTime(LocalDateTime.now());
                sessionRepository.save(session);
            }
        }

        log.info("用户 {} 踢出所有其他设备", userId);
    }
}

3.4 设备列表查询

@Service
public class DeviceSessionService {

    public List<Map<String, Object>> getUserDevices(Long userId) {
        List<DeviceSession> sessions = sessionRepository.findActiveSessionsByUserId(userId);
        
        return sessions.stream().map(session -> {
            Map<String, Object> device = new HashMap<>();
            device.put("sessionId", session.getSessionId());
            device.put("deviceId", session.getDeviceId());
            device.put("deviceName", session.getDeviceName());
            device.put("deviceType", session.getDeviceType());
            device.put("ipAddress", session.getIpAddress());
            device.put("loginTime", session.getLoginTime());
            device.put("lastActiveTime", session.getLastActiveTime());
            device.put("status", session.getStatus());
            return device;
        }).collect(Collectors.toList());
    }
}

四、前端界面实现

4.1 设备列表展示

<div class="device-list" id="deviceList">
    <!-- 设备项 -->
    <div class="device-item current">
        <div class="device-header">
            <span class="device-name">Windows PC (当前设备)</span>
            <span class="device-status status-active">活跃</span>
        </div>
        <div class="device-info">
            <div class="device-info-item">
                <span>📱 设备类型:</span>
                <span>DESKTOP</span>
            </div>
            <div class="device-info-item">
                <span>🌐 IP地址:</span>
                <span>192.168.1.100</span>
            </div>
            <div class="device-info-item">
                <span>🕐 登录时间:</span>
                <span>2026-02-28 10:00:00</span>
            </div>
            <div class="device-info-item">
                <span>⏰ 最后活跃:</span>
                <span>2026-02-28 10:05:00</span>
            </div>
        </div>
    </div>
</div>

4.2 踢出设备操作

function kickOutDevice(sessionId) {
    if (!confirm('确定要踢出此设备吗?')) {
        return;
    }

    fetch(`/api/devices/${sessionId}`, {
        method: 'DELETE',
        headers: {
            'Authorization': 'Bearer ' + token
        }
    })
    .then(response => response.json())
    .then(data => {
        if (data.success) {
            alert('设备已踢下线');
            loadDevices(); // 刷新设备列表
        } else {
            alert('踢出设备失败: ' + data.message);
        }
    });
}

五、安全增强措施

5.1 异常登录检测

@Service
public class LoginSecurityService {

    public void checkLoginSecurity(Long userId, String ipAddress, String deviceId) {
        // 1. 检查异地登录
        List<DeviceSession> recentSessions = sessionRepository
            .findRecentSessionsByUserId(userId, LocalDateTime.now().minusDays(7));
        
        Set<String> recentIps = recentSessions.stream()
            .map(DeviceSession::getIpAddress)
            .collect(Collectors.toSet());
        
        if (!recentIps.contains(ipAddress)) {
            // 异地登录告警
            alertService.sendLoginAlert(userId, "异地登录", ipAddress);
        }

        // 2. 检查频繁登录
        long loginCount = sessionRepository.countTodayLogins(userId);
        if (loginCount > 10) {
            // 频繁登录告警
            alertService.sendLoginAlert(userId, "频繁登录", ipAddress);
        }
    }
}

5.2 Token刷新机制

@Service
public class TokenRefreshService {

    public String refreshToken(String oldToken) {
        // 1. 验证旧Token
        if (!jwtUtil.validateToken(oldToken)) {
            throw new RuntimeException("Token无效");
        }

        // 2. 检查是否需要刷新
        Claims claims = jwtUtil.parseToken(oldToken);
        long expireTime = claims.getExpiration().getTime();
        long currentTime = System.currentTimeMillis();
        
        // 如果剩余时间小于1天,则刷新
        if (expireTime - currentTime < 86400000) {
            Long userId = claims.get("userId", Long.class);
            String username = claims.getSubject();
            String deviceId = claims.get("deviceId", String.class);
            String sessionId = claims.get("sessionId", String.class);
            
            return jwtUtil.generateToken(userId, username, deviceId, sessionId);
        }
        
        return oldToken;
    }
}

5.3 登录日志记录

@Entity
@Table(name = "login_logs")
public class LoginLog {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private Long userId;
    private String username;
    private String deviceId;
    private String ipAddress;
    private String location;
    private String loginResult;
    private LocalDateTime loginTime;
    private String failReason;
}

六、性能优化

6.1 Redis缓存策略

// Token缓存
String tokenKey = "device:token:" + token;
redisTemplate.opsForValue().set(tokenKey, sessionId, 7, TimeUnit.DAYS);

// 用户会话列表缓存
String userKey = "device:user:" + userId;
redisTemplate.opsForHash().put(userKey, sessionId, token);
redisTemplate.expire(userKey, 7, TimeUnit.DAYS);

6.2 数据库索引优化

-- 会话表索引
CREATE INDEX idx_user_status ON device_sessions(user_id, status);
CREATE INDEX idx_session_id ON device_sessions(session_id);
CREATE INDEX idx_last_active ON device_sessions(last_active_time);

-- 登录日志表索引
CREATE INDEX idx_user_login_time ON login_logs(user_id, login_time);

6.3 定时清理过期会话

@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
public void cleanExpiredSessions() {
    LocalDateTime expireTime = LocalDateTime.now().minusDays(30);
    
    List<DeviceSession> expiredSessions = sessionRepository
        .findByLastActiveTimeBeforeAndStatus(expireTime, "ACTIVE");
    
    for (DeviceSession session : expiredSessions) {
        session.setStatus("EXPIRED");
        sessionRepository.save(session);
    }
    
    log.info("清理过期会话: {} 个", expiredSessions.size());
}

七、实战效果演示

7.1 用户登录流程

步骤1:用户登录

用户输入用户名密码 → 系统验证 → 识别设备 → 创建会话 → 返回Token

步骤2:查看设备列表

用户点击"我的设备" → 显示所有登录设备 → 标记当前设备

步骤3:踢出设备

用户点击"踢下线" → 确认操作 → 更新会话状态 → 清除缓存 → 设备被强制下线

7.2 界面效果

┌─────────────────────────────────────────────────────────────┐
│  当前用户: testuser    当前设备: Windows PC    登录时间: 10:00  │
├─────────────────────────────────────────────────────────────┤
│  会话统计                                                     │
│  ┌──────┐  ┌──────┐  ┌──────┐  ┌──────┐                     │
│  │  3   │  │  2   │  │  1   │  │  0   │                     │
│  │总会话│  │活跃  │  │已踢出│  │已登出│                      │
│  └──────┘  └──────┘  └──────┘  └──────┘                     │
├─────────────────────────────────────────────────────────────┤
│  我的设备                                                     │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ Windows PC (当前设备)              [活跃]            │   │
│  │ 📱 DESKTOP  🌐 192.168.1.100                        │   │
│  │ 🕐 2026-02-28 10:00  ⏰ 2026-02-28 10:05           │   │
│  └─────────────────────────────────────────────────────┘   │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ iPhone                             [活跃]            │   │
│  │ 📱 MOBILE  🌐 192.168.1.101                         │   │
│  │ 🕐 2026-02-28 09:00  ⏰ 2026-02-28 09:30            │   │
│  │ [踢下线]                                             │   │
│  └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

八、最佳实践

8.1 配置建议

device:
  session:
    max-concurrent: 5           # 最大并发登录数
    timeout: 604800000          # 会话超时时间(7天)
    token-prefix: "device:token:"
    user-prefix: "device:user:"

8.2 安全建议

  1. 强制强密码:要求用户设置复杂密码
  2. 二次验证:敏感操作进行短信或邮箱验证
  3. 登录告警:异地登录、频繁登录及时告警
  4. 定期清理:定期清理过期会话和日志
  5. 操作日志:记录所有关键操作日志

8.3 性能建议

  1. Redis缓存:使用Redis缓存会话状态
  2. 数据库索引:为常用查询字段创建索引
  3. 定时任务:定时清理过期数据
  4. 分页查询:大量数据时使用分页查询

九、总结

9.1 核心收益

  1. 用户体验提升:用户可以自主管理登录设备
  2. 账号安全增强:及时发现和处理异常登录
  3. 多设备支持:同一账号可在多个设备同时使用
  4. 操作透明:所有登录行为可追溯

9.2 适用场景

  • 社交应用
  • 电商系统
  • 企业应用
  • 在线教育
  • 任何需要账号安全的系统

9.3 扩展方向

  • 支持地理位置解析
  • 支持设备指纹识别
  • 支持登录行为分析
  • 支持WebSocket实时通知
  • 支持多因素认证

十、源码获取

完整项目代码已上传,包含:

  • 完整的SpringBoot项目
  • JWT认证机制
  • Redis会话管理
  • 可视化前端界面
  • 详细的API文档

项目地址: 公众号"服务端技术精选",回复"登录设备管理"即可获取项目下载链接。


互动话题

  1. 你在项目中遇到过哪些账号安全问题?
  2. 除了踢下线,你觉得还有哪些安全措施是必要的?
  3. 你认为多设备登录应该如何平衡用户体验和安全性?

欢迎在评论区留言讨论!如果觉得文章对你有帮助,别忘了点赞、在看、转发三连支持一下~


关于作者

服务端技术精选,专注分享后端开发技术干货,包括微服务架构、分布式系统、性能优化等。欢迎关注,一起学习成长!

本文首发于公众号「服务端技术精选」,转载请注明出处。


标题:SpringBoot + 登录设备管理 + 强制踢下线:用户可查看并登出其他设备会话
作者:jiangyi
地址:http://jiangyi.space/articles/2026/03/02/1772277265194.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消