Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 108 additions & 2 deletions server/account/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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', () => {
Expand Down
13 changes: 12 additions & 1 deletion server/account/src/collections/postgres/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export function getMigrations (ns: string): [string, string][] {
getV18Migration(ns),
getV19Migration(ns),
getV20Migration(ns),
getV21Migration(ns)
getV21Migration(ns),
getV22Migration(ns)
]
}

Expand Down Expand Up @@ -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);
`
]
}
3 changes: 2 additions & 1 deletion server/account/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
33 changes: 31 additions & 2 deletions server/account/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { accountPlugin } from './plugin'
import {
type Account,
type AccountDB,
type AccountEvent,
AccountEventType,
type AccountMethodHandler,
type Integration,
Expand Down Expand Up @@ -428,15 +429,43 @@ export async function setPassword (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
personUuid: AccountUuid,
accountUuid: AccountUuid,
password: string
): Promise<void> {
if (password == null || password === '') {
return
}

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> {
Expand Down
Loading