0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

feat(core): init basic sentinel

This commit is contained in:
Gao Sun 2023-09-19 16:35:03 +08:00
parent d065cbc623
commit b9ab1f3d85
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
3 changed files with 251 additions and 0 deletions

View file

@ -0,0 +1,100 @@
import {
type ActivityReport,
SentinelActionResult,
SentinelActivityAction,
SentinelActivityTargetType,
SentinelDecision,
} from '@logto/schemas';
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);
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).toEqual(SentinelDecision.Allowed);
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({ id: 0 });
const activity = createMockActivityReport();
const decision = await sentinel.reportActivity(activity);
expect(decision).toEqual(SentinelDecision.Blocked);
expect(methods.query).toHaveBeenCalledTimes(1);
expect(methods.query).toHaveBeenCalledWith(
expectSqlString('insert into "sentinel_activities"')
);
});
});
describe('BasicSentinel -> decide()', () => {
it('should return blocked if the activity is blocked', async () => {
methods.maybeOne.mockResolvedValueOnce({ id: 0 });
const activity = createMockActivityReport();
const decision = await sentinel.decide(activity);
expect(decision).toEqual(SentinelDecision.Blocked);
});
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);
});
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);
});
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);
});
});

View file

@ -0,0 +1,130 @@
import {
type ActivityReport,
Sentinel,
SentinelDecision,
type SentinelActivity,
SentinelActivities,
SentinelActionResult,
SentinelActivityAction,
} from '@logto/schemas';
import { convertToIdentifiers, generateStandardId } from '@logto/shared';
import { cond } 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);
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<SentinelDecision> {
BasicSentinel.assertAction(activity.action);
const decision = await this.decide(activity);
await this.insertActivity({
id: generateStandardId(),
...activity,
decision,
decisionExpiresAt: cond(
decision === SentinelDecision.Blocked && addMinutes(new Date(), 10).valueOf()
),
});
return decision;
}
/**
* Checks whether the given target is blocked from performing actions.
*
* @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<boolean> {
const blocked = await this.pool.maybeOne(sql`
select ${fields.id} 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 Boolean(blocked);
}
protected async decide(
query: Pick<SentinelActivity, 'targetType' | 'targetHash' | 'actionResult'>
): Promise<SentinelDecision> {
const blocked = await this.isBlocked(query);
if (blocked) {
return SentinelDecision.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'
`);
return failedAttempts + (query.actionResult === SentinelActionResult.Failed ? 1 : 0) >= 5
? SentinelDecision.Blocked
: SentinelDecision.Allowed;
}
}

View 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),
});