mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
Merge pull request #4554 from logto-io/gao-basic-sentinel
feat(core): init basic sentinel
This commit is contained in:
commit
9644308176
4 changed files with 272 additions and 1 deletions
108
packages/core/src/sentinel/basic-sentinel.test.ts
Normal file
108
packages/core/src/sentinel/basic-sentinel.test.ts
Normal file
|
@ -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]);
|
||||||
|
});
|
||||||
|
});
|
139
packages/core/src/sentinel/basic-sentinel.ts
Normal file
139
packages/core/src/sentinel/basic-sentinel.ts
Normal file
|
@ -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<SentinelDecisionTuple> {
|
||||||
|
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<SentinelActivity, 'targetType' | 'targetHash'>
|
||||||
|
): Promise<Nullable<SentinelDecisionTuple>> {
|
||||||
|
const blocked = await this.pool.maybeOne<Pick<SentinelActivity, 'decisionExpiresAt'>>(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<SentinelActivity, 'targetType' | 'targetHash' | 'actionResult'>
|
||||||
|
): Promise<SentinelDecisionTuple> {
|
||||||
|
const blocked = await this.isBlocked(query);
|
||||||
|
|
||||||
|
if (blocked) {
|
||||||
|
return blocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
const failedAttempts = await this.pool.oneFirst<number>(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()];
|
||||||
|
}
|
||||||
|
}
|
21
packages/core/src/test-utils/query.ts
Normal file
21
packages/core/src/test-utils/query.ts
Normal file
|
@ -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),
|
||||||
|
});
|
|
@ -6,6 +6,9 @@ export type ActivityReport = Pick<
|
||||||
'targetType' | 'targetHash' | 'action' | 'actionResult' | 'payload'
|
'targetType' | 'targetHash' | 'action' | 'actionResult' | 'payload'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
/** The sentinel decision with its expiration. */
|
||||||
|
export type SentinelDecisionTuple = [decision: SentinelDecision, decisionExpiresAt: number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The sentinel class interface.
|
* The sentinel class interface.
|
||||||
*
|
*
|
||||||
|
@ -28,5 +31,5 @@ export abstract class Sentinel {
|
||||||
* @returns A Promise that resolves to the sentinel decision.
|
* @returns A Promise that resolves to the sentinel decision.
|
||||||
* @see {@link SentinelDecision}
|
* @see {@link SentinelDecision}
|
||||||
*/
|
*/
|
||||||
abstract reportActivity(activity: ActivityReport): Promise<SentinelDecision>;
|
abstract reportActivity(activity: ActivityReport): Promise<SentinelDecisionTuple>;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue