feat: add online device management and desktop settings integration
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user