mirror of
https://github.com/logto-io/logto.git
synced 2025-01-20 21:32:31 -05:00
refactor: reuse existing blocked record expiration
This commit is contained in:
parent
b9ab1f3d85
commit
f34b2f4e21
3 changed files with 38 additions and 25 deletions
|
@ -5,6 +5,7 @@ import {
|
|||
SentinelActivityTargetType,
|
||||
SentinelDecision,
|
||||
} from '@logto/schemas';
|
||||
import { addMinutes } from 'date-fns';
|
||||
|
||||
import { createMockCommonQueryMethods, expectSqlString } from '#src/test-utils/query.js';
|
||||
|
||||
|
@ -26,6 +27,12 @@ class TestSentinel extends BasicSentinel {
|
|||
|
||||
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();
|
||||
|
@ -39,7 +46,7 @@ describe('BasicSentinel -> reportActivity()', () => {
|
|||
const activity = createMockActivityReport();
|
||||
const decision = await sentinel.reportActivity(activity);
|
||||
|
||||
expect(decision).toEqual(SentinelDecision.Allowed);
|
||||
expect(decision).toStrictEqual([SentinelDecision.Allowed, mockedTime]);
|
||||
expect(methods.query).toHaveBeenCalledTimes(1);
|
||||
expect(methods.query).toHaveBeenCalledWith(
|
||||
expectSqlString('insert into "sentinel_activities"')
|
||||
|
@ -48,11 +55,11 @@ describe('BasicSentinel -> reportActivity()', () => {
|
|||
|
||||
it('should insert a blocked activity', async () => {
|
||||
// Mock the query method to return a blocked activity
|
||||
methods.maybeOne.mockResolvedValueOnce({ id: 0 });
|
||||
methods.maybeOne.mockResolvedValueOnce({ decisionExpiresAt: mockedBlockedTime });
|
||||
|
||||
const activity = createMockActivityReport();
|
||||
const decision = await sentinel.reportActivity(activity);
|
||||
expect(decision).toEqual(SentinelDecision.Blocked);
|
||||
expect(decision).toEqual([SentinelDecision.Blocked, mockedBlockedTime]);
|
||||
expect(methods.query).toHaveBeenCalledTimes(1);
|
||||
expect(methods.query).toHaveBeenCalledWith(
|
||||
expectSqlString('insert into "sentinel_activities"')
|
||||
|
@ -61,12 +68,13 @@ describe('BasicSentinel -> reportActivity()', () => {
|
|||
});
|
||||
|
||||
describe('BasicSentinel -> decide()', () => {
|
||||
it('should return blocked if the activity is blocked', async () => {
|
||||
methods.maybeOne.mockResolvedValueOnce({ id: 0 });
|
||||
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);
|
||||
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 () => {
|
||||
|
@ -75,7 +83,7 @@ describe('BasicSentinel -> decide()', () => {
|
|||
|
||||
const activity = createMockActivityReport();
|
||||
const decision = await sentinel.decide(activity);
|
||||
expect(decision).toEqual(SentinelDecision.Allowed);
|
||||
expect(decision).toEqual([SentinelDecision.Allowed, mockedTime]);
|
||||
});
|
||||
|
||||
it('should return blocked if the activity is not blocked and there are 5 failed attempts', async () => {
|
||||
|
@ -84,7 +92,7 @@ describe('BasicSentinel -> decide()', () => {
|
|||
|
||||
const activity = createMockActivityReport();
|
||||
const decision = await sentinel.decide(activity);
|
||||
expect(decision).toEqual(SentinelDecision.Blocked);
|
||||
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 () => {
|
||||
|
@ -95,6 +103,6 @@ describe('BasicSentinel -> decide()', () => {
|
|||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
activity.actionResult = SentinelActionResult.Failed;
|
||||
const decision = await sentinel.decide(activity);
|
||||
expect(decision).toEqual(SentinelDecision.Blocked);
|
||||
expect(decision).toEqual([SentinelDecision.Blocked, mockedBlockedTime]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,13 +2,14 @@ import {
|
|||
type ActivityReport,
|
||||
Sentinel,
|
||||
SentinelDecision,
|
||||
type SentinelDecisionTuple,
|
||||
type SentinelActivity,
|
||||
SentinelActivities,
|
||||
SentinelActionResult,
|
||||
SentinelActivityAction,
|
||||
} from '@logto/schemas';
|
||||
import { convertToIdentifiers, generateStandardId } from '@logto/shared';
|
||||
import { cond } from '@silverhand/essentials';
|
||||
import { type Nullable } from '@silverhand/essentials';
|
||||
import { addMinutes } from 'date-fns';
|
||||
import { sql, type CommonQueryMethods } from 'slonik';
|
||||
|
||||
|
@ -64,26 +65,26 @@ export default class BasicSentinel extends 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> {
|
||||
async reportActivity(activity: ActivityReport): Promise<SentinelDecisionTuple> {
|
||||
BasicSentinel.assertAction(activity.action);
|
||||
|
||||
const decision = await this.decide(activity);
|
||||
const [decision, decisionExpiresAt] = await this.decide(activity);
|
||||
|
||||
await this.insertActivity({
|
||||
id: generateStandardId(),
|
||||
...activity,
|
||||
decision,
|
||||
decisionExpiresAt: cond(
|
||||
decision === SentinelDecision.Blocked && addMinutes(new Date(), 10).valueOf()
|
||||
),
|
||||
decisionExpiresAt,
|
||||
});
|
||||
|
||||
return decision;
|
||||
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
|
||||
|
@ -91,9 +92,9 @@ export default class BasicSentinel extends Sentinel {
|
|||
*/
|
||||
protected async isBlocked(
|
||||
query: Pick<SentinelActivity, 'targetType' | 'targetHash'>
|
||||
): Promise<boolean> {
|
||||
const blocked = await this.pool.maybeOne(sql`
|
||||
select ${fields.id} from ${table}
|
||||
): 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})
|
||||
|
@ -101,16 +102,16 @@ export default class BasicSentinel extends Sentinel {
|
|||
and ${fields.decisionExpiresAt} > now()
|
||||
limit 1
|
||||
`);
|
||||
return Boolean(blocked);
|
||||
return blocked && [SentinelDecision.Blocked, blocked.decisionExpiresAt];
|
||||
}
|
||||
|
||||
protected async decide(
|
||||
query: Pick<SentinelActivity, 'targetType' | 'targetHash' | 'actionResult'>
|
||||
): Promise<SentinelDecision> {
|
||||
): Promise<SentinelDecisionTuple> {
|
||||
const blocked = await this.isBlocked(query);
|
||||
|
||||
if (blocked) {
|
||||
return SentinelDecision.Blocked;
|
||||
return blocked;
|
||||
}
|
||||
|
||||
const failedAttempts = await this.pool.oneFirst<number>(sql`
|
||||
|
@ -122,9 +123,10 @@ export default class BasicSentinel extends Sentinel {
|
|||
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
|
||||
: SentinelDecision.Allowed;
|
||||
? [SentinelDecision.Blocked, addMinutes(now, 10).valueOf()]
|
||||
: [SentinelDecision.Allowed, now.valueOf()];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<SentinelDecision>;
|
||||
abstract reportActivity(activity: ActivityReport): Promise<SentinelDecisionTuple>;
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue