diff --git a/packages/core/src/sentinel/basic-sentinel.test.ts b/packages/core/src/sentinel/basic-sentinel.test.ts new file mode 100644 index 000000000..06a613a1d --- /dev/null +++ b/packages/core/src/sentinel/basic-sentinel.test.ts @@ -0,0 +1,108 @@ +import { + type ActivityReport, + SentinelActionResult, + SentinelActivityAction, + SentinelActivityTargetType, + SentinelDecision, +} from '@logto/schemas'; +import { addMinutes } from 'date-fns'; + +import { createMockCommonQueryMethods, expectSqlString } from '#src/test-utils/query.js'; + +import BasicSentinel from './basic-sentinel.js'; + +const { jest } = import.meta; + +const createMockActivityReport = (): ActivityReport => ({ + targetType: SentinelActivityTargetType.User, + targetHash: 'baz', + action: SentinelActivityAction.Password, + actionResult: SentinelActionResult.Success, + payload: {}, +}); + +class TestSentinel extends BasicSentinel { + override decide = super.decide; +} + +const methods = createMockCommonQueryMethods(); +const sentinel = new TestSentinel(methods); +const mockedTime = new Date('2021-01-01T00:00:00.000Z').valueOf(); +const mockedBlockedTime = addMinutes(mockedTime, 10).valueOf(); + +beforeAll(() => { + jest.useFakeTimers().setSystemTime(mockedTime); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('BasicSentinel -> reportActivity()', () => { + it('should insert an activity', async () => { + methods.maybeOne.mockResolvedValueOnce(null); + methods.oneFirst.mockResolvedValueOnce(0); + + const activity = createMockActivityReport(); + const decision = await sentinel.reportActivity(activity); + + expect(decision).toStrictEqual([SentinelDecision.Allowed, mockedTime]); + expect(methods.query).toHaveBeenCalledTimes(1); + expect(methods.query).toHaveBeenCalledWith( + expectSqlString('insert into "sentinel_activities"') + ); + }); + + it('should insert a blocked activity', async () => { + // Mock the query method to return a blocked activity + methods.maybeOne.mockResolvedValueOnce({ decisionExpiresAt: mockedBlockedTime }); + + const activity = createMockActivityReport(); + const decision = await sentinel.reportActivity(activity); + expect(decision).toEqual([SentinelDecision.Blocked, mockedBlockedTime]); + expect(methods.query).toHaveBeenCalledTimes(1); + expect(methods.query).toHaveBeenCalledWith( + expectSqlString('insert into "sentinel_activities"') + ); + }); +}); + +describe('BasicSentinel -> decide()', () => { + it('should return existing blocked time if the activity is blocked', async () => { + const existingBlockedTime = addMinutes(mockedTime, 5).valueOf(); + methods.maybeOne.mockResolvedValueOnce({ decisionExpiresAt: existingBlockedTime }); + + const activity = createMockActivityReport(); + const decision = await sentinel.decide(activity); + expect(decision).toEqual([SentinelDecision.Blocked, existingBlockedTime]); + }); + + it('should return allowed if the activity is not blocked and there are less than 5 failed attempts', async () => { + methods.maybeOne.mockResolvedValueOnce(null); + methods.oneFirst.mockResolvedValueOnce(4); + + const activity = createMockActivityReport(); + const decision = await sentinel.decide(activity); + expect(decision).toEqual([SentinelDecision.Allowed, mockedTime]); + }); + + it('should return blocked if the activity is not blocked and there are 5 failed attempts', async () => { + methods.maybeOne.mockResolvedValueOnce(null); + methods.oneFirst.mockResolvedValueOnce(5); + + const activity = createMockActivityReport(); + const decision = await sentinel.decide(activity); + expect(decision).toEqual([SentinelDecision.Blocked, mockedBlockedTime]); + }); + + it('should return blocked if the activity is not blocked and there are 4 failed attempts and the current activity is failed', async () => { + methods.maybeOne.mockResolvedValueOnce(null); + methods.oneFirst.mockResolvedValueOnce(4); + + const activity = createMockActivityReport(); + // eslint-disable-next-line @silverhand/fp/no-mutation + activity.actionResult = SentinelActionResult.Failed; + const decision = await sentinel.decide(activity); + expect(decision).toEqual([SentinelDecision.Blocked, mockedBlockedTime]); + }); +}); diff --git a/packages/core/src/sentinel/basic-sentinel.ts b/packages/core/src/sentinel/basic-sentinel.ts new file mode 100644 index 000000000..d53b8fd8c --- /dev/null +++ b/packages/core/src/sentinel/basic-sentinel.ts @@ -0,0 +1,139 @@ +import { + type ActivityReport, + Sentinel, + SentinelDecision, + type SentinelDecisionTuple, + type SentinelActivity, + SentinelActivities, + SentinelActionResult, + SentinelActivityAction, +} from '@logto/schemas'; +import { convertToIdentifiers, generateStandardId } from '@logto/shared'; +import { type Nullable } from '@silverhand/essentials'; +import { addMinutes } from 'date-fns'; +import { sql, type CommonQueryMethods } from 'slonik'; + +import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; + +const { fields, table } = convertToIdentifiers(SentinelActivities); + +/** + * A basic sentinel that blocks a user after 5 failed attempts in 1 hour. + * + * @see {@link BasicSentinel.supportedActions} for the list of supported actions. + */ +export default class BasicSentinel extends Sentinel { + /** The list of actions that are accepted to be reported to this sentinel. */ + static supportedActions = Object.freeze([ + SentinelActivityAction.Password, + SentinelActivityAction.VerificationCode, + ] as const); + + /** The array of all supported actions in SQL format. */ + static supportedActionArray = sql.array( + BasicSentinel.supportedActions, + SentinelActivities.fields.action + ); + + /** + * Asserts that the given action is supported by this sentinel. + * + * @throws {Error} If the action is not supported. + */ + static assertAction(action: unknown): asserts action is SentinelActivityAction { + // eslint-disable-next-line no-restricted-syntax + if (!BasicSentinel.supportedActions.includes(action as SentinelActivityAction)) { + // Update to use the new error class later. + throw new Error(`Unsupported action: ${String(action)}`); + } + } + + protected insertActivity = buildInsertIntoWithPool(this.pool)(SentinelActivities); + + /** + * Init a basic sentinel with the given pool that has at least the access to the tenant-level + * data. We don't directly put the queries in the `TenantContext` because the sentinel was + * designed to be used as an isolated module that can be separated from the core business logic. + * + * @param pool A database pool with methods {@link CommonQueryMethods}. + */ + constructor(protected readonly pool: CommonQueryMethods) { + super(); + } + + /** + * Reports an activity to this sentinel. The sentinel will decide whether to block the user or + * not. + * + * Regardless of the decision, the activity will be recorded in the database. + * + * @param activity The activity to report. + * @returns The decision made by the sentinel. + * @throws {Error} If the action is not supported. + * @see {@link BasicSentinel.supportedActions} for the list of supported actions. + */ + async reportActivity(activity: ActivityReport): Promise { + BasicSentinel.assertAction(activity.action); + + const [decision, decisionExpiresAt] = await this.decide(activity); + + await this.insertActivity({ + id: generateStandardId(), + ...activity, + decision, + decisionExpiresAt, + }); + + return [decision, decisionExpiresAt]; + } + + /** + * Checks whether the given target is blocked from performing actions. + * + * @returns The decision made by the sentinel, or `null` if the target is not blocked. + * + * @remarks + * All supported actions share the same pool of activities, i.e. once a user has failed to + * perform any of the supported actions for certain times, the user will be blocked from + * performing any of the supported actions. + */ + protected async isBlocked( + query: Pick + ): Promise> { + const blocked = await this.pool.maybeOne>(sql` + select ${fields.decisionExpiresAt} from ${table} + where ${fields.targetType} = ${query.targetType} + and ${fields.targetHash} = ${query.targetHash} + and ${fields.action} = any(${BasicSentinel.supportedActionArray}) + and ${fields.decision} = ${SentinelDecision.Blocked} + and ${fields.decisionExpiresAt} > now() + limit 1 + `); + return blocked && [SentinelDecision.Blocked, blocked.decisionExpiresAt]; + } + + protected async decide( + query: Pick + ): Promise { + const blocked = await this.isBlocked(query); + + if (blocked) { + return blocked; + } + + const failedAttempts = await this.pool.oneFirst(sql` + select count(*) from ${table} + where ${fields.targetType} = ${query.targetType} + and ${fields.targetHash} = ${query.targetHash} + and ${fields.action} = any(${BasicSentinel.supportedActionArray}) + and ${fields.actionResult} = ${SentinelActionResult.Failed} + and ${fields.decision} != ${SentinelDecision.Blocked} + and ${fields.createdAt} > now() - interval '1 hour' + `); + const now = new Date(); + + return failedAttempts + (query.actionResult === SentinelActionResult.Failed ? 1 : 0) >= 5 + ? [SentinelDecision.Blocked, addMinutes(now, 10).valueOf()] + : [SentinelDecision.Allowed, now.valueOf()]; + } +} diff --git a/packages/core/src/test-utils/query.ts b/packages/core/src/test-utils/query.ts new file mode 100644 index 000000000..82e6f33ab --- /dev/null +++ b/packages/core/src/test-utils/query.ts @@ -0,0 +1,21 @@ +const { jest } = import.meta; + +export const createMockCommonQueryMethods = () => ({ + any: jest.fn(), + anyFirst: jest.fn(), + exists: jest.fn(), + many: jest.fn(), + manyFirst: jest.fn(), + maybeOne: jest.fn(), + maybeOneFirst: jest.fn(), + one: jest.fn(), + oneFirst: jest.fn(), + query: jest.fn().mockResolvedValue({ rows: [] }), + transaction: jest.fn(), +}); + +export const expectSqlString = (sql: string): unknown => + expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + sql: expect.stringContaining(sql), + }); diff --git a/packages/schemas/src/types/sentinel.ts b/packages/schemas/src/types/sentinel.ts index b14ca8746..49d453e04 100644 --- a/packages/schemas/src/types/sentinel.ts +++ b/packages/schemas/src/types/sentinel.ts @@ -6,6 +6,9 @@ export type ActivityReport = Pick< 'targetType' | 'targetHash' | 'action' | 'actionResult' | 'payload' >; +/** The sentinel decision with its expiration. */ +export type SentinelDecisionTuple = [decision: SentinelDecision, decisionExpiresAt: number]; + /** * The sentinel class interface. * @@ -28,5 +31,5 @@ export abstract class Sentinel { * @returns A Promise that resolves to the sentinel decision. * @see {@link SentinelDecision} */ - abstract reportActivity(activity: ActivityReport): Promise; + abstract reportActivity(activity: ActivityReport): Promise; }