From c6d2f0c6cc06bd15a37bf16593619a141d8fd889 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Thu, 26 Mar 2026 00:21:59 +0000 Subject: [PATCH] user session management --- .../public/locales/en-US/translation.json | 10 +- .../session/components/session-list.tsx | 165 ++++++++++++++++++ .../features/session/queries/session-query.ts | 55 ++++++ .../session/services/session-service.ts | 17 ++ .../features/session/types/session.types.ts | 8 + .../user/components/account-name-form.tsx | 10 +- .../settings/account/account-settings.tsx | 5 + apps/server/package.json | 1 + .../middlewares/audit-context.middleware.ts | 5 + apps/server/src/core/auth/auth.controller.ts | 24 ++- apps/server/src/core/auth/dto/jwt-payload.ts | 1 + .../src/core/auth/services/auth.service.ts | 25 ++- .../src/core/auth/services/token.service.ts | 3 +- .../src/core/auth/strategies/jwt.strategy.ts | 14 ++ apps/server/src/core/core.module.ts | 2 + .../core/session/dto/revoke-session.dto.ts | 7 + .../core/session/session-activity.service.ts | 36 ++++ .../src/core/session/session.controller.ts | 80 +++++++++ .../server/src/core/session/session.module.ts | 14 ++ .../src/core/session/session.service.ts | 111 ++++++++++++ .../services/workspace-invitation.service.ts | 4 +- .../workspace/services/workspace.service.ts | 6 + apps/server/src/database/database.module.ts | 3 + .../20260325T120000-user-sessions.ts | 44 +++++ .../repos/session/user-session.repo.ts | 125 +++++++++++++ apps/server/src/database/types/db.d.ts | 15 ++ .../server/src/database/types/entity.types.ts | 6 + apps/server/src/ee | 2 +- pnpm-lock.yaml | 11 +- 29 files changed, 788 insertions(+), 21 deletions(-) create mode 100644 apps/client/src/features/session/components/session-list.tsx create mode 100644 apps/client/src/features/session/queries/session-query.ts create mode 100644 apps/client/src/features/session/services/session-service.ts create mode 100644 apps/client/src/features/session/types/session.types.ts create mode 100644 apps/server/src/core/session/dto/revoke-session.dto.ts create mode 100644 apps/server/src/core/session/session-activity.service.ts create mode 100644 apps/server/src/core/session/session.controller.ts create mode 100644 apps/server/src/core/session/session.module.ts create mode 100644 apps/server/src/core/session/session.service.ts create mode 100644 apps/server/src/database/migrations/20260325T120000-user-sessions.ts create mode 100644 apps/server/src/database/repos/session/user-session.repo.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index f35ec179..a814d7e3 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -708,5 +708,13 @@ "Resend verification email": "Resend verification email", "Verification email sent. Please check your inbox.": "Verification email sent. Please check your inbox.", "Failed to resend verification email. Please try again.": "Failed to resend verification email. Please try again.", - "We've sent you an email with your associated workspaces.": "We've sent you an email with your associated workspaces." + "We've sent you an email with your associated workspaces.": "We've sent you an email with your associated workspaces.", + "Load more": "Load more", + "Log out of all devices": "Log out of all devices", + "Log out of all sessions except this device": "Log out of all sessions except this device", + "This Device": "This Device", + "Unknown device": "Unknown device", + "No active sessions": "No active sessions", + "Session revoked": "Session revoked", + "All other sessions revoked": "All other sessions revoked" } diff --git a/apps/client/src/features/session/components/session-list.tsx b/apps/client/src/features/session/components/session-list.tsx new file mode 100644 index 00000000..34e39198 --- /dev/null +++ b/apps/client/src/features/session/components/session-list.tsx @@ -0,0 +1,165 @@ +import { useState } from "react"; +import { + Button, + Divider, + Group, + Skeleton, + Stack, + Table, + Text, +} from "@mantine/core"; +import { IconDevices } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { + useGetSessionsQuery, + useRevokeSessionMutation, + useRevokeAllSessionsMutation, +} from "@/features/session/queries/session-query"; +import { formattedDate } from "@/lib/time"; + +const PAGE_SIZE = 5; + +export default function SessionList() { + const { t } = useTranslation(); + const { data: sessions, isLoading } = useGetSessionsQuery(); + const revokeSessionMutation = useRevokeSessionMutation(); + const revokeAllSessionsMutation = useRevokeAllSessionsMutation(); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + + const otherSessions = sessions?.filter((s) => !s.isCurrent) ?? []; + const visibleSessions = sessions?.slice(0, visibleCount) ?? []; + const hasMore = sessions && visibleCount < sessions.length; + + if (isLoading) { + return ( + + + + {t("Device Name")} + {t("Last Active")} + + + + + {[1, 2, 3].map((i) => ( + + + + + + + + + + + + + + + ))} + +
+ ); + } + + return ( + + {otherSessions.length > 0 && ( + <> +
+ {t("Log out of all devices")} + + + {t( + "Log out of all sessions except this device", + )} + + + +
+ + + )} + + + + + {t("Device Name")} + {t("Last Active")} + {otherSessions.length > 0 && } + + + + {visibleSessions.map((session) => ( + + + + +
+ + {session.deviceName || t("Unknown device")} + + {session.isCurrent && ( + + {t("This Device")} + + )} +
+
+
+ + + {session.isCurrent + ? t("Now") + : formattedDate(new Date(session.lastActiveAt))} + + + {otherSessions.length > 0 && ( + + {!session.isCurrent && ( + + )} + + )} +
+ ))} +
+
+ + {hasMore && ( + + )} + + {(!sessions || sessions.length === 0) && ( + + {t("No active sessions")} + + )} +
+ ); +} diff --git a/apps/client/src/features/session/queries/session-query.ts b/apps/client/src/features/session/queries/session-query.ts new file mode 100644 index 00000000..3104ce2b --- /dev/null +++ b/apps/client/src/features/session/queries/session-query.ts @@ -0,0 +1,55 @@ +import { + useMutation, + useQuery, + useQueryClient, + UseQueryResult, +} from "@tanstack/react-query"; +import { + getSessions, + revokeSession, + revokeAllSessions, +} from "@/features/session/services/session-service"; +import { ISession } from "@/features/session/types/session.types"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; + +export function useGetSessionsQuery(): UseQueryResult { + return useQuery({ + queryKey: ["session-list"], + queryFn: () => getSessions(), + }); +} + +export function useRevokeSessionMutation() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: (data) => revokeSession(data), + onSuccess: () => { + notifications.show({ message: t("Session revoked") }); + queryClient.invalidateQueries({ queryKey: ["session-list"] }); + }, + onError: (error) => { + const errorMessage = error["response"]?.data?.message; + notifications.show({ message: errorMessage, color: "red" }); + }, + }); +} + +export function useRevokeAllSessionsMutation() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: () => revokeAllSessions(), + onSuccess: () => { + notifications.show({ message: t("All other sessions revoked") }); + queryClient.invalidateQueries({ queryKey: ["session-list"] }); + }, + onError: (error) => { + const errorMessage = error["response"]?.data?.message; + notifications.show({ message: errorMessage, color: "red" }); + }, + }); +} diff --git a/apps/client/src/features/session/services/session-service.ts b/apps/client/src/features/session/services/session-service.ts new file mode 100644 index 00000000..6f2bde83 --- /dev/null +++ b/apps/client/src/features/session/services/session-service.ts @@ -0,0 +1,17 @@ +import api from "@/lib/api-client"; +import { ISession } from "@/features/session/types/session.types"; + +export async function getSessions(): Promise { + const req = await api.post<{ sessions: ISession[] }>("/sessions"); + return req.data.sessions; +} + +export async function revokeSession(data: { + sessionId: string; +}): Promise { + await api.post("/sessions/revoke", data); +} + +export async function revokeAllSessions(): Promise { + await api.post("/sessions/revoke-all"); +} diff --git a/apps/client/src/features/session/types/session.types.ts b/apps/client/src/features/session/types/session.types.ts new file mode 100644 index 00000000..ed0a3903 --- /dev/null +++ b/apps/client/src/features/session/types/session.types.ts @@ -0,0 +1,8 @@ +export type ISession = { + id: string; + deviceName: string | null; + geoLocation: string | null; + lastActiveAt: string; + createdAt: string; + isCurrent: boolean; +}; diff --git a/apps/client/src/features/user/components/account-name-form.tsx b/apps/client/src/features/user/components/account-name-form.tsx index 98929b04..70a5b52c 100644 --- a/apps/client/src/features/user/components/account-name-form.tsx +++ b/apps/client/src/features/user/components/account-name-form.tsx @@ -1,9 +1,8 @@ import { useAtom } from "jotai"; -import { focusAtom } from "jotai-optics"; import { z } from "zod/v4"; import { useForm } from "@mantine/form"; import { zod4Resolver } from "mantine-form-zod-resolver"; -import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { userAtom } from "@/features/user/atoms/current-user-atom.ts"; import { updateUser } from "@/features/user/services/user-service.ts"; import { IUser } from "@/features/user/types/user.types.ts"; import { useState } from "react"; @@ -17,18 +16,15 @@ const formSchema = z.object({ type FormValues = z.infer; -const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user")); - export default function AccountNameForm() { const { t } = useTranslation(); const [isLoading, setIsLoading] = useState(false); - const [currentUser] = useAtom(currentUserAtom); - const [, setUser] = useAtom(userAtom); + const [user, setUser] = useAtom(userAtom); const form = useForm({ validate: zod4Resolver(formSchema), initialValues: { - name: currentUser?.user.name, + name: user?.name, }, }); diff --git a/apps/client/src/pages/settings/account/account-settings.tsx b/apps/client/src/pages/settings/account/account-settings.tsx index f1d78f7d..6f87d31a 100644 --- a/apps/client/src/pages/settings/account/account-settings.tsx +++ b/apps/client/src/pages/settings/account/account-settings.tsx @@ -8,6 +8,7 @@ import { getAppName } from "@/lib/config.ts"; import { Helmet } from "react-helmet-async"; import { useTranslation } from "react-i18next"; import { AccountMfaSection } from "@/features/user/components/account-mfa-section"; +import SessionList from "@/features/session/components/session-list"; export default function AccountSettings() { const { t } = useTranslation(); @@ -36,6 +37,10 @@ export default function AccountSettings() { + + + + ); } diff --git a/apps/server/package.json b/apps/server/package.json index 53fd6468..94a9c935 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -66,6 +66,7 @@ "ai": "^6.0.134", "ai-sdk-ollama": "^3.8.1", "bcrypt": "^6.0.0", + "bowser": "^2.14.1", "bullmq": "^5.71.0", "cache-manager": "^7.2.8", "cheerio": "^1.2.0", diff --git a/apps/server/src/common/middlewares/audit-context.middleware.ts b/apps/server/src/common/middlewares/audit-context.middleware.ts index d58c4353..f5066535 100644 --- a/apps/server/src/common/middlewares/audit-context.middleware.ts +++ b/apps/server/src/common/middlewares/audit-context.middleware.ts @@ -7,6 +7,7 @@ export interface AuditContext { actorId: string | null; actorType: 'user' | 'system' | 'api_key'; ipAddress: string | null; + userAgent: string | null; } export const AUDIT_CONTEXT_KEY = 'auditContext'; @@ -19,11 +20,15 @@ export class AuditContextMiddleware implements NestMiddleware { const workspaceId = (req as any).workspaceId ?? null; const ipAddress = this.extractIpAddress(req); + const userAgent = + (req.headers['user-agent'] as string) ?? null; + const auditContext: AuditContext = { workspaceId, actorId: null, actorType: 'user', ipAddress, + userAgent, }; this.cls.set(AUDIT_CONTEXT_KEY, auditContext); diff --git a/apps/server/src/core/auth/auth.controller.ts b/apps/server/src/core/auth/auth.controller.ts index f83fc1cf..c0997e75 100644 --- a/apps/server/src/core/auth/auth.controller.ts +++ b/apps/server/src/core/auth/auth.controller.ts @@ -5,12 +5,14 @@ import { HttpStatus, Inject, Post, + Req, Res, UseGuards, Logger, } from '@nestjs/common'; import { LoginDto } from './dto/login.dto'; import { AuthService } from './services/auth.service'; +import { SessionService } from '../session/session.service'; import { SetupGuard } from './guards/setup.guard'; import { EnvironmentService } from '../../integrations/environment/environment.service'; import { CreateAdminUserDto } from './dto/create-admin-user.dto'; @@ -22,7 +24,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { ForgotPasswordDto } from './dto/forgot-password.dto'; import { PasswordResetDto } from './dto/password-reset.dto'; import { VerifyUserTokenDto } from './dto/verify-user-token.dto'; -import { FastifyReply } from 'fastify'; +import { FastifyReply, FastifyRequest } from 'fastify'; import { validateSsoEnforcement } from './auth.util'; import { ModuleRef } from '@nestjs/core'; import { AuditEvent, AuditResource } from '../../common/events/audit-events'; @@ -37,6 +39,7 @@ export class AuthController { constructor( private authService: AuthService, + private sessionService: SessionService, private environmentService: EnvironmentService, private moduleRef: ModuleRef, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, @@ -115,8 +118,15 @@ export class AuthController { @Body() dto: ChangePasswordDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, + @Req() req: FastifyRequest, ) { - return this.authService.changePassword(dto, user.id, workspace.id); + const currentSessionId = (req.raw as any).sessionId; + return this.authService.changePassword( + dto, + user.id, + workspace.id, + currentSessionId, + ); } @HttpCode(HttpStatus.OK) @@ -178,8 +188,18 @@ export class AuthController { @Post('logout') async logout( @AuthUser() user: User, + @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply, ) { + const sessionId = (req.raw as any).sessionId; + if (sessionId) { + await this.sessionService.revokeSession( + sessionId, + user.id, + user.workspaceId, + ); + } + res.clearCookie('authToken'); this.auditService.log({ diff --git a/apps/server/src/core/auth/dto/jwt-payload.ts b/apps/server/src/core/auth/dto/jwt-payload.ts index 0f7db401..f70848b9 100644 --- a/apps/server/src/core/auth/dto/jwt-payload.ts +++ b/apps/server/src/core/auth/dto/jwt-payload.ts @@ -11,6 +11,7 @@ export type JwtPayload = { email: string; workspaceId: string; type: 'access'; + sessionId?: string; }; export type JwtCollabPayload = { diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts index 1bb2c5ee..bfd8e1a0 100644 --- a/apps/server/src/core/auth/services/auth.service.ts +++ b/apps/server/src/core/auth/services/auth.service.ts @@ -8,6 +8,8 @@ import { import { LoginDto } from '../dto/login.dto'; import { CreateUserDto } from '../dto/create-user.dto'; import { TokenService } from './token.service'; +import { SessionService } from '../../session/session.service'; +import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo'; import { SignupService } from './signup.service'; import { CreateAdminUserDto } from '../dto/create-admin-user.dto'; import { UserRepo } from '@docmost/db/repos/user/user.repo'; @@ -44,6 +46,8 @@ export class AuthService { constructor( private signupService: SignupService, private tokenService: TokenService, + private sessionService: SessionService, + private userSessionRepo: UserSessionRepo, private userRepo: UserRepo, private userTokenRepo: UserTokenRepo, private mailService: MailService, @@ -90,19 +94,19 @@ export class AuthService { metadata: { source: 'password' }, }); - return this.tokenService.generateAccessToken(user); + return this.sessionService.createSessionAndToken(user); } async register(createUserDto: CreateUserDto, workspaceId: string) { const user = await this.signupService.signup(createUserDto, workspaceId); - return this.tokenService.generateAccessToken(user); + return this.sessionService.createSessionAndToken(user); } async setup(createAdminUserDto: CreateAdminUserDto) { const { workspace, user } = await this.signupService.initialSetup(createAdminUserDto); - const authToken = await this.tokenService.generateAccessToken(user); + const authToken = await this.sessionService.createSessionAndToken(user); return { workspace, authToken }; } @@ -110,6 +114,7 @@ export class AuthService { dto: ChangePasswordDto, userId: string, workspaceId: string, + currentSessionId?: string, ): Promise { const user = await this.userRepo.findById(userId, workspaceId, { includePassword: true, @@ -138,6 +143,16 @@ export class AuthService { workspaceId, ); + if (currentSessionId) { + await this.userSessionRepo.deleteAllExceptCurrent( + currentSessionId, + userId, + workspaceId, + ); + } else { + await this.userSessionRepo.deleteByUserId(userId, workspaceId); + } + this.auditService.log({ event: AuditEvent.USER_PASSWORD_CHANGED, resourceType: AuditResource.USER, @@ -244,6 +259,8 @@ export class AuthService { .execute(); }); + await this.userSessionRepo.deleteByUserId(user.id, workspace.id); + this.auditService.setActorId(user.id); this.auditService.log({ event: AuditEvent.USER_PASSWORD_RESET, @@ -276,7 +293,7 @@ export class AuthService { }; } - const authToken = await this.tokenService.generateAccessToken(user); + const authToken = await this.sessionService.createSessionAndToken(user); return { authToken }; } diff --git a/apps/server/src/core/auth/services/token.service.ts b/apps/server/src/core/auth/services/token.service.ts index 182b6675..b9035ba3 100644 --- a/apps/server/src/core/auth/services/token.service.ts +++ b/apps/server/src/core/auth/services/token.service.ts @@ -25,7 +25,7 @@ export class TokenService { private environmentService: EnvironmentService, ) {} - async generateAccessToken(user: User): Promise { + async generateAccessToken(user: User, sessionId: string): Promise { if (isUserDisabled(user)) { throw new ForbiddenException(); } @@ -35,6 +35,7 @@ export class TokenService { email: user.email, workspaceId: user.workspaceId, type: JwtType.ACCESS, + sessionId, }; return this.jwtService.sign(payload); } diff --git a/apps/server/src/core/auth/strategies/jwt.strategy.ts b/apps/server/src/core/auth/strategies/jwt.strategy.ts index 61096245..10c9e961 100644 --- a/apps/server/src/core/auth/strategies/jwt.strategy.ts +++ b/apps/server/src/core/auth/strategies/jwt.strategy.ts @@ -5,6 +5,8 @@ import { EnvironmentService } from '../../../integrations/environment/environmen import { JwtApiKeyPayload, JwtPayload, JwtType } from '../dto/jwt-payload'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo'; +import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo'; +import { SessionActivityService } from '../../session/session-activity.service'; import { FastifyRequest } from 'fastify'; import { extractBearerTokenFromHeader, isUserDisabled } from '../../../common/helpers'; import { ModuleRef } from '@nestjs/core'; @@ -16,6 +18,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { constructor( private userRepo: UserRepo, private workspaceRepo: WorkspaceRepo, + private userSessionRepo: UserSessionRepo, + private sessionActivityService: SessionActivityService, private readonly environmentService: EnvironmentService, private moduleRef: ModuleRef, ) { @@ -57,6 +61,16 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { throw new UnauthorizedException(); } + if ((payload as JwtPayload).sessionId) { + const sessionId = (payload as JwtPayload).sessionId; + const session = await this.userSessionRepo.findActiveById(sessionId); + if (!session || session.userId !== payload.sub || session.workspaceId !== payload.workspaceId) { + throw new UnauthorizedException('Session revoked'); + } + req.raw.sessionId = sessionId; + this.sessionActivityService.trackActivity(sessionId, payload.sub, payload.workspaceId); + } + return { user, workspace }; } diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index 81dfc138..4cef21ed 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -20,6 +20,7 @@ import { AuditContextMiddleware } from '../common/middlewares/audit-context.midd import { ShareModule } from './share/share.module'; import { NotificationModule } from './notification/notification.module'; import { WatcherModule } from './watcher/watcher.module'; +import { SessionModule } from './session/session.module'; import { ClsMiddleware } from 'nestjs-cls'; @Module({ @@ -38,6 +39,7 @@ import { ClsMiddleware } from 'nestjs-cls'; ShareModule, NotificationModule, WatcherModule, + SessionModule, ], }) export class CoreModule implements NestModule { diff --git a/apps/server/src/core/session/dto/revoke-session.dto.ts b/apps/server/src/core/session/dto/revoke-session.dto.ts new file mode 100644 index 00000000..7e7bb86f --- /dev/null +++ b/apps/server/src/core/session/dto/revoke-session.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsUUID } from 'class-validator'; + +export class RevokeSessionDto { + @IsUUID() + @IsNotEmpty() + sessionId: string; +} diff --git a/apps/server/src/core/session/session-activity.service.ts b/apps/server/src/core/session/session-activity.service.ts new file mode 100644 index 00000000..4b9adc4e --- /dev/null +++ b/apps/server/src/core/session/session-activity.service.ts @@ -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(() => {}); + } +} diff --git a/apps/server/src/core/session/session.controller.ts b/apps/server/src/core/session/session.controller.ts new file mode 100644 index 00000000..75b83c06 --- /dev/null +++ b/apps/server/src/core/session/session.controller.ts @@ -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, + ); + } +} diff --git a/apps/server/src/core/session/session.module.ts b/apps/server/src/core/session/session.module.ts new file mode 100644 index 00000000..9712e887 --- /dev/null +++ b/apps/server/src/core/session/session.module.ts @@ -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 {} diff --git a/apps/server/src/core/session/session.service.ts b/apps/server/src/core/session/session.service.ts new file mode 100644 index 00000000..fa3bbb5f --- /dev/null +++ b/apps/server/src/core/session/session.service.ts @@ -0,0 +1,111 @@ +import { Injectable } from '@nestjs/common'; +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'; + +@Injectable() +export class SessionService { + constructor( + private readonly tokenService: TokenService, + private readonly userSessionRepo: UserSessionRepo, + private readonly environmentService: EnvironmentService, + private readonly cls: ClsService, + ) {} + + async createSessionAndToken(user: User): Promise { + const auditContext = this.cls.get(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, + ipAddress: s.ipAddress, + geoLocation: s.geoLocation, + lastActiveAt: s.lastActiveAt, + createdAt: s.createdAt, + isCurrent: s.id === currentSessionId, + })); + + return mapped.sort((a, b) => { + if (a.isCurrent) return -1; + if (b.isCurrent) return 1; + return 0; + }); + } + + async revokeSession( + sessionId: string, + userId: string, + workspaceId: string, + ): Promise { + await this.userSessionRepo.revokeById(sessionId, userId, workspaceId); + } + + async revokeAllOtherSessions( + currentSessionId: string, + userId: string, + workspaceId: string, + ): Promise { + 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; + } + } +} diff --git a/apps/server/src/core/workspace/services/workspace-invitation.service.ts b/apps/server/src/core/workspace/services/workspace-invitation.service.ts index e6ebe7ff..50ed49f0 100644 --- a/apps/server/src/core/workspace/services/workspace-invitation.service.ts +++ b/apps/server/src/core/workspace/services/workspace-invitation.service.ts @@ -22,6 +22,7 @@ import InvitationEmail from '@docmost/transactional/emails/invitation-email'; import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo'; import InvitationAcceptedEmail from '@docmost/transactional/emails/invitation-accepted-email'; import { TokenService } from '../../auth/services/token.service'; +import { SessionService } from '../../session/session.service'; import { nanoIdGen } from '../../../common/helpers'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; @@ -49,6 +50,7 @@ export class WorkspaceInvitationService { private mailService: MailService, private domainService: DomainService, private tokenService: TokenService, + private sessionService: SessionService, @InjectKysely() private readonly db: KyselyDB, @InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue, private readonly environmentService: EnvironmentService, @@ -350,7 +352,7 @@ export class WorkspaceInvitationService { }; } - const authToken = await this.tokenService.generateAccessToken(newUser); + const authToken = await this.sessionService.createSessionAndToken(newUser); return { authToken }; } diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index a52506cd..fc187f3f 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -7,6 +7,7 @@ import { NotFoundException, } from '@nestjs/common'; import { LicenseCheckService } from '../../../integrations/environment/license-check.service'; +import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo'; import { CreateWorkspaceDto } from '../dto/create-workspace.dto'; import { UpdateWorkspaceDto } from '../dto/update-workspace.dto'; import { SpaceService } from '../../space/services/space.service'; @@ -67,6 +68,7 @@ export class WorkspaceService { @InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue, @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, + private userSessionRepo: UserSessionRepo, ) {} async findById(workspaceId: string) { @@ -673,6 +675,8 @@ export class WorkspaceService { workspaceId, ); + await this.userSessionRepo.revokeByUserId(userId, workspaceId); + this.auditService.log({ event: AuditEvent.USER_DEACTIVATED, resourceType: AuditResource.USER, @@ -787,6 +791,8 @@ export class WorkspaceService { }); }); + await this.userSessionRepo.revokeByUserId(userId, workspaceId); + this.auditService.log({ event: AuditEvent.USER_DELETED, resourceType: AuditResource.USER, diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index 3503e4ea..6c9a7e56 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -17,6 +17,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types'; import * as process from 'node:process'; import { MigrationService } from '@docmost/db/services/migration.service'; import { UserTokenRepo } from './repos/user-token/user-token.repo'; +import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo'; import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo'; @@ -76,6 +77,7 @@ import { normalizePostgresUrl } from '../common/helpers'; CommentRepo, AttachmentRepo, UserTokenRepo, + UserSessionRepo, BacklinkRepo, ShareRepo, NotificationRepo, @@ -95,6 +97,7 @@ import { normalizePostgresUrl } from '../common/helpers'; CommentRepo, AttachmentRepo, UserTokenRepo, + UserSessionRepo, BacklinkRepo, ShareRepo, NotificationRepo, diff --git a/apps/server/src/database/migrations/20260325T120000-user-sessions.ts b/apps/server/src/database/migrations/20260325T120000-user-sessions.ts new file mode 100644 index 00000000..480e4c90 --- /dev/null +++ b/apps/server/src/database/migrations/20260325T120000-user-sessions.ts @@ -0,0 +1,44 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('user_sessions') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('user_id', 'uuid', (col) => + col.notNull().references('users.id').onDelete('cascade'), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.notNull().references('workspaces.id').onDelete('cascade'), + ) + .addColumn('device_name', 'varchar') + .addColumn('user_agent', 'text') + .addColumn('ip_address', sql`inet`) + .addColumn('geo_location', 'varchar') + .addColumn('last_active_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('expires_at', 'timestamptz', (col) => col.notNull()) + .addColumn('revoked_at', 'timestamptz') + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .execute(); + + await db.schema + .createIndex('idx_user_sessions_user_workspace') + .on('user_sessions') + .columns(['user_id', 'workspace_id']) + .execute(); + + await db.schema + .createIndex('idx_user_sessions_expires_at') + .on('user_sessions') + .column('expires_at') + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('user_sessions').execute(); +} diff --git a/apps/server/src/database/repos/session/user-session.repo.ts b/apps/server/src/database/repos/session/user-session.repo.ts new file mode 100644 index 00000000..4edc0743 --- /dev/null +++ b/apps/server/src/database/repos/session/user-session.repo.ts @@ -0,0 +1,125 @@ +import { + InsertableUserSession, + UserSession, +} from '@docmost/db/types/entity.types'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { dbOrTx } from '@docmost/db/utils'; +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; + +@Injectable() +export class UserSessionRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async insertSession( + session: InsertableUserSession, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .insertInto('userSessions') + .values(session) + .returningAll() + .executeTakeFirstOrThrow(); + } + + async findActiveById(id: string): Promise { + return this.db + .selectFrom('userSessions') + .selectAll() + .where('id', '=', id) + .where('expiresAt', '>', new Date()) + .where('revokedAt', 'is', null) + .executeTakeFirst(); + } + + async findActiveByUser( + userId: string, + workspaceId: string, + ): Promise { + return this.db + .selectFrom('userSessions') + .selectAll() + .where('userId', '=', userId) + .where('workspaceId', '=', workspaceId) + .where('expiresAt', '>', new Date()) + .where('revokedAt', 'is', null) + .orderBy('lastActiveAt', 'desc') + .execute(); + } + + async updateLastActiveAt(id: string): Promise { + await this.db + .updateTable('userSessions') + .set({ lastActiveAt: new Date() }) + .where('id', '=', id) + .execute(); + } + + async revokeById( + id: string, + userId: string, + workspaceId: string, + ): Promise { + await this.db + .updateTable('userSessions') + .set({ revokedAt: new Date() }) + .where('id', '=', id) + .where('userId', '=', userId) + .where('workspaceId', '=', workspaceId) + .where('revokedAt', 'is', null) + .execute(); + } + + async revokeAllExceptCurrent( + currentSessionId: string, + userId: string, + workspaceId: string, + ): Promise { + await this.db + .updateTable('userSessions') + .set({ revokedAt: new Date() }) + .where('userId', '=', userId) + .where('workspaceId', '=', workspaceId) + .where('id', '!=', currentSessionId) + .where('revokedAt', 'is', null) + .execute(); + } + + async revokeByUserId( + userId: string, + workspaceId: string, + ): Promise { + await this.db + .updateTable('userSessions') + .set({ revokedAt: new Date() }) + .where('userId', '=', userId) + .where('workspaceId', '=', workspaceId) + .where('revokedAt', 'is', null) + .execute(); + } + + async deleteByUserId( + userId: string, + workspaceId: string, + ): Promise { + await this.db + .deleteFrom('userSessions') + .where('userId', '=', userId) + .where('workspaceId', '=', workspaceId) + .execute(); + } + + async deleteAllExceptCurrent( + currentSessionId: string, + userId: string, + workspaceId: string, + ): Promise { + await this.db + .deleteFrom('userSessions') + .where('userId', '=', userId) + .where('workspaceId', '=', workspaceId) + .where('id', '!=', currentSessionId) + .execute(); + } +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index ed166b75..776e7248 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -429,6 +429,20 @@ export interface PagePermissions { updatedAt: Generated; } +export interface UserSessions { + id: Generated; + userId: string; + workspaceId: string; + deviceName: string | null; + userAgent: string | null; + ipAddress: string | null; + geoLocation: string | null; + lastActiveAt: Generated; + expiresAt: Timestamp; + revokedAt: Timestamp | null; + createdAt: Generated; +} + export interface DB { apiKeys: ApiKeys; attachments: Attachments; @@ -451,6 +465,7 @@ export interface DB { spaces: Spaces; userMfa: UserMfa; users: Users; + userSessions: UserSessions; userTokens: UserTokens; watchers: Watchers; workspaceInvitations: WorkspaceInvitations; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index f8bf9ff7..d23e7475 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -22,6 +22,7 @@ import { Shares, FileTasks, UserMfa as _UserMFA, + UserSessions, ApiKeys, Watchers, Audit as _Audit, @@ -157,6 +158,11 @@ export type PagePermission = Selectable<_PagePermissions>; export type InsertablePagePermission = Insertable<_PagePermissions>; export type UpdatablePagePermission = Updateable>; +// User Session +export type UserSession = Selectable; +export type InsertableUserSession = Insertable; +export type UpdatableUserSession = Updateable>; + // Audit export type Audit = Selectable<_Audit>; export type InsertableAudit = Insertable<_Audit>; diff --git a/apps/server/src/ee b/apps/server/src/ee index 73b69538..ebf89155 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 73b6953888a626ef8a6c02cdc372d52b4ea031c6 +Subproject commit ebf891554c031ad72d2b2bdbb09018436b3079c3 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b979f3c6..d3927e4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -552,6 +552,9 @@ importers: bcrypt: specifier: ^6.0.0 version: 6.0.0 + bowser: + specifier: ^2.14.1 + version: 2.14.1 bullmq: specifier: ^5.71.0 version: 5.71.0 @@ -5840,8 +5843,8 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - bowser@2.11.0: - resolution: {integrity: sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} boxen@5.1.2: resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} @@ -11381,7 +11384,7 @@ snapshots: dependencies: '@aws-sdk/types': 3.973.6 '@smithy/types': 4.13.1 - bowser: 2.11.0 + bowser: 2.14.1 tslib: 2.8.1 '@aws-sdk/util-user-agent-node@3.973.10': @@ -16625,7 +16628,7 @@ snapshots: boolbase@1.0.0: {} - bowser@2.11.0: {} + bowser@2.14.1: {} boxen@5.1.2: dependencies: