From 48abf44ceb5980cdc65cac05b8e5487c32da92d1 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Nov 2025 00:18:29 +0800 Subject: [PATCH] feat: Support multiple concurrent login sessions per platform (#2816) * Initial plan * Implement multi-device login support - allow multiple concurrent sessions Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> * Address code review feedback - extract constants and utility functions Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> * Add validation and logging improvements based on code review Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> * Revert unnecessary file changes - keep only multi-device login feature files Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- back/api/user.ts | 5 +- back/config/index.ts | 1 + back/data/system.ts | 15 ++++- back/loaders/express.ts | 8 +-- back/loaders/sock.ts | 23 +++---- back/services/user.ts | 146 ++++++++++++++++++++++++++++++++++++++-- back/shared/auth.ts | 46 +++++++++++++ 7 files changed, 217 insertions(+), 27 deletions(-) create mode 100644 back/shared/auth.ts 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/config/index.ts b/back/config/index.ts index 7e4d1623..76eb08c6 100644 --- a/back/config/index.ts +++ b/back/config/index.ts @@ -176,4 +176,5 @@ export default { sshdPath, systemLogPath, dependenceCachePath, + maxTokensPerPlatform: 10, // Maximum number of concurrent sessions per platform }; diff --git a/back/data/system.ts b/back/data/system.ts index e63d42e7..84b6aae2 100644 --- a/back/data/system.ts +++ b/back/data/system.ts @@ -48,6 +48,19 @@ export interface LoginLogInfo { status?: LoginStatus; } +export interface TokenInfo { + value: string; + timestamp: number; + ip: string; + address: string; + platform: string; + /** + * Token expiration time in seconds since Unix epoch. + * If undefined, the token uses JWT's built-in expiration. + */ + expiration?: number; +} + export interface AuthInfo { username: string; password: string; @@ -58,7 +71,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..f7d4a65b 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -9,6 +9,7 @@ import rewrite from 'express-urlrewrite'; import { errors } from 'celebrate'; import { serveEnv } from '../config/serverEnv'; import { IKeyvStore, shareStore } from '../shared/store'; +import { isValidToken } from '../shared/auth'; import path from 'path'; export default ({ app }: { app: Application }) => { @@ -77,11 +78,8 @@ export default ({ app }: { app: Application }) => { } const authInfo = await shareStore.getAuthInfo(); - if (authInfo && headerToken) { - const { token = '', tokens = {} } = authInfo; - if (headerToken === token || tokens[req.platform] === headerToken) { - return next(); - } + if (isValidToken(authInfo, headerToken, req.platform)) { + return next(); } const errorCode = headerToken ? 'invalid_token' : 'credentials_required'; diff --git a/back/loaders/sock.ts b/back/loaders/sock.ts index d7db3d97..2d1eb8a7 100644 --- a/back/loaders/sock.ts +++ b/back/loaders/sock.ts @@ -4,6 +4,7 @@ import { Container } from 'typedi'; import SockService from '../services/sock'; import { getPlatform } from '../config/util'; import { shareStore } from '../shared/store'; +import { isValidToken } from '../shared/auth'; export default async ({ server }: { server: Server }) => { const echo = sockJs.createServer({ prefix: '/api/ws', log: () => {} }); @@ -17,21 +18,19 @@ export default async ({ server }: { server: Server }) => { const authInfo = await shareStore.getAuthInfo(); const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop'; const headerToken = conn.url.replace(`${conn.pathname}?token=`, ''); - if (authInfo) { - const { token = '', tokens = {} } = authInfo; - if (headerToken === token || tokens[platform] === headerToken) { - sockService.addClient(conn); - conn.on('data', (message) => { - conn.write(message); - }); + if (isValidToken(authInfo, headerToken, platform)) { + sockService.addClient(conn); - conn.on('close', function () { - sockService.removeClient(conn); - }); + conn.on('data', (message) => { + conn.write(message); + }); - return; - } + 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..068b850c 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,23 @@ 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 +192,37 @@ export default class UserService { } } - public async logout(platform: string): Promise { + public async logout(platform: string, tokenValue: string): Promise { + if (!platform || !tokenValue) { + this.logger.warn('Invalid logout parameters - empty platform or token'); + return; + } + const authInfo = await this.getAuthInfo(); + + // Verify the token exists before attempting to remove it + const tokenExists = this.findTokenInList( + authInfo.tokens, + platform, + tokenValue, + ); + if (!tokenExists && authInfo.token !== tokenValue) { + // Token not found, but don't throw error - user may have already logged out + this.logger.info( + `Logout attempted for non-existent token on platform: ${platform}`, + ); + return; + } + + 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 +402,100 @@ 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 = config.maxTokensPerPlatform, + ): Record { + // Validate maxTokensPerPlatform parameter + if (!Number.isInteger(maxTokensPerPlatform) || maxTokensPerPlatform < 1) { + this.logger.warn( + `Invalid maxTokensPerPlatform value: ${maxTokensPerPlatform}, using default`, + ); + maxTokensPerPlatform = config.maxTokensPerPlatform; + } + + 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(); diff --git a/back/shared/auth.ts b/back/shared/auth.ts new file mode 100644 index 00000000..8789f5cf --- /dev/null +++ b/back/shared/auth.ts @@ -0,0 +1,46 @@ +import { AuthInfo, TokenInfo } from '../data/system'; + +/** + * Validates if a token exists in the authentication info. + * Supports both legacy string tokens and new TokenInfo array format. + * + * @param authInfo - The authentication information + * @param headerToken - The token to validate + * @param platform - The platform (desktop, mobile) + * @returns true if the token is valid, false otherwise + */ +export function isValidToken( + authInfo: AuthInfo | null | undefined, + headerToken: string, + platform: string, +): boolean { + if (!authInfo || !headerToken) { + return false; + } + + const { token = '', tokens = {} } = authInfo; + + // Check legacy token field + if (headerToken === token) { + return true; + } + + // Check platform-specific tokens (support both legacy string and new TokenInfo[] format) + const platformTokens = tokens[platform]; + + // Handle null/undefined platformTokens + if (platformTokens === null || platformTokens === undefined) { + return false; + } + + if (typeof platformTokens === 'string') { + // Legacy format: single string token + return headerToken === platformTokens; + } else if (Array.isArray(platformTokens)) { + // New format: array of TokenInfo objects + return platformTokens.some((t: TokenInfo) => t && t.value === headerToken); + } + + // Unexpected type - log warning and reject + return false; +}