feat: add online device management and desktop settings integration

This commit is contained in:
2026-02-19 17:34:41 +08:00
parent 365ada1a4a
commit 19f53875c9
7 changed files with 1070 additions and 48 deletions

View File

@@ -1,6 +1,6 @@
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const { UserDB } = require('./database');
const { UserDB, DeviceSessionDB } = require('./database');
const { decryptSecret } = require('./utils/encryption');
const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
const DEFAULT_OSS_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
@@ -64,13 +64,16 @@ if (JWT_SECRET.length < 32) {
console.log('[安全] ✓ JWT密钥验证通过');
// 生成Access Token短期
function generateToken(user) {
function generateToken(user, sessionId = null) {
const safeSessionId = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : null;
return jwt.sign(
{
id: user.id,
username: user.username,
is_admin: user.is_admin,
type: 'access'
type: 'access',
sid: safeSessionId,
jti: crypto.randomBytes(12).toString('hex')
},
JWT_SECRET,
{ expiresIn: ACCESS_TOKEN_EXPIRES }
@@ -78,11 +81,13 @@ function generateToken(user) {
}
// 生成Refresh Token长期
function generateRefreshToken(user) {
function generateRefreshToken(user, sessionId = null) {
const safeSessionId = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : null;
return jwt.sign(
{
id: user.id,
type: 'refresh',
sid: safeSessionId,
// 添加随机标识使每次生成的refresh token不同
jti: crypto.randomBytes(16).toString('hex')
},
@@ -91,8 +96,26 @@ function generateRefreshToken(user) {
);
}
function decodeAccessToken(token) {
if (!token || typeof token !== 'string') return null;
try {
return jwt.verify(token, JWT_SECRET);
} catch {
return null;
}
}
function decodeRefreshToken(token) {
if (!token || typeof token !== 'string') return null;
try {
return jwt.verify(token, REFRESH_SECRET);
} catch {
return null;
}
}
// 验证Refresh Token并返回新的Access Token
function refreshAccessToken(refreshToken) {
function refreshAccessToken(refreshToken, context = {}) {
try {
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
@@ -100,6 +123,18 @@ function refreshAccessToken(refreshToken) {
return { success: false, message: '无效的刷新令牌类型' };
}
const sessionId = typeof decoded.sid === 'string' ? decoded.sid.trim() : '';
if (sessionId) {
const activeSession = DeviceSessionDB.findActiveBySessionId(sessionId);
if (!activeSession || Number(activeSession.user_id) !== Number(decoded.id)) {
return { success: false, message: '当前设备会话已失效,请重新登录' };
}
DeviceSessionDB.touch(sessionId, {
ipAddress: context.ipAddress,
userAgent: context.userAgent
});
}
const user = UserDB.findById(decoded.id);
if (!user) {
@@ -115,11 +150,12 @@ function refreshAccessToken(refreshToken) {
}
// 生成新的access token
const newAccessToken = generateToken(user);
const newAccessToken = generateToken(user, sessionId || null);
return {
success: true,
token: newAccessToken,
sessionId: sessionId || null,
user: {
id: user.id,
username: user.username,
@@ -148,6 +184,29 @@ function authMiddleware(req, res, next) {
try {
const decoded = jwt.verify(token, JWT_SECRET);
if (decoded.type !== 'access') {
return res.status(401).json({
success: false,
message: '无效的令牌类型'
});
}
const sessionId = typeof decoded.sid === 'string' ? decoded.sid.trim() : '';
if (sessionId) {
const activeSession = DeviceSessionDB.findActiveBySessionId(sessionId);
if (!activeSession || Number(activeSession.user_id) !== Number(decoded.id)) {
return res.status(401).json({
success: false,
message: '当前设备已下线,请重新登录'
});
}
DeviceSessionDB.touch(sessionId, {
ipAddress: req.ip || req.socket?.remoteAddress || '',
userAgent: req.get('user-agent') || ''
});
}
const user = UserDB.findById(decoded.id);
if (!user) {
@@ -217,6 +276,8 @@ function authMiddleware(req, res, next) {
// 主题偏好
theme_preference: user.theme_preference || null
};
req.authSessionId = sessionId || null;
req.authTokenJti = typeof decoded.jti === 'string' ? decoded.jti : null;
next();
} catch (error) {
@@ -329,6 +390,8 @@ module.exports = {
JWT_SECRET,
generateToken,
generateRefreshToken,
decodeAccessToken,
decodeRefreshToken,
refreshAccessToken,
authMiddleware,
adminMiddleware,

View File

@@ -507,6 +507,27 @@ function initDatabase() {
)
`);
// 在线设备会话(用于设备管理和强制下线)
db.exec(`
CREATE TABLE IF NOT EXISTS user_device_sessions (
session_id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
client_type TEXT NOT NULL DEFAULT 'web',
device_id TEXT,
device_name TEXT,
platform TEXT,
ip_address TEXT,
user_agent TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_active_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
revoked_at DATETIME,
revoked_reason TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
`);
// 文件哈希索引(用于秒传)
db.exec(`
CREATE TABLE IF NOT EXISTS user_file_hash_index (
@@ -554,6 +575,12 @@ function initDatabase() {
CREATE INDEX IF NOT EXISTS idx_upload_sessions_hash
ON upload_sessions(user_id, file_hash, file_size);
-- 在线设备会话索引
CREATE INDEX IF NOT EXISTS idx_user_device_sessions_user_active
ON user_device_sessions(user_id, revoked_at, expires_at, last_active_at);
CREATE INDEX IF NOT EXISTS idx_user_device_sessions_expires
ON user_device_sessions(expires_at);
-- 秒传索引
CREATE INDEX IF NOT EXISTS idx_file_hash_index_lookup
ON user_file_hash_index(user_id, storage_type, file_hash, file_size);
@@ -2615,6 +2642,169 @@ const UploadSessionDB = {
}
};
const DeviceSessionDB = {
_normalizeSessionId(sessionId) {
return typeof sessionId === 'string' ? sessionId.trim() : '';
},
_normalizeText(value, maxLength = 255) {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
return trimmed.slice(0, maxLength);
},
_normalizeClientType(value) {
const normalized = String(value || '').trim().toLowerCase();
if (['web', 'desktop', 'mobile', 'api'].includes(normalized)) {
return normalized;
}
return 'web';
},
create(data = {}) {
const sessionId = this._normalizeSessionId(data.sessionId);
const userId = Number(data.userId);
const clientType = this._normalizeClientType(data.clientType);
const deviceId = this._normalizeText(data.deviceId, 128);
const deviceName = this._normalizeText(data.deviceName, 120);
const platform = this._normalizeText(data.platform, 80);
const ipAddress = this._normalizeText(data.ipAddress, 80);
const userAgent = this._normalizeText(data.userAgent, 1024);
const ttlDays = Math.max(1, Math.min(30, Number(data.ttlDays || 7)));
if (!sessionId || !Number.isFinite(userId) || userId <= 0) {
return null;
}
db.prepare(`
INSERT INTO user_device_sessions (
session_id, user_id, client_type, device_id, device_name,
platform, ip_address, user_agent, created_at, last_active_at,
expires_at, revoked_at, revoked_reason, updated_at
) VALUES (
?, ?, ?, ?, ?,
?, ?, ?, datetime('now', 'localtime'), datetime('now', 'localtime'),
datetime('now', 'localtime', '+' || ? || ' days'), NULL, NULL, datetime('now', 'localtime')
)
ON CONFLICT(session_id) DO UPDATE SET
user_id = excluded.user_id,
client_type = excluded.client_type,
device_id = excluded.device_id,
device_name = excluded.device_name,
platform = excluded.platform,
ip_address = excluded.ip_address,
user_agent = excluded.user_agent,
last_active_at = datetime('now', 'localtime'),
expires_at = datetime('now', 'localtime', '+' || ? || ' days'),
revoked_at = NULL,
revoked_reason = NULL,
updated_at = datetime('now', 'localtime')
`).run(
sessionId,
Math.floor(userId),
clientType,
deviceId,
deviceName,
platform,
ipAddress,
userAgent,
ttlDays,
ttlDays
);
return this.findBySessionId(sessionId);
},
findBySessionId(sessionId) {
const sid = this._normalizeSessionId(sessionId);
if (!sid) return null;
return db.prepare('SELECT * FROM user_device_sessions WHERE session_id = ?').get(sid);
},
findActiveBySessionId(sessionId) {
const sid = this._normalizeSessionId(sessionId);
if (!sid) return null;
return db.prepare(`
SELECT *
FROM user_device_sessions
WHERE session_id = ?
AND revoked_at IS NULL
AND expires_at > datetime('now', 'localtime')
LIMIT 1
`).get(sid);
},
listActiveByUser(userId, limit = 60) {
const uid = Number(userId);
if (!Number.isFinite(uid) || uid <= 0) return [];
const safeLimit = Math.max(1, Math.min(200, Math.floor(Number(limit) || 60)));
return db.prepare(`
SELECT *
FROM user_device_sessions
WHERE user_id = ?
AND revoked_at IS NULL
AND expires_at > datetime('now', 'localtime')
ORDER BY datetime(COALESCE(last_active_at, created_at)) DESC, created_at DESC
LIMIT ?
`).all(Math.floor(uid), safeLimit);
},
touch(sessionId, options = {}) {
const sid = this._normalizeSessionId(sessionId);
if (!sid) return { changes: 0 };
const ipAddress = this._normalizeText(options.ipAddress, 80);
const userAgent = this._normalizeText(options.userAgent, 1024);
return db.prepare(`
UPDATE user_device_sessions
SET ip_address = COALESCE(?, ip_address),
user_agent = COALESCE(?, user_agent),
last_active_at = datetime('now', 'localtime'),
updated_at = datetime('now', 'localtime')
WHERE session_id = ?
AND revoked_at IS NULL
AND expires_at > datetime('now', 'localtime')
`).run(ipAddress, userAgent, sid);
},
revoke(sessionId, userId = null, reason = 'manual') {
const sid = this._normalizeSessionId(sessionId);
if (!sid) return { changes: 0 };
const revokeReason = this._normalizeText(reason, 120) || 'manual';
if (userId !== null && userId !== undefined) {
const uid = Number(userId);
if (!Number.isFinite(uid) || uid <= 0) return { changes: 0 };
return db.prepare(`
UPDATE user_device_sessions
SET revoked_at = datetime('now', 'localtime'),
revoked_reason = ?,
updated_at = datetime('now', 'localtime')
WHERE session_id = ?
AND user_id = ?
AND revoked_at IS NULL
`).run(revokeReason, sid, Math.floor(uid));
}
return db.prepare(`
UPDATE user_device_sessions
SET revoked_at = datetime('now', 'localtime'),
revoked_reason = ?,
updated_at = datetime('now', 'localtime')
WHERE session_id = ?
AND revoked_at IS NULL
`).run(revokeReason, sid);
},
cleanupExpired(keepDays = 30) {
const safeDays = Math.max(1, Math.min(365, Math.floor(Number(keepDays) || 30)));
return db.prepare(`
DELETE FROM user_device_sessions
WHERE (expires_at <= datetime('now', 'localtime', '-' || ? || ' days'))
OR (revoked_at IS NOT NULL AND revoked_at <= datetime('now', 'localtime', '-' || ? || ' days'))
`).run(safeDays, safeDays);
}
};
const FileHashIndexDB = {
normalizeStorageType(storageType) {
return storageType === 'oss' ? 'oss' : 'local';
@@ -2960,6 +3150,7 @@ module.exports = {
DownloadTrafficReportDB,
DownloadTrafficReservationDB,
UploadSessionDB,
DeviceSessionDB,
FileHashIndexDB,
DownloadTrafficIngestDB,
SystemLogDB,

View File

@@ -74,6 +74,7 @@ const {
DownloadTrafficReportDB,
DownloadTrafficReservationDB,
UploadSessionDB,
DeviceSessionDB,
FileHashIndexDB,
DownloadTrafficIngestDB,
SystemLogDB,
@@ -81,7 +82,17 @@ const {
WalManager
} = require('./database');
const StorageUsageCache = require('./utils/storage-cache');
const { JWT_SECRET, generateToken, generateRefreshToken, refreshAccessToken, authMiddleware, adminMiddleware, isJwtSecretSecure } = require('./auth');
const {
JWT_SECRET,
generateToken,
generateRefreshToken,
decodeAccessToken,
decodeRefreshToken,
refreshAccessToken,
authMiddleware,
adminMiddleware,
isJwtSecretSecure
} = require('./auth');
const { StorageInterface, LocalStorageClient, OssStorageClient, formatFileSize, formatOssError } = require('./storage');
const { encryptSecret, decryptSecret } = require('./utils/encryption');
@@ -2083,6 +2094,34 @@ setTimeout(() => {
cleanupExpiredUploadSessions('startup');
}, 20 * 1000);
const deviceSessionSweepTimer = setInterval(() => {
try {
const result = DeviceSessionDB.cleanupExpired(30);
const cleaned = Number(result?.changes || 0);
if (cleaned > 0) {
console.log(`[在线设备] 已清理过期会话 ${cleaned}`);
}
} catch (error) {
console.error('[在线设备] 清理过期会话失败:', error);
}
}, 6 * 60 * 60 * 1000);
if (deviceSessionSweepTimer && typeof deviceSessionSweepTimer.unref === 'function') {
deviceSessionSweepTimer.unref();
}
setTimeout(() => {
try {
const result = DeviceSessionDB.cleanupExpired(30);
const cleaned = Number(result?.changes || 0);
if (cleaned > 0) {
console.log(`[在线设备] 启动清理过期会话 ${cleaned}`);
}
} catch (error) {
console.error('[在线设备] 启动清理过期会话失败:', error);
}
}, 25 * 1000);
// 构建用于存储客户端的用户对象(自动尝试解密 OSS Secret
function buildStorageUserContext(user, overrides = {}) {
if (!user) {
@@ -2852,6 +2891,85 @@ function detectDeviceTypeFromUserAgent(userAgent = '') {
return mobilePattern.test(ua) ? 'mobile' : 'desktop';
}
function inferPlatformFromUserAgent(userAgent = '') {
const ua = String(userAgent || '');
if (!ua) return '未知平台';
if (/windows/i.test(ua)) return 'Windows';
if (/macintosh|mac os x/i.test(ua)) return 'macOS';
if (/android/i.test(ua)) return 'Android';
if (/iphone|ipad|ios/i.test(ua)) return 'iOS';
if (/linux/i.test(ua)) return 'Linux';
return '未知平台';
}
function normalizeClientType(value = '') {
const normalized = String(value || '').trim().toLowerCase();
if (['web', 'desktop', 'mobile', 'api'].includes(normalized)) {
return normalized;
}
return '';
}
function resolveClientType(clientType, userAgent = '') {
const normalized = normalizeClientType(clientType);
if (normalized) return normalized;
const ua = String(userAgent || '').toLowerCase();
if (ua.includes('tauri') || ua.includes('electron') || ua.includes('wanwan-cloud-desktop')) {
return 'desktop';
}
return detectDeviceTypeFromUserAgent(ua) === 'mobile' ? 'mobile' : 'web';
}
function sanitizeDeviceText(value, maxLength = 120) {
if (typeof value !== 'string') return '';
return value.trim().slice(0, maxLength);
}
function buildDeviceName({ clientType, deviceName, platform }) {
const preferred = sanitizeDeviceText(deviceName, 120);
if (preferred) return preferred;
const platformText = sanitizeDeviceText(platform, 80) || '未知平台';
if (clientType === 'desktop') return `桌面客户端 · ${platformText}`;
if (clientType === 'mobile') return `移动端浏览器 · ${platformText}`;
if (clientType === 'api') return `API 客户端 · ${platformText}`;
return `网页端浏览器 · ${platformText}`;
}
function buildDeviceSessionContext(req, payload = {}) {
const userAgent = sanitizeDeviceText(req.get('user-agent') || req.headers?.['user-agent'] || '', 1024);
const clientType = resolveClientType(payload.client_type || req.headers?.['x-client-type'], userAgent);
const platform = sanitizeDeviceText(payload.platform, 80) || inferPlatformFromUserAgent(userAgent);
return {
clientType,
deviceId: sanitizeDeviceText(payload.device_id, 128) || null,
deviceName: buildDeviceName({
clientType,
deviceName: payload.device_name,
platform
}),
platform,
ipAddress: sanitizeDeviceText(getClientIp(req), 80) || null,
userAgent
};
}
function formatOnlineDeviceRecord(row, currentSessionId = '') {
const sid = String(row?.session_id || '');
const isCurrent = !!currentSessionId && sid === currentSessionId;
return {
session_id: sid,
client_type: String(row?.client_type || 'web'),
device_name: String(row?.device_name || '未知设备'),
platform: String(row?.platform || ''),
ip_address: String(row?.ip_address || ''),
last_active_at: row?.last_active_at || row?.updated_at || row?.created_at || null,
created_at: row?.created_at || null,
expires_at: row?.expires_at || null,
is_current: isCurrent,
is_local: isCurrent
};
}
function normalizeTimeHHmm(value) {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
@@ -3952,7 +4070,7 @@ app.post('/api/login',
});
}
const { username, password, captcha } = req.body;
const { username, password, captcha, client_type, device_id, device_name, platform } = req.body;
try {
// 检查是否需要验证码
@@ -4085,8 +4203,32 @@ app.post('/api/login',
user = loginPolicyState.user;
}
const token = generateToken(user);
const refreshToken = generateRefreshToken(user);
const deviceContext = buildDeviceSessionContext(req, {
client_type,
device_id,
device_name,
platform
});
let sessionId = null;
try {
const createdSession = DeviceSessionDB.create({
sessionId: crypto.randomBytes(24).toString('hex'),
userId: user.id,
clientType: deviceContext.clientType,
deviceId: deviceContext.deviceId,
deviceName: deviceContext.deviceName,
platform: deviceContext.platform,
ipAddress: deviceContext.ipAddress,
userAgent: deviceContext.userAgent,
ttlDays: 7
});
sessionId = createdSession?.session_id || null;
} catch (sessionError) {
console.error('[在线设备] 创建登录会话失败:', sessionError.message);
}
const token = generateToken(user, sessionId);
const refreshToken = generateRefreshToken(user, sessionId);
// 清除失败记录
if (req.rateLimitKeys) {
@@ -4173,7 +4315,10 @@ app.post('/api/refresh-token', (req, res) => {
});
}
const result = refreshAccessToken(refreshToken);
const result = refreshAccessToken(refreshToken, {
ipAddress: getClientIp(req),
userAgent: req.get('user-agent') || ''
});
if (!result.success) {
return res.status(401).json({
@@ -4200,6 +4345,20 @@ app.post('/api/refresh-token', (req, res) => {
// 登出清除Cookie
app.post('/api/logout', (req, res) => {
const accessToken = req.headers.authorization?.replace('Bearer ', '') || req.cookies?.token || '';
const refreshToken = req.cookies?.refreshToken || '';
const accessPayload = decodeAccessToken(accessToken);
const refreshPayload = decodeRefreshToken(refreshToken);
const sessionId = String(accessPayload?.sid || refreshPayload?.sid || '').trim();
if (sessionId) {
try {
DeviceSessionDB.revoke(sessionId, null, 'logout');
} catch (error) {
console.error('[在线设备] 登出时撤销会话失败:', error.message);
}
}
// 清除所有认证Cookie
res.clearCookie('token', { path: '/' });
res.clearCookie('refreshToken', { path: '/' });
@@ -4246,6 +4405,87 @@ app.get('/api/user/profile', authMiddleware, (req, res) => {
});
});
// 在线设备列表
app.get('/api/user/online-devices', authMiddleware, (req, res) => {
try {
const currentSessionId = String(req.authSessionId || '').trim();
const devices = DeviceSessionDB
.listActiveByUser(req.user.id, 80)
.map((row) => formatOnlineDeviceRecord(row, currentSessionId));
res.json({
success: true,
devices,
current_session_id: currentSessionId || null
});
} catch (error) {
console.error('获取在线设备失败:', error);
res.status(500).json({
success: false,
message: '获取在线设备失败'
});
}
});
// 强制踢下线(可踢其它设备,也可踢当前设备)
app.post('/api/user/online-devices/:sessionId/kick', authMiddleware, (req, res) => {
try {
const sessionId = String(req.params.sessionId || '').trim();
if (!sessionId || sessionId.length < 16 || sessionId.length > 128) {
return res.status(400).json({
success: false,
message: '会话标识无效'
});
}
const target = DeviceSessionDB.findBySessionId(sessionId);
if (!target || Number(target.user_id) !== Number(req.user.id)) {
return res.status(404).json({
success: false,
message: '目标设备不存在'
});
}
if (target.revoked_at) {
return res.status(409).json({
success: false,
message: '设备已离线'
});
}
const revokeResult = DeviceSessionDB.revoke(sessionId, req.user.id, 'kicked_by_user');
if (!(Number(revokeResult?.changes || 0) > 0)) {
return res.status(409).json({
success: false,
message: '设备已离线'
});
}
const kickedCurrent = String(req.authSessionId || '') === sessionId;
if (kickedCurrent) {
res.clearCookie('token', { path: '/' });
res.clearCookie('refreshToken', { path: '/' });
}
logAuth(req, 'device_kick', `用户踢下线设备: ${sessionId}`, {
userId: req.user.id,
kickedCurrent
});
res.json({
success: true,
kicked_current: kickedCurrent,
message: kickedCurrent ? '当前设备已下线' : '设备已下线'
});
} catch (error) {
console.error('踢设备下线失败:', error);
res.status(500).json({
success: false,
message: '踢设备下线失败'
});
}
});
// 获取用户下载流量额度与报表
app.get('/api/user/download-traffic-report', authMiddleware, (req, res) => {
try {