0
Fork 0
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:
Gao Sun 2023-09-19 17:21:45 +08:00
parent b9ab1f3d85
commit f34b2f4e21
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
3 changed files with 38 additions and 25 deletions

View file

@ -5,6 +5,7 @@ import {
SentinelActivityTargetType, SentinelActivityTargetType,
SentinelDecision, SentinelDecision,
} from '@logto/schemas'; } from '@logto/schemas';
import { addMinutes } from 'date-fns';
import { createMockCommonQueryMethods, expectSqlString } from '#src/test-utils/query.js'; import { createMockCommonQueryMethods, expectSqlString } from '#src/test-utils/query.js';
@ -26,6 +27,12 @@ class TestSentinel extends BasicSentinel {
const methods = createMockCommonQueryMethods(); const methods = createMockCommonQueryMethods();
const sentinel = new TestSentinel(methods); 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(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
@ -39,7 +46,7 @@ describe('BasicSentinel -> reportActivity()', () => {
const activity = createMockActivityReport(); const activity = createMockActivityReport();
const decision = await sentinel.reportActivity(activity); 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).toHaveBeenCalledTimes(1);
expect(methods.query).toHaveBeenCalledWith( expect(methods.query).toHaveBeenCalledWith(
expectSqlString('insert into "sentinel_activities"') expectSqlString('insert into "sentinel_activities"')
@ -48,11 +55,11 @@ describe('BasicSentinel -> reportActivity()', () => {
it('should insert a blocked activity', async () => { it('should insert a blocked activity', async () => {
// Mock the query method to return a blocked activity // Mock the query method to return a blocked activity
methods.maybeOne.mockResolvedValueOnce({ id: 0 }); methods.maybeOne.mockResolvedValueOnce({ decisionExpiresAt: mockedBlockedTime });
const activity = createMockActivityReport(); const activity = createMockActivityReport();
const decision = await sentinel.reportActivity(activity); 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).toHaveBeenCalledTimes(1);
expect(methods.query).toHaveBeenCalledWith( expect(methods.query).toHaveBeenCalledWith(
expectSqlString('insert into "sentinel_activities"') expectSqlString('insert into "sentinel_activities"')
@ -61,12 +68,13 @@ describe('BasicSentinel -> reportActivity()', () => {
}); });
describe('BasicSentinel -> decide()', () => { describe('BasicSentinel -> decide()', () => {
it('should return blocked if the activity is blocked', async () => { it('should return existing blocked time if the activity is blocked', async () => {
methods.maybeOne.mockResolvedValueOnce({ id: 0 }); const existingBlockedTime = addMinutes(mockedTime, 5).valueOf();
methods.maybeOne.mockResolvedValueOnce({ decisionExpiresAt: existingBlockedTime });
const activity = createMockActivityReport(); const activity = createMockActivityReport();
const decision = await sentinel.decide(activity); 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 () => { 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 activity = createMockActivityReport();
const decision = await sentinel.decide(activity); 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 () => { 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 activity = createMockActivityReport();
const decision = await sentinel.decide(activity); 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 () => { 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 // eslint-disable-next-line @silverhand/fp/no-mutation
activity.actionResult = SentinelActionResult.Failed; activity.actionResult = SentinelActionResult.Failed;
const decision = await sentinel.decide(activity); const decision = await sentinel.decide(activity);
expect(decision).toEqual(SentinelDecision.Blocked); expect(decision).toEqual([SentinelDecision.Blocked, mockedBlockedTime]);
}); });
}); });

View file

@ -2,13 +2,14 @@ import {
type ActivityReport, type ActivityReport,
Sentinel, Sentinel,
SentinelDecision, SentinelDecision,
type SentinelDecisionTuple,
type SentinelActivity, type SentinelActivity,
SentinelActivities, SentinelActivities,
SentinelActionResult, SentinelActionResult,
SentinelActivityAction, SentinelActivityAction,
} from '@logto/schemas'; } from '@logto/schemas';
import { convertToIdentifiers, generateStandardId } from '@logto/shared'; import { convertToIdentifiers, generateStandardId } from '@logto/shared';
import { cond } from '@silverhand/essentials'; import { type Nullable } from '@silverhand/essentials';
import { addMinutes } from 'date-fns'; import { addMinutes } from 'date-fns';
import { sql, type CommonQueryMethods } from 'slonik'; import { sql, type CommonQueryMethods } from 'slonik';
@ -64,26 +65,26 @@ export default class BasicSentinel extends Sentinel {
* @throws {Error} If the action is not supported. * @throws {Error} If the action is not supported.
* @see {@link BasicSentinel.supportedActions} for the list of supported actions. * @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); BasicSentinel.assertAction(activity.action);
const decision = await this.decide(activity); const [decision, decisionExpiresAt] = await this.decide(activity);
await this.insertActivity({ await this.insertActivity({
id: generateStandardId(), id: generateStandardId(),
...activity, ...activity,
decision, decision,
decisionExpiresAt: cond( decisionExpiresAt,
decision === SentinelDecision.Blocked && addMinutes(new Date(), 10).valueOf()
),
}); });
return decision; return [decision, decisionExpiresAt];
} }
/** /**
* Checks whether the given target is blocked from performing actions. * 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 * @remarks
* All supported actions share the same pool of activities, i.e. once a user has failed to * 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 * 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( protected async isBlocked(
query: Pick<SentinelActivity, 'targetType' | 'targetHash'> query: Pick<SentinelActivity, 'targetType' | 'targetHash'>
): Promise<boolean> { ): Promise<Nullable<SentinelDecisionTuple>> {
const blocked = await this.pool.maybeOne(sql` const blocked = await this.pool.maybeOne<Pick<SentinelActivity, 'decisionExpiresAt'>>(sql`
select ${fields.id} from ${table} select ${fields.decisionExpiresAt} from ${table}
where ${fields.targetType} = ${query.targetType} where ${fields.targetType} = ${query.targetType}
and ${fields.targetHash} = ${query.targetHash} and ${fields.targetHash} = ${query.targetHash}
and ${fields.action} = any(${BasicSentinel.supportedActionArray}) and ${fields.action} = any(${BasicSentinel.supportedActionArray})
@ -101,16 +102,16 @@ export default class BasicSentinel extends Sentinel {
and ${fields.decisionExpiresAt} > now() and ${fields.decisionExpiresAt} > now()
limit 1 limit 1
`); `);
return Boolean(blocked); return blocked && [SentinelDecision.Blocked, blocked.decisionExpiresAt];
} }
protected async decide( protected async decide(
query: Pick<SentinelActivity, 'targetType' | 'targetHash' | 'actionResult'> query: Pick<SentinelActivity, 'targetType' | 'targetHash' | 'actionResult'>
): Promise<SentinelDecision> { ): Promise<SentinelDecisionTuple> {
const blocked = await this.isBlocked(query); const blocked = await this.isBlocked(query);
if (blocked) { if (blocked) {
return SentinelDecision.Blocked; return blocked;
} }
const failedAttempts = await this.pool.oneFirst<number>(sql` 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.decision} != ${SentinelDecision.Blocked}
and ${fields.createdAt} > now() - interval '1 hour' and ${fields.createdAt} > now() - interval '1 hour'
`); `);
const now = new Date();
return failedAttempts + (query.actionResult === SentinelActionResult.Failed ? 1 : 0) >= 5 return failedAttempts + (query.actionResult === SentinelActionResult.Failed ? 1 : 0) >= 5
? SentinelDecision.Blocked ? [SentinelDecision.Blocked, addMinutes(now, 10).valueOf()]
: SentinelDecision.Allowed; : [SentinelDecision.Allowed, now.valueOf()];
} }
} }

View file

@ -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>;
} }