From 802e09fdd5f6d0f3af639553feb0ac3a30f8b6c0 Mon Sep 17 00:00:00 2001 From: Alexey Zinoviev Date: Sun, 16 Nov 2025 00:47:47 +0400 Subject: [PATCH] eqms-1401: track password changed events Signed-off-by: Alexey Zinoviev --- server/account/src/__tests__/utils.test.ts | 110 +++++++++++++++++- .../src/collections/postgres/migrations.ts | 13 ++- server/account/src/types.ts | 3 +- server/account/src/utils.ts | 33 +++++- 4 files changed, 153 insertions(+), 6 deletions(-) diff --git a/server/account/src/__tests__/utils.test.ts b/server/account/src/__tests__/utils.test.ts index dd18a62c210..6a86da07997 100644 --- a/server/account/src/__tests__/utils.test.ts +++ b/server/account/src/__tests__/utils.test.ts @@ -65,14 +65,16 @@ import { loginOrSignUpWithProvider, sendEmail, addSocialIdBase, - doReleaseSocialId + doReleaseSocialId, + getLastPasswordChangeEvent, + isPasswordChangedSince } from '../utils' // eslint-disable-next-line import/no-named-default import platform, { getMetadata, PlatformError, Severity, Status } from '@hcengineering/platform' import { decodeTokenVerbose, generateToken, TokenError } from '@hcengineering/server-token' import { randomBytes } from 'crypto' -import { type AccountDB, AccountEventType, type Workspace } from '../types' +import { type AccountDB, type AccountEvent, AccountEventType, type Workspace } from '../types' import { accountPlugin } from '../plugin' // Mock platform with minimum required functionality @@ -514,6 +516,110 @@ describe('account utils', () => { expect(verifyPassword(password, hash, salt)).toBe(false) }) }) + + describe('getLastPasswordChangeEvent', () => { + const mockDb = { + accountEvent: { + find: jest.fn() as jest.MockedFunction + } + } as unknown as AccountDB + + beforeEach(() => { + jest.clearAllMocks() + }) + + test('should return most recent password change event when it exists', async () => { + const accountUuid = 'test-account-uuid' as AccountUuid + const now = Date.now() + const mockEvent: AccountEvent = { + accountUuid, + eventType: AccountEventType.PASSWORD_CHANGED, + time: now + } + + ;(mockDb.accountEvent.find as jest.Mock).mockResolvedValue([mockEvent]) + + const result = await getLastPasswordChangeEvent(mockDb, accountUuid) + + expect(result).toEqual(mockEvent) + expect(mockDb.accountEvent.find).toHaveBeenCalledWith( + { accountUuid, eventType: AccountEventType.PASSWORD_CHANGED }, + { time: 'descending' }, + 1 + ) + }) + + test('should return null when no password change events exist', async () => { + const accountUuid = 'test-account-uuid' as AccountUuid + + ;(mockDb.accountEvent.find as jest.Mock).mockResolvedValue([]) + + const result = await getLastPasswordChangeEvent(mockDb, accountUuid) + + expect(result).toBeNull() + }) + }) + + describe('isPasswordChangedSince', () => { + const mockDb = { + accountEvent: { + find: jest.fn() as jest.MockedFunction + } + } as unknown as AccountDB + + beforeEach(() => { + jest.clearAllMocks() + }) + + test('should return true when password changed after given timestamp', async () => { + const accountUuid = 'test-account-uuid' as AccountUuid + const now = Date.now() + const oneHourAgo = now - 1000 * 60 * 60 // 1 hour ago + const halfHourAgo = now - 1000 * 60 * 30 // 30 min ago + + const mockEvent: AccountEvent = { + accountUuid, + eventType: AccountEventType.PASSWORD_CHANGED, + time: halfHourAgo + } + + ;(mockDb.accountEvent.find as jest.Mock).mockResolvedValue([mockEvent]) + + const result = await isPasswordChangedSince(mockDb, accountUuid, oneHourAgo) + + expect(result).toBe(true) + }) + + test('should return false when password changed before given timestamp', async () => { + const accountUuid = 'test-account-uuid' as AccountUuid + const now = Date.now() + const oneMonthAgo = now - 1000 * 60 * 60 * 24 * 30 // 1 month ago + const twoMonthsAgo = now - 1000 * 60 * 60 * 24 * 60 * 2 // 2 months ago + + const mockEvent: AccountEvent = { + accountUuid, + eventType: AccountEventType.PASSWORD_CHANGED, + time: twoMonthsAgo + } + + ;(mockDb.accountEvent.find as jest.Mock).mockResolvedValue([mockEvent]) + + const result = await isPasswordChangedSince(mockDb, accountUuid, oneMonthAgo) + + expect(result).toBe(false) + }) + + test('should return false when no password change events exist', async () => { + const accountUuid = 'test-account-uuid' as AccountUuid + const now = Date.now() + + ;(mockDb.accountEvent.find as jest.Mock).mockResolvedValue([]) + + const result = await isPasswordChangedSince(mockDb, accountUuid, now) + + expect(result).toBe(false) + }) + }) }) describe('wrap', () => { diff --git a/server/account/src/collections/postgres/migrations.ts b/server/account/src/collections/postgres/migrations.ts index f03bd68bef7..be4a5d57786 100644 --- a/server/account/src/collections/postgres/migrations.ts +++ b/server/account/src/collections/postgres/migrations.ts @@ -39,7 +39,8 @@ export function getMigrations (ns: string): [string, string][] { getV18Migration(ns), getV19Migration(ns), getV20Migration(ns), - getV21Migration(ns) + getV21Migration(ns), + getV22Migration(ns) ] } @@ -579,3 +580,13 @@ function getV21Migration (ns: string): [string, string] { ` ] } + +function getV22Migration (ns: string): [string, string] { + return [ + 'account_db_v22_add_password_change_event_index', + ` + CREATE INDEX IF NOT EXISTS account_events_account_uuid_event_type_time_idx + ON ${ns}.account_events (account_uuid, event_type, time DESC); + ` + ] +} diff --git a/server/account/src/types.ts b/server/account/src/types.ts index b5629780b60..05676ec5d09 100644 --- a/server/account/src/types.ts +++ b/server/account/src/types.ts @@ -78,7 +78,8 @@ export interface AccountEvent { export enum AccountEventType { ACCOUNT_CREATED = 'account_created', SOCIAL_ID_RELEASED = 'social_id_released', - ACCOUNT_DELETED = 'account_deleted' + ACCOUNT_DELETED = 'account_deleted', + PASSWORD_CHANGED = 'password_changed' } export interface Member { diff --git a/server/account/src/utils.ts b/server/account/src/utils.ts index 3ebf6e9c0dd..e6be70ad8d1 100644 --- a/server/account/src/utils.ts +++ b/server/account/src/utils.ts @@ -48,6 +48,7 @@ import { accountPlugin } from './plugin' import { type Account, type AccountDB, + type AccountEvent, AccountEventType, type AccountMethodHandler, type Integration, @@ -428,7 +429,7 @@ export async function setPassword ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, - personUuid: AccountUuid, + accountUuid: AccountUuid, password: string ): Promise { if (password == null || password === '') { @@ -436,7 +437,35 @@ export async function setPassword ( } const salt = randomBytes(32) - await db.setPassword(personUuid, hashWithSalt(password, salt), salt) + await db.setPassword(accountUuid, hashWithSalt(password, salt), salt) + + // Record password change event + try { + await db.accountEvent.insertOne({ + accountUuid, + eventType: AccountEventType.PASSWORD_CHANGED, + time: Date.now() + }) + } catch (err) { + ctx.warn('Failed to record password change event', { accountUuid, err }) + } +} + +export async function getLastPasswordChangeEvent ( + db: AccountDB, + accountUuid: AccountUuid +): Promise { + const result = await db.accountEvent.find( + { accountUuid, eventType: AccountEventType.PASSWORD_CHANGED }, + { time: 'descending' }, + 1 + ) + return result[0] ?? null +} + +export async function isPasswordChangedSince (db: AccountDB, accountUuid: AccountUuid, since: number): Promise { + const lastEvent = await getLastPasswordChangeEvent(db, accountUuid) + return lastEvent != null && lastEvent.time >= since } export async function generateUniqueOtp (db: AccountDB): Promise {