Skip to content

Commit

Permalink
feat(core): integrate basic sentinel (#4562)
Browse files Browse the repository at this point in the history
* feat(core): integrate basic sentinel

* chore: add integration tests

* refactor(test): fix toast matching

* chore: add changeset

* refactor(test): update naming
  • Loading branch information
gao-sun authored Sep 25, 2023
1 parent b8e592d commit 827123f
Show file tree
Hide file tree
Showing 32 changed files with 385 additions and 84 deletions.
5 changes: 5 additions & 0 deletions .changeset/proud-meals-bathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@logto/core": patch
---

block an identifier from verification for 10 minutes after 5 failed attempts within 1 hour
8 changes: 1 addition & 7 deletions packages/core/src/middleware/koa-i18next.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
import type { i18n } from 'i18next';
import _i18next from 'i18next';
import type { MiddlewareType } from 'koa';
import type { IRouterParamContext } from 'koa-router';

import detectLanguage from '#src/i18n/detect-language.js';

// This may be fixed by a cjs require wrapper. TBD.
// See https://github.com/microsoft/TypeScript/issues/49189
// eslint-disable-next-line no-restricted-syntax
const i18next = _i18next as unknown as i18n;
import { i18next } from '#src/utils/i18n.js';

type LanguageUtils = {
formatLanguageCode(code: string): string;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import type {
InteractionEvent,
IdentifierPayload,
SocialConnectorPayload,
VerifyVerificationCodePayload,
import {
type InteractionEvent,
type IdentifierPayload,
type SocialConnectorPayload,
type VerifyVerificationCodePayload,
SentinelActionResult,
SentinelActivityTargetType,
SentinelDecision,
SentinelActivityAction,
} from '@logto/schemas';
import { type Optional, isKeyInObject } from '@silverhand/essentials';
import { sha256 } from 'hash-wasm';

import RequestError from '#src/errors/RequestError/index.js';
import { verifyUserPassword } from '#src/libraries/user.js';
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';
import { i18next } from '#src/utils/i18n.js';

import type {
PasswordIdentifierPayload,
Expand Down Expand Up @@ -126,7 +133,20 @@ const verifySocialVerifiedIdentifier = async (
};
};

export default async function identifierPayloadVerification(
/**
* Validate the identifier payload according to the payload type. Type should be one of
* the following:
*
* - Password: If an existing user with the given identifier exists, and the password is
* correct, then the payload is valid.
* - Verification code: If the verification code in the payload matches the one sent to
* the given identifier, then the payload is valid.
* - Social: If the connector can use the session data to retrieve the user info, then
* the payload is valid.
* - Social verified email/phone: If the connector session data contains the verified email
* or phone, then the payload is valid.
*/
async function identifierPayloadVerification(
ctx: WithLogContext,
tenant: TenantContext,
identifierPayload: IdentifierPayload,
Expand All @@ -149,3 +169,99 @@ export default async function identifierPayloadVerification(
// Sign-In with social verified email or phone
return verifySocialVerifiedIdentifier(identifierPayload, ctx, interactionStorage);
}

const getActionByPayload = (payload: IdentifierPayload): Optional<SentinelActivityAction> => {
if (isPasswordIdentifier(payload)) {
return SentinelActivityAction.Password;
}

if (isVerificationCodeIdentifier(payload)) {
return SentinelActivityAction.VerificationCode;
}
};

const getUserIdentifier = (payload: IdentifierPayload): Optional<string> => {
for (const key of ['username', 'email', 'phone'] as const) {
if (isKeyInObject(payload, key)) {
return String(payload[key]);
}
}
};

/**
* Verify the identifier payload, and report the activity to this sentinel. The sentinel
* will decide whether to block the user or not.
*
* If the payload is not recognized, the activity will be ignored. Supported payloads are the
* cartesian product of (identifier type) x (action type):
*
* - Identifier type: Username, email, phone
* - Action type: Password, verification code
*
* @remarks
* If the user is blocked, the verification will still be performed, but the promise will be
* rejected with a {@link RequestError} with the code `session.verification_blocked_too_many_attempts`.
*
* If the user is not blocked, but the verification throws, the promise will be rejected with
* the error thrown by the verification.
*
* @param verificationPromise The promise that resolves when the verification is complete.
* @param payload The payload to report.
* @returns The result of the verification.
* @throws {RequestError} If the user is blocked.
* @throws If the user is not blocked but the verification throws.
* @see {@link identifierPayloadVerification} for the actual verification.
*/
const verifyIdentifierPayload: typeof identifierPayloadVerification = async (
ctx,
tenant,
identifierPayload,
interactionStorage
) => {
const action = getActionByPayload(identifierPayload);
const identifier = getUserIdentifier(identifierPayload);
const verificationPromise = identifierPayloadVerification(
ctx,
tenant,
identifierPayload,
interactionStorage
);

if (!action || !identifier) {
return verificationPromise;
}

const [result, error] = await (async () => {
try {
return [await verificationPromise, undefined];
} catch (error) {
return [undefined, error instanceof Error ? error : new Error(String(error))];
}
})();

const actionResult = error ? SentinelActionResult.Failed : SentinelActionResult.Success;

const [decision, decisionExpiresAt] = await tenant.sentinel.reportActivity({
targetType: SentinelActivityTargetType.User,
targetHash: await sha256(identifier),
action,
actionResult,
payload: { event: interactionStorage.event }, // Maybe also include the session data?
});

if (decision === SentinelDecision.Blocked) {
const rtf = new Intl.RelativeTimeFormat([...i18next.languages]);
throw new RequestError({
code: 'session.verification_blocked_too_many_attempts',
relativeTime: rtf.format(Math.round((decisionExpiresAt - Date.now()) / 1000 / 60), 'minute'),
});
}

if (error) {
throw error;
}

return result;
};

export default verifyIdentifierPayload;
5 changes: 1 addition & 4 deletions packages/core/src/sentinel/basic-sentinel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,7 @@ export default class BasicSentinel extends Sentinel {
] as const);

/** The array of all supported actions in SQL format. */
static supportedActionArray = sql.array(
BasicSentinel.supportedActions,
SentinelActivities.fields.action
);
static supportedActionArray = sql.array(BasicSentinel.supportedActions, 'varchar');

/**
* Asserts that the given action is supported by this sentinel.
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/tenants/Tenant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import koaSpaSessionGuard from '#src/middleware/koa-spa-session-guard.js';
import initOidc from '#src/oidc/init.js';
import initApis from '#src/routes/init.js';
import initMeApis from '#src/routes-me/init.js';
import BasicSentinel from '#src/sentinel/basic-sentinel.js';

import Libraries from './Libraries.js';
import Queries from './Queries.js';
Expand Down Expand Up @@ -57,7 +58,8 @@ export default class Tenant implements TenantContext {
public readonly logtoConfigs = createLogtoConfigLibrary(queries),
public readonly cloudConnection = createCloudConnectionLibrary(logtoConfigs),
public readonly connectors = createConnectorLibrary(queries, cloudConnection),
public readonly libraries = new Libraries(id, queries, connectors, cloudConnection)
public readonly libraries = new Libraries(id, queries, connectors, cloudConnection),
public readonly sentinel = new BasicSentinel(envSet.pool)
) {
const isAdminTenant = id === adminTenantId;
const mountedApps = [
Expand Down Expand Up @@ -92,6 +94,7 @@ export default class Tenant implements TenantContext {
connectors,
libraries,
envSet,
sentinel,
};

// Mount APIs
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/tenants/TenantContext.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type Sentinel } from '@logto/schemas';
import type Provider from 'oidc-provider';

import type { EnvSet } from '#src/env-set/index.js';
Expand All @@ -17,4 +18,5 @@ export default abstract class TenantContext {
public abstract readonly cloudConnection: CloudConnectionLibrary;
public abstract readonly connectors: ConnectorLibrary;
public abstract readonly libraries: Libraries;
public abstract readonly sentinel: Sentinel;
}
7 changes: 7 additions & 0 deletions packages/core/src/test-utils/sentinel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Sentinel, SentinelDecision } from '@logto/schemas';

export class MockSentinel extends Sentinel {
override async reportActivity(activity: unknown) {
return [SentinelDecision.Allowed, Date.now()] as const;
}
}
4 changes: 4 additions & 0 deletions packages/core/src/test-utils/tenant.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type Sentinel } from '@logto/schemas';
import { TtlCache } from '@logto/shared';
import { createMockPool, createMockQueryResult } from 'slonik';

Expand All @@ -15,6 +16,7 @@ import type TenantContext from '#src/tenants/TenantContext.js';
import { mockEnvSet } from './env-set.js';
import type { GrantMock } from './oidc-provider.js';
import { createMockProvider } from './oidc-provider.js';
import { MockSentinel } from './sentinel.js';

export class MockWellKnownCache extends WellKnownCache {
constructor(public ttlCache = new TtlCache<string, string>(60_000)) {
Expand Down Expand Up @@ -65,6 +67,7 @@ export class MockTenant implements TenantContext {
public cloudConnection: CloudConnectionLibrary;
public connectors: ConnectorLibrary;
public libraries: Libraries;
public sentinel: Sentinel;

constructor(
public provider = createMockProvider(),
Expand All @@ -81,6 +84,7 @@ export class MockTenant implements TenantContext {
};
this.libraries = new Libraries(this.id, this.queries, this.connectors, this.cloudConnection);
this.setPartial('libraries', librariesOverride);
this.sentinel = new MockSentinel();
}

setPartialKey<Type extends 'queries' | 'libraries', Key extends keyof this[Type]>(
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/utils/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { i18n } from 'i18next';
import _i18next from 'i18next';

// This may be fixed by a cjs require wrapper. TBD.
// See https://github.com/microsoft/TypeScript/issues/49189
// eslint-disable-next-line no-restricted-syntax
export const i18next = _i18next as unknown as i18n;
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { demoAppUrl } from '#src/constants.js';
import ExpectExperience from '#src/ui-helpers/expect-experience.js';
import { setupUsernameAndEmailExperience } from '#src/ui-helpers/index.js';

describe('basic sentinel', () => {
beforeAll(async () => {
await setupUsernameAndEmailExperience();
});

it('should block a non-existing identifier after 5 failed attempts in 1 hour', async () => {
const experience = new ExpectExperience(await browser.newPage(), { forgotPassword: true });
// Open the demo app and navigate to the sign-in page
await experience.startWith(demoAppUrl, 'sign-in');
await experience.toFillInput('identifier', 'nonexisting_username_9', { submit: true });

// Password tests
experience.toBeAt('sign-in/password');

await experience.toFillPasswordsToInputs(
{ inputNames: ['password'], shouldNavigate: false },
['1', 'account or password'],
['2', 'account or password'],
['3', 'account or password'],
['4', 'account or password'],
'5'
);

await experience.waitForToast('Too many attempts');
await experience.page.reload({ waitUntil: 'networkidle0' });
await experience.toFillPasswordsToInputs(
{ inputNames: ['password'], shouldNavigate: false },
'6'
);
await experience.waitForToast('Too many attempts');
});

it('should block failed attempts from both password and verification code', async () => {
const experience = new ExpectExperience(await browser.newPage(), { forgotPassword: true });
// Open the demo app and navigate to the sign-in page
await experience.startWith(demoAppUrl, 'sign-in');
await experience.toFillInput('identifier', '[email protected]', { submit: true });
await experience.toFillPasswordsToInputs(
{ inputNames: ['password'], shouldNavigate: false },
['1', 'account or password'],
['2', 'account or password'],
['3', 'account or password']
);
await experience.toClick('a', 'with verification code');
await experience.toFillVerificationCode('000000');
await experience.toFillVerificationCode('000000');
await experience.waitForToast('Too many attempts');
await experience.page.reload({ waitUntil: 'networkidle0' });
await experience.toFillVerificationCode('000000');
await experience.waitForToast('Too many attempts');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe('smoke testing on the demo app', () => {

// Simple password tests
experience.toBeAt('register/password');
await experience.toFillPasswords(
await experience.toFillNewPasswords(
[credentials.pwnedPassword, 'simple password'],
credentials.password
);
Expand Down
Loading

0 comments on commit 827123f

Please sign in to comment.