Skip to content

Commit 02fa0f4

Browse files
authored
eqms-1401: track password changed events (#10217)
Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
1 parent 8459e7d commit 02fa0f4

File tree

4 files changed

+153
-6
lines changed

4 files changed

+153
-6
lines changed

server/account/src/__tests__/utils.test.ts

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,16 @@ import {
6565
loginOrSignUpWithProvider,
6666
sendEmail,
6767
addSocialIdBase,
68-
doReleaseSocialId
68+
doReleaseSocialId,
69+
getLastPasswordChangeEvent,
70+
isPasswordChangedSince
6971
} from '../utils'
7072
// eslint-disable-next-line import/no-named-default
7173
import platform, { getMetadata, PlatformError, Severity, Status } from '@hcengineering/platform'
7274
import { decodeTokenVerbose, generateToken, TokenError } from '@hcengineering/server-token'
7375
import { randomBytes } from 'crypto'
7476

75-
import { type AccountDB, AccountEventType, type Workspace } from '../types'
77+
import { type AccountDB, type AccountEvent, AccountEventType, type Workspace } from '../types'
7678
import { accountPlugin } from '../plugin'
7779

7880
// Mock platform with minimum required functionality
@@ -514,6 +516,110 @@ describe('account utils', () => {
514516
expect(verifyPassword(password, hash, salt)).toBe(false)
515517
})
516518
})
519+
520+
describe('getLastPasswordChangeEvent', () => {
521+
const mockDb = {
522+
accountEvent: {
523+
find: jest.fn() as jest.MockedFunction<AccountDB['accountEvent']['find']>
524+
}
525+
} as unknown as AccountDB
526+
527+
beforeEach(() => {
528+
jest.clearAllMocks()
529+
})
530+
531+
test('should return most recent password change event when it exists', async () => {
532+
const accountUuid = 'test-account-uuid' as AccountUuid
533+
const now = Date.now()
534+
const mockEvent: AccountEvent = {
535+
accountUuid,
536+
eventType: AccountEventType.PASSWORD_CHANGED,
537+
time: now
538+
}
539+
540+
;(mockDb.accountEvent.find as jest.Mock).mockResolvedValue([mockEvent])
541+
542+
const result = await getLastPasswordChangeEvent(mockDb, accountUuid)
543+
544+
expect(result).toEqual(mockEvent)
545+
expect(mockDb.accountEvent.find).toHaveBeenCalledWith(
546+
{ accountUuid, eventType: AccountEventType.PASSWORD_CHANGED },
547+
{ time: 'descending' },
548+
1
549+
)
550+
})
551+
552+
test('should return null when no password change events exist', async () => {
553+
const accountUuid = 'test-account-uuid' as AccountUuid
554+
555+
;(mockDb.accountEvent.find as jest.Mock).mockResolvedValue([])
556+
557+
const result = await getLastPasswordChangeEvent(mockDb, accountUuid)
558+
559+
expect(result).toBeNull()
560+
})
561+
})
562+
563+
describe('isPasswordChangedSince', () => {
564+
const mockDb = {
565+
accountEvent: {
566+
find: jest.fn() as jest.MockedFunction<AccountDB['accountEvent']['find']>
567+
}
568+
} as unknown as AccountDB
569+
570+
beforeEach(() => {
571+
jest.clearAllMocks()
572+
})
573+
574+
test('should return true when password changed after given timestamp', async () => {
575+
const accountUuid = 'test-account-uuid' as AccountUuid
576+
const now = Date.now()
577+
const oneHourAgo = now - 1000 * 60 * 60 // 1 hour ago
578+
const halfHourAgo = now - 1000 * 60 * 30 // 30 min ago
579+
580+
const mockEvent: AccountEvent = {
581+
accountUuid,
582+
eventType: AccountEventType.PASSWORD_CHANGED,
583+
time: halfHourAgo
584+
}
585+
586+
;(mockDb.accountEvent.find as jest.Mock).mockResolvedValue([mockEvent])
587+
588+
const result = await isPasswordChangedSince(mockDb, accountUuid, oneHourAgo)
589+
590+
expect(result).toBe(true)
591+
})
592+
593+
test('should return false when password changed before given timestamp', async () => {
594+
const accountUuid = 'test-account-uuid' as AccountUuid
595+
const now = Date.now()
596+
const oneMonthAgo = now - 1000 * 60 * 60 * 24 * 30 // 1 month ago
597+
const twoMonthsAgo = now - 1000 * 60 * 60 * 24 * 60 * 2 // 2 months ago
598+
599+
const mockEvent: AccountEvent = {
600+
accountUuid,
601+
eventType: AccountEventType.PASSWORD_CHANGED,
602+
time: twoMonthsAgo
603+
}
604+
605+
;(mockDb.accountEvent.find as jest.Mock).mockResolvedValue([mockEvent])
606+
607+
const result = await isPasswordChangedSince(mockDb, accountUuid, oneMonthAgo)
608+
609+
expect(result).toBe(false)
610+
})
611+
612+
test('should return false when no password change events exist', async () => {
613+
const accountUuid = 'test-account-uuid' as AccountUuid
614+
const now = Date.now()
615+
616+
;(mockDb.accountEvent.find as jest.Mock).mockResolvedValue([])
617+
618+
const result = await isPasswordChangedSince(mockDb, accountUuid, now)
619+
620+
expect(result).toBe(false)
621+
})
622+
})
517623
})
518624

519625
describe('wrap', () => {

server/account/src/collections/postgres/migrations.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ export function getMigrations (ns: string): [string, string][] {
3939
getV18Migration(ns),
4040
getV19Migration(ns),
4141
getV20Migration(ns),
42-
getV21Migration(ns)
42+
getV21Migration(ns),
43+
getV22Migration(ns)
4344
]
4445
}
4546

@@ -579,3 +580,13 @@ function getV21Migration (ns: string): [string, string] {
579580
`
580581
]
581582
}
583+
584+
function getV22Migration (ns: string): [string, string] {
585+
return [
586+
'account_db_v22_add_password_change_event_index',
587+
`
588+
CREATE INDEX IF NOT EXISTS account_events_account_uuid_event_type_time_idx
589+
ON ${ns}.account_events (account_uuid, event_type, time DESC);
590+
`
591+
]
592+
}

server/account/src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ export interface AccountEvent {
7878
export enum AccountEventType {
7979
ACCOUNT_CREATED = 'account_created',
8080
SOCIAL_ID_RELEASED = 'social_id_released',
81-
ACCOUNT_DELETED = 'account_deleted'
81+
ACCOUNT_DELETED = 'account_deleted',
82+
PASSWORD_CHANGED = 'password_changed'
8283
}
8384

8485
export interface Member {

server/account/src/utils.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { accountPlugin } from './plugin'
4848
import {
4949
type Account,
5050
type AccountDB,
51+
type AccountEvent,
5152
AccountEventType,
5253
type AccountMethodHandler,
5354
type Integration,
@@ -428,15 +429,43 @@ export async function setPassword (
428429
ctx: MeasureContext,
429430
db: AccountDB,
430431
branding: Branding | null,
431-
personUuid: AccountUuid,
432+
accountUuid: AccountUuid,
432433
password: string
433434
): Promise<void> {
434435
if (password == null || password === '') {
435436
return
436437
}
437438

438439
const salt = randomBytes(32)
439-
await db.setPassword(personUuid, hashWithSalt(password, salt), salt)
440+
await db.setPassword(accountUuid, hashWithSalt(password, salt), salt)
441+
442+
// Record password change event
443+
try {
444+
await db.accountEvent.insertOne({
445+
accountUuid,
446+
eventType: AccountEventType.PASSWORD_CHANGED,
447+
time: Date.now()
448+
})
449+
} catch (err) {
450+
ctx.warn('Failed to record password change event', { accountUuid, err })
451+
}
452+
}
453+
454+
export async function getLastPasswordChangeEvent (
455+
db: AccountDB,
456+
accountUuid: AccountUuid
457+
): Promise<AccountEvent | null> {
458+
const result = await db.accountEvent.find(
459+
{ accountUuid, eventType: AccountEventType.PASSWORD_CHANGED },
460+
{ time: 'descending' },
461+
1
462+
)
463+
return result[0] ?? null
464+
}
465+
466+
export async function isPasswordChangedSince (db: AccountDB, accountUuid: AccountUuid, since: number): Promise<boolean> {
467+
const lastEvent = await getLastPasswordChangeEvent(db, accountUuid)
468+
return lastEvent != null && lastEvent.time >= since
440469
}
441470

442471
export async function generateUniqueOtp (db: AccountDB): Promise<string> {

0 commit comments

Comments
 (0)