-
-
Notifications
You must be signed in to change notification settings - Fork 418
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #4554 from logto-io/gao-basic-sentinel
feat(core): init basic sentinel
- Loading branch information
Showing
4 changed files
with
272 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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]); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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()]; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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), | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters