eqms-1401: track password changed events (#10217)

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
This commit is contained in:
Alexey Zinoviev
2025-11-17 12:09:49 +07:00
committed by GitHub
parent 8459e7d1f8
commit 02fa0f4419
4 changed files with 153 additions and 6 deletions
+108 -2
View File
@@ -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<AccountDB['accountEvent']['find']>
}
} 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<AccountDB['accountEvent']['find']>
}
} 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', () => {
@@ -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);
`
]
}
+2 -1
View File
@@ -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 {
+31 -2
View File
@@ -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<void> {
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<AccountEvent | null> {
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<boolean> {
const lastEvent = await getLastPasswordChangeEvent(db, accountUuid)
return lastEvent != null && lastEvent.time >= since
}
export async function generateUniqueOtp (db: AccountDB): Promise<string> {