From 168bdb4178691ba112d3dae205267c1e7bf7f73e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 16:19:34 +0000 Subject: [PATCH] Implement multi-device login support - allow multiple concurrent sessions Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- back/api/user.ts | 5 +- back/data/system.ts | 11 ++++- back/loaders/express.ts | 21 +++++++- back/loaders/sock.ts | 32 ++++++++++++- back/services/user.ts | 103 +++++++++++++++++++++++++++++++++++++--- 5 files changed, 160 insertions(+), 12 deletions(-) diff --git a/back/api/user.ts b/back/api/user.ts index 9da5ef0d..bed0fd78 100644 --- a/back/api/user.ts +++ b/back/api/user.ts @@ -8,7 +8,7 @@ import path from 'path'; import { v4 as uuidV4 } from 'uuid'; import rateLimit from 'express-rate-limit'; import config from '../config'; -import { isDemoEnv } from '../config/util'; +import { isDemoEnv, getToken } from '../config/util'; const route = Router(); const storage = multer.diskStorage({ @@ -56,7 +56,8 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const userService = Container.get(UserService); - await userService.logout(req.platform); + const token = getToken(req); + await userService.logout(req.platform, token); res.send({ code: 200 }); } catch (e) { return next(e); diff --git a/back/data/system.ts b/back/data/system.ts index e63d42e7..87469fa8 100644 --- a/back/data/system.ts +++ b/back/data/system.ts @@ -48,6 +48,15 @@ export interface LoginLogInfo { status?: LoginStatus; } +export interface TokenInfo { + value: string; + timestamp: number; + ip: string; + address: string; + platform: string; + expiration?: number; +} + export interface AuthInfo { username: string; password: string; @@ -58,7 +67,7 @@ export interface AuthInfo { platform: string; isTwoFactorChecking: boolean; token: string; - tokens: Record; + tokens: Record; twoFactorActivated: boolean; twoFactorSecret: string; avatar: string; diff --git a/back/loaders/express.ts b/back/loaders/express.ts index 41c98398..ae672717 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -79,9 +79,28 @@ export default ({ app }: { app: Application }) => { const authInfo = await shareStore.getAuthInfo(); if (authInfo && headerToken) { const { token = '', tokens = {} } = authInfo; - if (headerToken === token || tokens[req.platform] === headerToken) { + + // Check legacy token field + if (headerToken === token) { return next(); } + + // Check platform-specific tokens (support both legacy string and new TokenInfo[] format) + const platformTokens = tokens[req.platform]; + if (platformTokens) { + if (typeof platformTokens === 'string') { + // Legacy format: single string token + if (headerToken === platformTokens) { + return next(); + } + } else if (Array.isArray(platformTokens)) { + // New format: array of TokenInfo objects + const tokenExists = platformTokens.some((t) => t.value === headerToken); + if (tokenExists) { + return next(); + } + } + } } const errorCode = headerToken ? 'invalid_token' : 'credentials_required'; diff --git a/back/loaders/sock.ts b/back/loaders/sock.ts index d7db3d97..7a202f0f 100644 --- a/back/loaders/sock.ts +++ b/back/loaders/sock.ts @@ -19,7 +19,9 @@ export default async ({ server }: { server: Server }) => { const headerToken = conn.url.replace(`${conn.pathname}?token=`, ''); if (authInfo) { const { token = '', tokens = {} } = authInfo; - if (headerToken === token || tokens[platform] === headerToken) { + + // Check legacy token field + if (headerToken === token) { sockService.addClient(conn); conn.on('data', (message) => { @@ -32,6 +34,34 @@ export default async ({ server }: { server: Server }) => { return; } + + // Check platform-specific tokens (support both legacy string and new TokenInfo[] format) + const platformTokens = tokens[platform]; + if (platformTokens) { + let isValidToken = false; + + if (typeof platformTokens === 'string') { + // Legacy format: single string token + isValidToken = headerToken === platformTokens; + } else if (Array.isArray(platformTokens)) { + // New format: array of TokenInfo objects + isValidToken = platformTokens.some((t) => t.value === headerToken); + } + + if (isValidToken) { + sockService.addClient(conn); + + conn.on('data', (message) => { + conn.write(message); + }); + + conn.on('close', function () { + sockService.removeClient(conn); + }); + + return; + } + } } conn.close('404'); diff --git a/back/services/user.ts b/back/services/user.ts index 604b3c82..47c04ecf 100644 --- a/back/services/user.ts +++ b/back/services/user.ts @@ -11,6 +11,7 @@ import { SystemModelInfo, LoginStatus, AuthInfo, + TokenInfo, } from '../data/system'; import { NotificationInfo } from '../data/notify'; import NotificationService from './notify'; @@ -101,12 +102,19 @@ export default class UserService { algorithm: 'HS384', }); + const tokenInfo: TokenInfo = { + value: token, + timestamp, + ip, + address, + platform: req.platform, + }; + + const updatedTokens = this.addTokenToList(tokens, req.platform, tokenInfo); + await this.updateAuthInfo(content, { token, - tokens: { - ...tokens, - [req.platform]: token, - }, + tokens: updatedTokens, lastlogon: timestamp, retries: 0, lastip: ip, @@ -180,11 +188,13 @@ export default class UserService { } } - public async logout(platform: string): Promise { + public async logout(platform: string, tokenValue: string): Promise { const authInfo = await this.getAuthInfo(); + const updatedTokens = this.removeTokenFromList(authInfo.tokens, platform, tokenValue); + await this.updateAuthInfo(authInfo, { - token: '', - tokens: { ...authInfo.tokens, [platform]: '' }, + token: authInfo.token === tokenValue ? '' : authInfo.token, + tokens: updatedTokens, }); } @@ -364,6 +374,85 @@ export default class UserService { } } + private normalizeTokens(tokens: Record): Record { + const normalized: Record = {}; + + for (const [platform, value] of Object.entries(tokens)) { + if (typeof value === 'string') { + // Legacy format: convert string token to TokenInfo array + if (value) { + normalized[platform] = [{ + value, + timestamp: Date.now(), + ip: '', + address: '', + platform, + }]; + } else { + normalized[platform] = []; + } + } else { + // Already in new format + normalized[platform] = value || []; + } + } + + return normalized; + } + + private addTokenToList( + tokens: Record, + platform: string, + tokenInfo: TokenInfo, + maxTokensPerPlatform: number = 10 + ): Record { + const normalized = this.normalizeTokens(tokens); + + if (!normalized[platform]) { + normalized[platform] = []; + } + + // Add new token + normalized[platform].unshift(tokenInfo); + + // Limit the number of active tokens per platform + if (normalized[platform].length > maxTokensPerPlatform) { + normalized[platform] = normalized[platform].slice(0, maxTokensPerPlatform); + } + + return normalized; + } + + private removeTokenFromList( + tokens: Record, + platform: string, + tokenValue: string + ): Record { + const normalized = this.normalizeTokens(tokens); + + if (normalized[platform]) { + normalized[platform] = normalized[platform].filter( + (t) => t.value !== tokenValue + ); + } + + return normalized; + } + + private findTokenInList( + tokens: Record, + platform: string, + tokenValue: string + ): TokenInfo | undefined { + const normalized = this.normalizeTokens(tokens); + + if (normalized[platform]) { + return normalized[platform].find((t) => t.value === tokenValue); + } + + return undefined; + } + public async resetAuthInfo(info: Partial) { const { retries, twoFactorActivated, password, username } = info; const authInfo = await this.getAuthInfo();