mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
eqms-1401: track password changed events (#10217)
Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
This commit is contained in:
@@ -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);
|
||||
`
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user