mirror of
https://github.com/docmost/docmost.git
synced 2026-05-19 07:54:05 +08:00
feat: user session management (#2056)
* user session management * WIP * cleanup * license * cleanup * don't cache index * rename current device property * fix
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||
|
||||
export class RevokeSessionDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
sessionId: string;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
||||
import type { Redis } from 'ioredis';
|
||||
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
|
||||
const THROTTLE_SECONDS = 15 * 60; // 15 minutes
|
||||
|
||||
@Injectable()
|
||||
export class SessionActivityService {
|
||||
private readonly redis: Redis;
|
||||
|
||||
constructor(
|
||||
private readonly redisService: RedisService,
|
||||
private readonly userSessionRepo: UserSessionRepo,
|
||||
private readonly userRepo: UserRepo,
|
||||
) {
|
||||
this.redis = this.redisService.getOrThrow();
|
||||
}
|
||||
|
||||
trackActivity(sessionId: string, userId: string, workspaceId: string): void {
|
||||
const key = `session:activity:${sessionId}`;
|
||||
|
||||
this.redis
|
||||
.set(key, '1', 'EX', THROTTLE_SECONDS, 'NX')
|
||||
.then((result) => {
|
||||
if (result === null) return; // key already exists, throttled
|
||||
|
||||
this.userSessionRepo.updateLastActiveAt(sessionId).catch(() => {});
|
||||
this.userRepo
|
||||
.updateUser({ lastActiveAt: new Date() }, userId, workspaceId)
|
||||
.catch(() => {});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { SessionService } from './session.service';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { RevokeSessionDto } from './dto/revoke-session.dto';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('sessions')
|
||||
export class SessionController {
|
||||
constructor(private readonly sessionService: SessionService) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post()
|
||||
async listSessions(
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
const currentSessionId = (req.raw as any).sessionId ?? null;
|
||||
const sessions = await this.sessionService.getActiveSessions(
|
||||
user.id,
|
||||
workspace.id,
|
||||
currentSessionId,
|
||||
);
|
||||
return { sessions };
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('revoke')
|
||||
async revokeSession(
|
||||
@Body() dto: RevokeSessionDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
const currentSessionId = (req.raw as any).sessionId;
|
||||
if (dto.sessionId === currentSessionId) {
|
||||
throw new BadRequestException(
|
||||
'Cannot revoke current session. Use logout instead.',
|
||||
);
|
||||
}
|
||||
await this.sessionService.revokeSession(
|
||||
dto.sessionId,
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('revoke-all')
|
||||
async revokeAllSessions(
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
const currentSessionId = (req.raw as any).sessionId;
|
||||
if (!currentSessionId) {
|
||||
throw new BadRequestException(
|
||||
'Current session not found. Please log in again.',
|
||||
);
|
||||
}
|
||||
await this.sessionService.revokeAllOtherSessions(
|
||||
currentSessionId,
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { SessionService } from './session.service';
|
||||
import { SessionActivityService } from './session-activity.service';
|
||||
import { SessionController } from './session.controller';
|
||||
import { TokenModule } from '../auth/token.module';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TokenModule],
|
||||
controllers: [SessionController],
|
||||
providers: [SessionService, SessionActivityService],
|
||||
exports: [SessionService, SessionActivityService],
|
||||
})
|
||||
export class SessionModule {}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { TokenService } from '../auth/services/token.service';
|
||||
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import {
|
||||
AuditContext,
|
||||
AUDIT_CONTEXT_KEY,
|
||||
} from '../../common/middlewares/audit-context.middleware';
|
||||
import * as Bowser from 'bowser';
|
||||
|
||||
const MAX_SESSIONS_PER_USER = 25;
|
||||
const RETENTION_DAYS = 7;
|
||||
|
||||
@Injectable()
|
||||
export class SessionService {
|
||||
private readonly logger = new Logger(SessionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly userSessionRepo: UserSessionRepo,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly cls: ClsService,
|
||||
) {}
|
||||
|
||||
@Interval('session-cleanup', 24 * 60 * 60 * 1000)
|
||||
async cleanupSessions() {
|
||||
try {
|
||||
await this.userSessionRepo.deleteStale(RETENTION_DAYS);
|
||||
await this.userSessionRepo.trimExcessSessions(MAX_SESSIONS_PER_USER);
|
||||
this.logger.debug('Session cleanup completed');
|
||||
} catch (err) {
|
||||
this.logger.error('Session cleanup failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
async createSessionAndToken(user: User): Promise<string> {
|
||||
const auditContext = this.cls.get<AuditContext>(AUDIT_CONTEXT_KEY);
|
||||
const ipAddress = auditContext?.ipAddress ?? null;
|
||||
const userAgent = auditContext?.userAgent ?? null;
|
||||
|
||||
const deviceName = this.parseDeviceName(userAgent);
|
||||
const expiresAt = this.environmentService.getCookieExpiresIn();
|
||||
|
||||
const session = await this.userSessionRepo.insertSession({
|
||||
userId: user.id,
|
||||
workspaceId: user.workspaceId,
|
||||
deviceName,
|
||||
ipAddress,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return this.tokenService.generateAccessToken(user, session.id);
|
||||
}
|
||||
|
||||
async getActiveSessions(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
currentSessionId: string | null,
|
||||
) {
|
||||
const sessions = await this.userSessionRepo.findActiveByUser(
|
||||
userId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const mapped = sessions.map((s) => ({
|
||||
id: s.id,
|
||||
deviceName: s.deviceName,
|
||||
geoLocation: s.geoLocation,
|
||||
lastActiveAt: s.lastActiveAt,
|
||||
createdAt: s.createdAt,
|
||||
isCurrentDevice: s.id === currentSessionId,
|
||||
}));
|
||||
|
||||
return mapped.sort((a, b) => {
|
||||
if (a.isCurrentDevice) return -1;
|
||||
if (b.isCurrentDevice) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
async revokeSession(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.userSessionRepo.revokeById(sessionId, userId, workspaceId);
|
||||
}
|
||||
|
||||
async revokeAllOtherSessions(
|
||||
currentSessionId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.userSessionRepo.revokeAllExceptCurrent(
|
||||
currentSessionId,
|
||||
userId,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
private parseDeviceName(userAgent: string | null): string | null {
|
||||
if (!userAgent) return null;
|
||||
|
||||
try {
|
||||
const parsed = Bowser.parse(userAgent);
|
||||
|
||||
const os = parsed.os?.name;
|
||||
const browser = parsed.browser?.name;
|
||||
const platformType = parsed.platform?.type;
|
||||
|
||||
if (platformType === 'mobile' || platformType === 'tablet') {
|
||||
return parsed.platform?.model || os || 'Mobile Device';
|
||||
}
|
||||
|
||||
if (os) {
|
||||
return browser ? `${browser} on ${os}` : os;
|
||||
}
|
||||
|
||||
return browser || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user