移动端自动退出登录系统设计方案
目录
业务背景与价值
业务背景
移动端自动退出登录功能是为了解决以下场景:
- 用户忘记退出登录,导致账号安全风险
- 多设备同时登录引发的会话冲突
- 长时间不活动的会话占用系统资源
- 防止非授权人员访问他人账号信息
业务价值
专业术语解释:自动退出登录机制是一种会话管理(Session Management)策略,通过设定会话超时(Session Timeout)阈值,在用户无操作达到特定时间后强制终止会话,要求用户重新认证(Re-authentication)。
大白话说明:就像银行ATM机,闲置几分钟后会自动退卡,防止他人未经授权使用。
从多个角度提供价值:
- 安全角度:降低账号被非授权人员访问的风险,保护用户隐私
- 合规角度:符合数据保护法规要求,如GDPR、网络安全法等
- 资源角度:释放无效会话占用的系统资源,提高系统性能
- 体验角度:确保同一用户在不同设备登录时体验一致
核心功能设计
1. 基础功能清单
移动端自动退出登录系统的核心功能包括:
| 功能模块 | 专业术语 | 大白话解释 | 业务价值 |
|---|---|---|---|
| 会话超时机制 | Session Timeout | 超过30分钟没操作自动退出 | 安全保障 |
| 滑动过期策略 | Sliding Expiration | 有操作就重置计时器 | 用户体验 |
| 多设备互踢 | Device Invalidation | 新设备登录时旧设备自动下线 | 会话一致性 |
| 双Token认证 | Refresh Token Pattern | 短期访问Token+长期刷新Token | 安全与体验平衡 |
| 异常行为监控 | Anomaly Detection | 检测不寻常登录行为 | 防攻击 |
| 前台后台差异化 | Foreground/Background Policy | 应用切后台时更短的超时时间 | 安全与体验平衡 |
2. 业务流程图
整体业务流程
2.1 专业版流程复述
- 用户登录
- 客户端发送登录请求,附带设备标识和认证信息
- 网关验证用户凭证并认证成功
- 会话服务创建新会话,生成token并分配唯一会话ID
- 会话服务在MySQL中持久化会话记录,存储last_active时间戳和device_id
- 网关返回JWT token、会话ID和超时配置给客户端
- 活跃度监控
- 客户端启动定时器(每5分钟)发送心跳请求
- 客户端监听用户交互事件(点击、滚动等),重置本地超时计时器
- 心跳请求携带会话ID和token发送至网关
- 网关转发心跳请求至会话服务
- 会话服务验证会话存在性和有效性
- 会话服务更新MySQL中的last_active时间戳
- 会话服务返回心跳结果给网关,网关转发给客户端
- 循环上述过程直到会话终止或超时
- 会话超时
- 用户停止操作设备,本地计时器开始计时
- 30分钟无操作后,客户端超时计时器触发
- 客户端向网关发送注销请求
- 网关将请求转发至会话服务
- 会话服务将该会话在MySQL中标记为已失效
- 客户端清除本地token和会话数据
- 客户端跳转至登录页面
- 设备互踢
- 用户在新设备上登录同一账号
- 会话服务检测到同一用户有已激活会话
- 会话服务在MySQL中将旧会话标记为失效
- 用户在新设备登录成功,会话服务设置新会话为激活状态
- 旧设备下次心跳请求时,会话服务返回会话已终止状态
- 旧设备客户端收到401响应,清除本地认证状态
- 旧设备客户端跳转至登录页面
- 客户端离线处理
- 设备网络中断时,心跳请求无法发送
- 客户端本地超时计时器继续运行
- 达到超时阈值后,客户端主动登出
- 网络恢复后,会话状态验证请求获知会话已失效
- 客户端清除本地认证状态并跳转至登录页面
2.2 大白话版本
- 登录阶段
- 你输入账号密码点击登录
- 系统检查你的账号密码是否正确
- 确认无误后,系统记录下"张三正在用iPhone12登录"
- 系统给你发一张"通行证",上面注明了失效时间
- 你的手机保存了这张通行证,现在可以正常使用App了
- 保持登录状态
- 你的手机开始计时"30分钟无操作自动退出"
- 每当你点击屏幕、滑动页面,计时器就会重置回30分钟
- 你的手机每隔5分钟会悄悄问服务器:"我还能用吗?"
- 服务器看到你的问询,就会更新你的"最后活跃时间"
- 服务器回答:"可以,请继续使用"
- 这个问答过程会一直持续,只要你还在使用手机
- 自动退出
- 你把手机放在一边,去喝咖啡了
- 30分钟过去了,你一直没碰手机
- 手机上的计时器发现:"已经30分钟没动静了,该退出了"
- 手机通知服务器:"我要退出登录了"
- 服务器记录下你已退出
- 手机清除你的登录信息
- 当你回来继续使用时,发现已经回到了登录界面
- 换设备登录
- 你用平板电脑登录了同一个账号
- 系统发现:"咦,张三已经在iPhone上登录了,但现在又用平板登录"
- 系统决定:"新设备优先,让平板可以登录,把iPhone踢下线"
- 你的平板登录成功了
- 你的iPhone下次问"我还能用吗"时
- 服务器回答:"不行了,你已经在别的设备登录了"
- iPhone自动退出登录,跳转到登录页面
- 网络问题处理
- 你的手机突然没网了(比如进了电梯)
- 手机无法向服务器发送"我还在吗"的询问
- 手机继续计时30分钟
- 如果30分钟内还没恢复网络,手机会自动退出登录
- 等你有网络时,手机会确认登录已失效
- 系统将你带回登录页面,需要重新输入账号密码
整个过程就像图书馆的座位管理:你只要每30分钟在座位上走动一下(有操作),管理员就知道这个座位有人;如果你30分钟不出现,或者去了另一个阅览室坐下,这边的座位就会被收回。
3. 关键技术要点
3.1 会话超时计算
专业术语: 会话超时(Session Timeout)是指在用户无交互的情况下,系统可以保持用户登录状态的最长时间。
大白话: 就像手机锁屏,设定多长时间不操作会自动锁屏。
实现策略:
- 前端维护最后活动时间戳
- 后端同步记录会话最后活动时间
- 前后端分别进行超时判断
- 前端可以增加提前预警机制
3.2 滑动过期机制
专业术语: 滑动过期(Sliding Expiration)指的是会话超时时间会根据用户最近的活动动态调整,每次用户活动都会重置超时计时器。
大白话: 只要你还在操作,系统就不会让你登出,就像会不断续杯的咖啡厅。
实现策略:
- 每次检测到用户活动,重置超时计时器
- 针对不同活动类型可能设置不同权重(如点击比滚动更重要)
- 必须区分有意义的用户活动和自动化行为(如页面自动刷新)
3.3 双Token认证机制
专业术语: 双Token认证采用访问令牌(Access Token)和刷新令牌(Refresh Token)结合的方式,前者生命周期短但使用频繁,后者生命周期长但使用频率低。
大白话: 就像酒店房卡和身份证,房卡(Access Token)每天都用但容易过期,身份证(Refresh Token)很少用但可以重新办理房卡。
实现策略:
- Access Token有效期短(如30分钟),用于API访问
- Refresh Token有效期长(如7天),用于获取新的Access Token
- 兼顾安全性(短期token)和用户体验(减少登录频率)
4. 关键代码实现
4.1 数据库设计
首先设计MySQL表结构:
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
salt VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE sessions (
id VARCHAR(36) PRIMARY KEY,
user_id INT NOT NULL,
device_id VARCHAR(255) NOT NULL,
token VARCHAR(512) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
INDEX (user_id, is_active)
);
4.2 Node.js后端实现
4.2.1 基本配置
// server.js
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const mysql = require('mysql2/promise');
const bodyParser = require('body-parser');
const cors = require('cors');
const app = express();
app.use(cors());
app.use(bodyParser.json());
// 配置
const JWT_SECRET = 'your-secret-key';
const JWT_EXPIRY = '1h';
const SESSION_TIMEOUT = 30 * 60 * 1000; // 30分钟
// 数据库连接池
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
password: 'password',
database: 'session_management'
});
4.2.2 用户登录
// 登录接口
app.post('/api/login', async (req, res) => {
const { username, password, deviceId } = req.body;
try {
// 1. 验证用户凭证
const [users] = await pool.query('SELECT * FROM users WHERE username = ?', [username]);
if (users.length === 0) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const user = users[0];
const isValid = await bcrypt.compare(password, user.password_hash);
if (!isValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// 2. 检查并失效同一用户的旧会话
await pool.query(
'UPDATE sessions SET is_active = FALSE WHERE user_id = ? AND is_active = TRUE',
[user.id]
);
// 3. 创建新会话
const sessionId = require('crypto').randomUUID();
const token = jwt.sign(
{ userId: user.id, sessionId, deviceId },
JWT_SECRET,
{ expiresIn: JWT_EXPIRY }
);
await pool.query(
'INSERT INTO sessions (id, user_id, device_id, token, last_active) VALUES (?, ?, ?, ?, NOW())',
[sessionId, user.id, deviceId, token]
);
// 4. 返回响应
res.json({
token,
sessionId,
timeout: SESSION_TIMEOUT,
user: { id: user.id, username: user.username }
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
4.2.3 心跳检测
// 心跳接口
app.post('/api/heartbeat', async (req, res) => {
const { sessionId } = req.body;
const token = req.headers.authorization?.split(' ')[1];
if (!token || !sessionId) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
// 1. 验证token
const decoded = jwt.verify(token, JWT_SECRET);
if (decoded.sessionId !== sessionId) {
return res.status(401).json({ error: 'Invalid session' });
}
// 2. 检查会话状态
const [sessions] = await pool.query(
'SELECT * FROM sessions WHERE id = ? AND user_id = ? AND is_active = TRUE',
[sessionId, decoded.userId]
);
if (sessions.length === 0) {
return res.status(401).json({ error: 'Session expired' });
}
// 3. 更新最后活跃时间
await pool.query(
'UPDATE sessions SET last_active = NOW() WHERE id = ?',
[sessionId]
);
// 4. 返回新的token(延长有效期)
const newToken = jwt.sign(
{ userId: decoded.userId, sessionId, deviceId: decoded.deviceId },
JWT_SECRET,
{ expiresIn: JWT_EXPIRY }
);
res.json({
token: newToken,
sessionId,
timeout: SESSION_TIMEOUT
});
} catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ error: 'Invalid token' });
}
console.error('Heartbeat error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
4.2.4 注销会话
// 注销接口
app.post('/api/logout', async (req, res) => {
const { sessionId } = req.body;
const token = req.headers.authorization?.split(' ')[1];
if (!token || !sessionId) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
// 验证token
const decoded = jwt.verify(token, JWT_SECRET);
if (decoded.sessionId !== sessionId) {
return res.status(401).json({ error: 'Invalid session' });
}
// 标记会话为失效
await pool.query(
'UPDATE sessions SET is_active = FALSE WHERE id = ? AND user_id = ?',
[sessionId, decoded.userId]
);
res.json({ success: true });
} catch (error) {
console.error('Logout error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
4.3 Vue.js前端实现
4.3.1 认证服务 (auth.service.js)
import axios from 'axios';
const API_URL = 'http://localhost:3000/api';
let inactivityTimer;
let heartbeatInterval;
export default {
// 登录
async login(username, password, deviceId) {
try {
const response = await axios.post(`${API_URL}/login`, {
username,
password,
deviceId: deviceId || this.generateDeviceId()
});
this.setupSessionManagement(response.data);
return response.data;
} catch (error) {
console.error('Login failed:', error);
throw error;
}
},
// 生成设备ID
generateDeviceId() {
return 'device_' + Math.random().toString(36).substr(2, 9);
},
// 设置会话管理
setupSessionManagement(sessionData) {
// 存储会话数据
localStorage.setItem('session', JSON.stringify({
token: sessionData.token,
sessionId: sessionData.sessionId,
timeout: sessionData.timeout,
lastActivity: Date.now()
}));
// 清除现有计时器
this.clearTimers();
// 设置心跳
this.setupHeartbeat(sessionData.sessionId, sessionData.token);
// 设置不活跃检测
this.setupInactivityDetection(sessionData.timeout);
},
// 设置心跳
setupHeartbeat(sessionId, token) {
const doHeartbeat = async () => {
try {
const response = await axios.post(
`${API_URL}/heartbeat`,
{ sessionId },
{ headers: { Authorization: `Bearer ${token}` } }
);
// 更新本地存储的token
localStorage.setItem('session', JSON.stringify({
...JSON.parse(localStorage.getItem('session')),
token: response.data.token,
lastActivity: Date.now()
}));
} catch (error) {
if (error.response && error.response.status === 401) {
// 会话已失效
this.logout(true);
}
}
};
// 立即执行一次
doHeartbeat();
// 每5分钟执行一次
heartbeatInterval = setInterval(doHeartbeat, 5 * 60 * 1000);
},
// 设置不活跃检测
setupInactivityDetection(timeout) {
const checkInactivity = () => {
const session = JSON.parse(localStorage.getItem('session'));
if (!session) return;
const timeSinceLastActivity = Date.now() - session.lastActivity;
if (timeSinceLastActivity > timeout) {
this.logout();
}
};
// 每分钟检查一次
inactivityTimer = setInterval(checkInactivity, 60 * 1000);
// 监听用户活动
const updateActivity = () => {
const session = JSON.parse(localStorage.getItem('session'));
if (session) {
localStorage.setItem('session', JSON.stringify({
...session,
lastActivity: Date.now()
}));
}
};
window.addEventListener('mousedown', updateActivity);
window.addEventListener('keypress', updateActivity);
window.addEventListener('scroll', updateActivity, true);
},
// 注销
async logout(silent = false) {
try {
const session = JSON.parse(localStorage.getItem('session'));
if (session) {
await axios.post(
`${API_URL}/logout`,
{ sessionId: session.sessionId },
{ headers: { Authorization: `Bearer ${session.token}` } }
);
}
} catch (error) {
console.error('Logout error:', error);
} finally {
this.clearSession();
if (!silent) {
window.location.href = '/login';
}
}
},
// 清除会话
clearSession() {
localStorage.removeItem('session');
this.clearTimers();
},
// 清除计时器
clearTimers() {
if (inactivityTimer) clearInterval(inactivityTimer);
if (heartbeatInterval) clearInterval(heartbeatInterval);
},
// 获取当前token
getToken() {
const session = JSON.parse(localStorage.getItem('session'));
return session ? session.token : null;
},
// 检查是否已认证
isAuthenticated() {
return !!this.getToken();
}
};
4.3.2 登录组件 (Login.vue)
<template>
<div class="login-container">
<h2>Login</h2>
<form @submit.prevent="handleLogin">
<div class="form-group">
<label>Username</label>
<input v-model="username" type="text" required>
</div>
<div class="form-group">
<label>Password</label>
<input v-model="password" type="password" required>
</div>
<button type="submit" :disabled="loading">
{{ loading ? 'Logging in...' : 'Login' }}
</button>
<div v-if="error" class="error-message">{{ error }}</div>
</form>
</div>
</template>
<script>
import authService from '../services/auth.service';
export default {
data() {
return {
username: '',
password: '',
loading: false,
error: ''
};
},
methods: {
async handleLogin() {
this.loading = true;
this.error = '';
try {
await authService.login(this.username, this.password);
this.$router.push('/dashboard');
} catch (error) {
this.error = error.response?.data?.error || 'Login failed';
} finally {
this.loading = false;
}
}
}
};
</script>
4.3.3 主应用组件 (App.vue)
<template>
<div id="app">
<router-view v-if="isRouterAlive"/>
</div>
</template>
<script>
import authService from './services/auth.service';
export default {
name: 'App',
provide() {
return {
reload: this.reload
};
},
data() {
return {
isRouterAlive: true
};
},
created() {
// 初始化时检查认证状态
if (authService.isAuthenticated()) {
const session = JSON.parse(localStorage.getItem('session'));
authService.setupSessionManagement(session);
}
// 监听401错误(会话失效)
this.setupAxiosInterceptor();
},
methods: {
reload() {
this.isRouterAlive = false;
this.$nextTick(() => {
this.isRouterAlive = true;
});
},
setupAxiosInterceptor() {
const axios = require('axios');
axios.interceptors.response.use(
response => response,
error => {
if (error.response && error.response.status === 401) {
authService.logout(true);
if (this.$route.path !== '/login') {
this.$router.push('/login');
}
}
return Promise.reject(error);
}
);
}
}
};
</script>
4.3.4 受保护路由 (router.js)
import Vue from 'vue';
import Router from 'vue-router';
import authService from './services/auth.service';
import Login from './views/Login.vue';
import Dashboard from './views/Dashboard.vue';
Vue.use(Router);
const router = new Router({
routes: [
{
path: '/login',
name: 'login',
component: Login
},
{
path: '/dashboard',
name: 'dashboard',
component: Dashboard,
meta: { requiresAuth: true }
},
{
path: '*',
redirect: '/dashboard'
}
]
});
router.beforeEach((to, from, next) => {
if (to.matched.some(record => record.meta.requiresAuth)) {
if (!authService.isAuthenticated()) {
next({
path: '/login',
query: { redirect: to.fullPath }
});
} else {
next();
}
} else {
next();
}
});
export default router;
5. 关键点说明
- JWT Token:用于认证,包含用户ID和会话ID
- 会话ID:唯一标识每个会话,用于设备互踢
- 设备ID:识别不同设备,同一设备可以保持登录状态
- 双重超时检测:服务端基于last_active检测,客户端基于本地计时器检测
- 心跳机制:保持会话活跃,同时检测会话状态变化
- 离线处理:客户端独立处理超时,不依赖服务端响应
5.1 业务痛点与难点
-
用户体验与安全的平衡
- 痛点:频繁的自动登出影响用户体验,不登出又存在安全风险
- 解决方案:采用滑动过期+警告提示机制,给用户缓冲时间
-
多设备登录冲突
- 痛点:同一账号在不同设备登录时的处理策略
- 解决方案:提供配置选项(允许/不允许多设备登录),后登录的设备踢出前一个
-
网络不稳定的误判
- 痛点:弱网环境下心跳可能失败,导致误判为不活跃
- 解决方案:客户端本地也检测用户操作,结合双端判断
-
Token失效的平滑处理
- 痛点:Token过期时如何让用户无感知重新登录
- 解决方案:使用Refresh Token机制自动续期
-
敏感操作的额外验证
- 痛点:自动登出前用户可能正在填写重要表单
- 解决方案:检测到敏感操作时延长会话时间或暂缓登出
5.2 面试考察点
-
基础概念理解
- Session与Token的区别
- JWT的组成和工作原理
- 滑动过期与绝对过期的应用场景
-
系统设计能力
- 如何设计一个高可用的会话管理系统
- 心跳机制的具体实现方案
- 多设备登录的冲突解决策略
-
安全考量
- Token泄露的防范措施
- 如何防止Token重放攻击
- 敏感操作的额外保护机制
-
性能优化
- 会话存储的选型与优化(Redis)
- 高并发下的会话管理策略
- 心跳检测的性能影响与优化
-
异常处理
- 网络不稳定的容错处理
- 客户端与服务端时间不同步问题
- 自动登出时的数据保存策略
-
用户体验
- 如何减少自动登出对用户的干扰
- 登出前的友好提示设计
- 重新登录后的状态恢复方案