Implement multi-device login support - allow multiple concurrent sessions

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-11-07 16:19:34 +00:00
parent 044deabed3
commit 168bdb4178
5 changed files with 160 additions and 12 deletions

View File

@ -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);

View File

@ -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<string, string>;
tokens: Record<string, string | TokenInfo[]>;
twoFactorActivated: boolean;
twoFactorSecret: string;
avatar: string;

View File

@ -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';

View File

@ -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');

View File

@ -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<any> {
public async logout(platform: string, tokenValue: string): Promise<any> {
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<string, string | TokenInfo[]>): Record<string, TokenInfo[]> {
const normalized: Record<string, TokenInfo[]> = {};
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<string, string | TokenInfo[]>,
platform: string,
tokenInfo: TokenInfo,
maxTokensPerPlatform: number = 10
): Record<string, TokenInfo[]> {
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<string, string | TokenInfo[]>,
platform: string,
tokenValue: string
): Record<string, TokenInfo[]> {
const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) {
normalized[platform] = normalized[platform].filter(
(t) => t.value !== tokenValue
);
}
return normalized;
}
private findTokenInList(
tokens: Record<string, string | TokenInfo[]>,
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<AuthInfo>) {
const { retries, twoFactorActivated, password, username } = info;
const authInfo = await this.getAuthInfo();