From 827123faa0e9fcc97315fba1e429b265a66f6c58 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Mon, 25 Sep 2023 16:20:17 +0800 Subject: [PATCH] feat(core): integrate basic sentinel (#4562) * feat(core): integrate basic sentinel * chore: add integration tests * refactor(test): fix toast matching * chore: add changeset * refactor(test): update naming --- .changeset/proud-meals-bathe.md | 5 + packages/core/src/middleware/koa-i18next.ts | 8 +- .../identifier-payload-verification.ts | 128 +++++++++++++++++- packages/core/src/sentinel/basic-sentinel.ts | 5 +- packages/core/src/tenants/Tenant.ts | 5 +- packages/core/src/tenants/TenantContext.ts | 2 + packages/core/src/test-utils/sentinel.ts | 7 + packages/core/src/test-utils/tenant.ts | 4 + packages/core/src/utils/i18n.ts | 7 + .../tests/experience/basic-sentinel.test.ts | 56 ++++++++ .../src/tests/experience/bootstrap.test.ts | 2 +- .../tests/experience/password-policy.test.ts | 49 ++----- .../src/ui-helpers/expect-console.ts | 17 ++- .../src/ui-helpers/expect-experience.ts | 63 +++++++-- .../src/ui-helpers/expect-page.ts | 21 ++- .../integration-tests/src/ui-helpers/index.ts | 44 ++++++ .../phrases/src/locales/de/errors/session.ts | 3 + .../phrases/src/locales/en/errors/session.ts | 2 + .../phrases/src/locales/es/errors/session.ts | 3 + .../phrases/src/locales/fr/errors/session.ts | 3 + .../phrases/src/locales/it/errors/session.ts | 3 + .../phrases/src/locales/ja/errors/session.ts | 3 + .../phrases/src/locales/ko/errors/session.ts | 3 + .../src/locales/pl-pl/errors/session.ts | 3 + .../src/locales/pt-br/errors/session.ts | 3 + .../src/locales/pt-pt/errors/session.ts | 3 + .../phrases/src/locales/ru/errors/session.ts | 3 + .../src/locales/tr-tr/errors/session.ts | 3 + .../src/locales/zh-cn/errors/session.ts | 3 + .../src/locales/zh-hk/errors/session.ts | 3 + .../src/locales/zh-tw/errors/session.ts | 3 + packages/schemas/src/types/sentinel.ts | 2 +- 32 files changed, 385 insertions(+), 84 deletions(-) create mode 100644 .changeset/proud-meals-bathe.md create mode 100644 packages/core/src/test-utils/sentinel.ts create mode 100644 packages/core/src/utils/i18n.ts create mode 100644 packages/integration-tests/src/tests/experience/basic-sentinel.test.ts diff --git a/.changeset/proud-meals-bathe.md b/.changeset/proud-meals-bathe.md new file mode 100644 index 00000000000..4a167df4fd0 --- /dev/null +++ b/.changeset/proud-meals-bathe.md @@ -0,0 +1,5 @@ +--- +"@logto/core": patch +--- + +block an identifier from verification for 10 minutes after 5 failed attempts within 1 hour diff --git a/packages/core/src/middleware/koa-i18next.ts b/packages/core/src/middleware/koa-i18next.ts index ec3d4d44412..488223aa659 100644 --- a/packages/core/src/middleware/koa-i18next.ts +++ b/packages/core/src/middleware/koa-i18next.ts @@ -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; diff --git a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts index 172dee2d076..f685b7b912e 100644 --- a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts +++ b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts @@ -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, @@ -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, @@ -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 => { + if (isPasswordIdentifier(payload)) { + return SentinelActivityAction.Password; + } + + if (isVerificationCodeIdentifier(payload)) { + return SentinelActivityAction.VerificationCode; + } +}; + +const getUserIdentifier = (payload: IdentifierPayload): Optional => { + 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; diff --git a/packages/core/src/sentinel/basic-sentinel.ts b/packages/core/src/sentinel/basic-sentinel.ts index d53b8fd8c10..b91b2383b3f 100644 --- a/packages/core/src/sentinel/basic-sentinel.ts +++ b/packages/core/src/sentinel/basic-sentinel.ts @@ -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. diff --git a/packages/core/src/tenants/Tenant.ts b/packages/core/src/tenants/Tenant.ts index b51fb130ead..9319475207a 100644 --- a/packages/core/src/tenants/Tenant.ts +++ b/packages/core/src/tenants/Tenant.ts @@ -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'; @@ -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 = [ @@ -92,6 +94,7 @@ export default class Tenant implements TenantContext { connectors, libraries, envSet, + sentinel, }; // Mount APIs diff --git a/packages/core/src/tenants/TenantContext.ts b/packages/core/src/tenants/TenantContext.ts index 13cff3daecc..fa28ccb4ebe 100644 --- a/packages/core/src/tenants/TenantContext.ts +++ b/packages/core/src/tenants/TenantContext.ts @@ -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'; @@ -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; } diff --git a/packages/core/src/test-utils/sentinel.ts b/packages/core/src/test-utils/sentinel.ts new file mode 100644 index 00000000000..790e2c5a9c9 --- /dev/null +++ b/packages/core/src/test-utils/sentinel.ts @@ -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; + } +} diff --git a/packages/core/src/test-utils/tenant.ts b/packages/core/src/test-utils/tenant.ts index 3d0be39e845..7dfbed27ad8 100644 --- a/packages/core/src/test-utils/tenant.ts +++ b/packages/core/src/test-utils/tenant.ts @@ -1,3 +1,4 @@ +import { type Sentinel } from '@logto/schemas'; import { TtlCache } from '@logto/shared'; import { createMockPool, createMockQueryResult } from 'slonik'; @@ -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(60_000)) { @@ -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(), @@ -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( diff --git a/packages/core/src/utils/i18n.ts b/packages/core/src/utils/i18n.ts new file mode 100644 index 00000000000..b240ece7096 --- /dev/null +++ b/packages/core/src/utils/i18n.ts @@ -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; diff --git a/packages/integration-tests/src/tests/experience/basic-sentinel.test.ts b/packages/integration-tests/src/tests/experience/basic-sentinel.test.ts new file mode 100644 index 00000000000..e8106c74f1c --- /dev/null +++ b/packages/integration-tests/src/tests/experience/basic-sentinel.test.ts @@ -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', 'test_basic_sentinel_7@foo.com', { 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'); + }); +}); diff --git a/packages/integration-tests/src/tests/experience/bootstrap.test.ts b/packages/integration-tests/src/tests/experience/bootstrap.test.ts index 1a8e1c72fe8..d8cea77f3fc 100644 --- a/packages/integration-tests/src/tests/experience/bootstrap.test.ts +++ b/packages/integration-tests/src/tests/experience/bootstrap.test.ts @@ -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 ); diff --git a/packages/integration-tests/src/tests/experience/password-policy.test.ts b/packages/integration-tests/src/tests/experience/password-policy.test.ts index 4e8ce879232..d0f6dbe9a22 100644 --- a/packages/integration-tests/src/tests/experience/password-policy.test.ts +++ b/packages/integration-tests/src/tests/experience/password-policy.test.ts @@ -1,11 +1,12 @@ /* Test the sign-in with different password policies. */ -import { ConnectorType, SignInIdentifier, SignInMode } from '@logto/schemas'; +import { ConnectorType, SignInIdentifier } from '@logto/schemas'; import { updateSignInExperience } from '#src/api/sign-in-experience.js'; import { demoAppUrl } from '#src/constants.js'; import { clearConnectorsByTypes, setEmailConnector } from '#src/helpers/connector.js'; import ExpectExperience from '#src/ui-helpers/expect-experience.js'; +import { setupUsernameAndEmailExperience } from '#src/ui-helpers/index.js'; import { waitFor } from '#src/utils.js'; describe('password policy', () => { @@ -23,38 +24,14 @@ describe('password policy', () => { beforeAll(async () => { await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); await setEmailConnector(); - await updateSignInExperience({ - signInMode: SignInMode.SignInAndRegister, - signUp: { - identifiers: [SignInIdentifier.Username], - password: true, - verify: false, - }, - signIn: { - methods: [ - { - identifier: SignInIdentifier.Username, - password: true, - verificationCode: false, - isPasswordPrimary: true, - }, - { - identifier: SignInIdentifier.Email, - password: true, - verificationCode: true, - isPasswordPrimary: true, - }, - ], - }, - passwordPolicy: { - length: { min: 8, max: 32 }, - characterTypes: { min: 3 }, - rejects: { - pwned: true, - repetitionAndSequence: true, - userInfo: true, - words: [username], - }, + await setupUsernameAndEmailExperience({ + length: { min: 8, max: 32 }, + characterTypes: { min: 3 }, + rejects: { + pwned: true, + repetitionAndSequence: true, + userInfo: true, + words: [username], }, }); }); @@ -68,7 +45,7 @@ describe('password policy', () => { // Password tests experience.toBeAt('register/password'); - await experience.toFillPasswords( + await experience.toFillNewPasswords( ...invalidPasswords, [username + 'A', /product context .* personal information/], username + 'ABCD_ok' @@ -98,7 +75,7 @@ describe('password policy', () => { // Wait for the password page to load await waitFor(100); experience.toBeAt('continue/password'); - await experience.toFillPasswords( + await experience.toFillNewPasswords( ...invalidPasswords, [emailName, 'personal information'], emailName + 'ABCD@# $' @@ -126,7 +103,7 @@ describe('password policy', () => { // Wait for the password page to load await waitFor(100); experience.toBeAt('forgot-password/reset'); - await experience.toFillPasswords( + await experience.toFillNewPasswords( ...invalidPasswords, [emailName, 'personal information'], [emailName + 'ABCD@# $', 'be the same as'], diff --git a/packages/integration-tests/src/ui-helpers/expect-console.ts b/packages/integration-tests/src/ui-helpers/expect-console.ts index 81bc8a4b13c..5a5b0870b46 100644 --- a/packages/integration-tests/src/ui-helpers/expect-console.ts +++ b/packages/integration-tests/src/ui-helpers/expect-console.ts @@ -1,9 +1,9 @@ import path from 'node:path'; -import { appendPath } from '@silverhand/essentials'; +import { appendPath, condString } from '@silverhand/essentials'; import { consolePassword, consoleUsername, logtoConsoleUrl } from '#src/constants.js'; -import { dcls } from '#src/utils.js'; +import { cls, dcls } from '#src/utils.js'; import ExpectPage, { ExpectPageError } from './expect-page.js'; import { expectConfirmModalAndAct, expectToSaveChanges } from './index.js'; @@ -106,6 +106,19 @@ export default class ExpectConsole extends ExpectPage { }); } + /** + * Expect a toast to appear with the given text, then remove it immediately. + * + * @param text The text to match. + * @param type The type of the toast, if provided. + */ + async waitForToast(text: string | RegExp, type?: 'success' | 'error') { + return this.toMatchAndRemove( + `${cls('toast')}${condString(type && cls(type))}:has(${dcls('message')})`, + text + ); + } + async toSaveChanges(confirmation?: string | RegExp) { await expectToSaveChanges(this.page); diff --git a/packages/integration-tests/src/ui-helpers/expect-experience.ts b/packages/integration-tests/src/ui-helpers/expect-experience.ts index a18ea6bc201..998158cab03 100644 --- a/packages/integration-tests/src/ui-helpers/expect-experience.ts +++ b/packages/integration-tests/src/ui-helpers/expect-experience.ts @@ -113,7 +113,7 @@ export default class ExpectExperience extends ExpectPage { } /** - * Assert the page is at the verification code page and fill the verification code input with the + * Assert the page is at the verification code page and fill the verification code inputs with the * code from Logto database. * * @param type The type of experience to expect. @@ -121,7 +121,15 @@ export default class ExpectExperience extends ExpectPage { async toCompleteVerification(type: ExperienceType) { this.toBeAt(`${type}/verification-code`); const { code } = await readVerificationCode(); + await this.toFillVerificationCode(code); + } + /** + * Fill the verification code inputs with the given code. + * + * @param code The verification code to fill. + */ + async toFillVerificationCode(code: string) { for (const [index, char] of code.split('').entries()) { // eslint-disable-next-line no-await-in-loop await this.toFillInput(`passcode_${index}`, char); @@ -129,10 +137,32 @@ export default class ExpectExperience extends ExpectPage { } /** - * Fill the password inputs with the given passwords. If the password is an array, the second - * element will be used to assert the error message; otherwise, the password is expected to be - * valid and the form will be submitted. + * Fill the password form inputs with the given passwords. If forgot password flow is enabled, + * only the `newPassword` input will be filled; otherwise, both `newPassword` and `confirmPassword` + * will be filled. + * + * @param passwords The passwords to fill. + * @see {@link toFillPasswordsToInputs} for filling passwords to specific named inputs. + */ + async toFillNewPasswords( + ...passwords: Array + ) { + return this.toFillPasswordsToInputs( + { + inputNames: this.options.forgotPassword + ? ['newPassword'] + : ['newPassword', 'confirmPassword'], + }, + ...passwords + ); + } + + /** + * Fill the password form inputs with the given passwords. If the password is an array, + * the second element will be used to assert the error message; otherwise, the password is + * expected to be valid and the form will be submitted. * + * @param inputNames The names of the password form inputs. * @param passwords The passwords to fill. * @example * @@ -146,22 +176,18 @@ export default class ExpectExperience extends ExpectPage { * ); * ``` */ - async toFillPasswords( + async toFillPasswordsToInputs( + { inputNames, shouldNavigate = true }: { inputNames: string[]; shouldNavigate?: boolean }, ...passwords: Array ) { for (const element of passwords) { const [password, errorMessage] = Array.isArray(element) ? element : [element, undefined]; // eslint-disable-next-line no-await-in-loop - await this.toFillForm( - this.options.forgotPassword - ? { newPassword: password } - : { - newPassword: password, - confirmPassword: password, - }, - { submit: true, shouldNavigate: errorMessage === undefined } - ); + await this.toFillForm(Object.fromEntries(inputNames.map((name) => [name, password])), { + submit: true, + shouldNavigate: shouldNavigate && errorMessage === undefined, + }); if (errorMessage === undefined) { break; @@ -175,6 +201,15 @@ export default class ExpectExperience extends ExpectPage { } } + /** + * Expect a toast to appear with the given text, then remove it immediately. + * + * @param text The text to match. + */ + async waitForToast(text: string | RegExp) { + return this.toMatchAndRemove('div[role=toast]', text); + } + /** Build a full experience URL from a pathname. */ protected buildExperienceUrl(pathname = '') { return appendPath(this.options.endpoint, pathname); diff --git a/packages/integration-tests/src/ui-helpers/expect-page.ts b/packages/integration-tests/src/ui-helpers/expect-page.ts index 498923d687e..7be51f30154 100644 --- a/packages/integration-tests/src/ui-helpers/expect-page.ts +++ b/packages/integration-tests/src/ui-helpers/expect-page.ts @@ -1,7 +1,6 @@ -import { condString } from '@silverhand/essentials'; import { type ElementHandle, type Page } from 'puppeteer'; -import { cls, dcls, expectNavigation } from '#src/utils.js'; +import { expectNavigation } from '#src/utils.js'; /** Error thrown by {@link ExpectPage}. */ export class ExpectPageError extends Error { @@ -135,20 +134,18 @@ export default class ExpectPage { } /** - * Expect a toast to appear with the given text, then remove it immediately. + * Expect the page to match an element with the given selector and text, then remove it immediately. * + * @param selector The selector to match. * @param text The text to match. */ - async waitForToast(text: string | RegExp, type?: 'success' | 'error') { - const toast = await expect(this.page).toMatchElement( - `${cls('toast')}${condString(type && cls(type))}:has(${dcls('message')})`, - { - text, - } - ); + async toMatchAndRemove(selector: string, text: string | RegExp) { + const matched = await expect(this.page).toMatchElement(selector, { + text, + }); - // Remove immediately to prevent waiting for the toast to disappear and matching the same toast again - await toast.evaluate((element) => { + // Remove immediately to prevent matching the same element again while waiting for the element to disappear + await matched.evaluate((element) => { element.remove(); }); } diff --git a/packages/integration-tests/src/ui-helpers/index.ts b/packages/integration-tests/src/ui-helpers/index.ts index 5410f44db3d..2d26c01cf49 100644 --- a/packages/integration-tests/src/ui-helpers/index.ts +++ b/packages/integration-tests/src/ui-helpers/index.ts @@ -1,10 +1,14 @@ +import { ConnectorType } from '@logto/connector-kit'; +import { SignInMode, SignInIdentifier, type PartialPasswordPolicy } from '@logto/schemas'; import { type ElementHandle, type Browser, type Page } from 'puppeteer'; +import { updateSignInExperience } from '#src/api/sign-in-experience.js'; import { consolePassword, consoleUsername, logtoConsoleUrl as logtoConsoleUrlString, } from '#src/constants.js'; +import { clearConnectorsByTypes, setEmailConnector } from '#src/helpers/connector.js'; import { expectNavigation, waitFor } from '#src/utils.js'; export const goToAdminConsole = async () => { @@ -146,3 +150,43 @@ export const expectToClickSidebarMenu = async (page: Page, menuText: string) => export const getInputValue = async (input: ElementHandle) => { return input.evaluate((element) => element.value); }; + +/** + * Setup the email connector and update the sign-in experience to the following: + * + * - Sign-in and register mode + * - Use username and password to sign-up + * - Use username or email to sign-in + * - Email sign-in can use verification code + * + * @param passwordPolicy The password policy to partially update the existing one. + */ +export const setupUsernameAndEmailExperience = async (passwordPolicy?: PartialPasswordPolicy) => { + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); + await setEmailConnector(); + await updateSignInExperience({ + signInMode: SignInMode.SignInAndRegister, + signUp: { + identifiers: [SignInIdentifier.Username], + password: true, + verify: false, + }, + signIn: { + methods: [ + { + identifier: SignInIdentifier.Username, + password: true, + verificationCode: false, + isPasswordPrimary: true, + }, + { + identifier: SignInIdentifier.Email, + password: true, + verificationCode: true, + isPasswordPrimary: true, + }, + ], + }, + passwordPolicy, + }); +}; diff --git a/packages/phrases/src/locales/de/errors/session.ts b/packages/phrases/src/locales/de/errors/session.ts index 4613d08131e..5b0b538f20d 100644 --- a/packages/phrases/src/locales/de/errors/session.ts +++ b/packages/phrases/src/locales/de/errors/session.ts @@ -10,6 +10,9 @@ const session = { 'Die Verifizierung war nicht erfolgreich. Starte die Verifizierung neu und versuche es erneut.', verification_expired: 'Die Verbindung wurde unterbrochen. Verifiziere erneut, um die Sicherheit deines Kontos zu gewährleisten.', + /** UNTRANSLATED */ + verification_blocked_too_many_attempts: + 'Too many attempts in a short time. Please try again {{relativeTime}}.', unauthorized: 'Bitte melde dich erst an.', unsupported_prompt_name: 'Nicht unterstützter prompt Name.', forgot_password_not_enabled: 'Forgot password is not enabled.', diff --git a/packages/phrases/src/locales/en/errors/session.ts b/packages/phrases/src/locales/en/errors/session.ts index d94f13196dd..2ff38bbe0b1 100644 --- a/packages/phrases/src/locales/en/errors/session.ts +++ b/packages/phrases/src/locales/en/errors/session.ts @@ -9,6 +9,8 @@ const session = { verification_session_not_found: 'The verification was not successful. Restart the verification flow and try again.', verification_expired: 'The connection has timed out. Verify again to ensure your account safety.', + verification_blocked_too_many_attempts: + 'Too many attempts in a short time. Please try again {{relativeTime}}.', unauthorized: 'Please sign in first.', unsupported_prompt_name: 'Unsupported prompt name.', forgot_password_not_enabled: 'Forgot password is not enabled.', diff --git a/packages/phrases/src/locales/es/errors/session.ts b/packages/phrases/src/locales/es/errors/session.ts index ac6bb82b991..000419b2d2e 100644 --- a/packages/phrases/src/locales/es/errors/session.ts +++ b/packages/phrases/src/locales/es/errors/session.ts @@ -11,6 +11,9 @@ const session = { 'La verificación no se completó correctamente. Reinicie el flujo de verificación e intente de nuevo.', verification_expired: 'La conexión ha expirado. Verifique de nuevo para garantizar la seguridad de su cuenta.', + /** UNTRANSLATED */ + verification_blocked_too_many_attempts: + 'Too many attempts in a short time. Please try again {{relativeTime}}.', unauthorized: 'Inicie sesión primero, por favor.', unsupported_prompt_name: 'Nombre de indicación no compatible.', forgot_password_not_enabled: 'Olvidé la contraseña no está habilitada.', diff --git a/packages/phrases/src/locales/fr/errors/session.ts b/packages/phrases/src/locales/fr/errors/session.ts index 4ad9b7b8bec..971bd950fd0 100644 --- a/packages/phrases/src/locales/fr/errors/session.ts +++ b/packages/phrases/src/locales/fr/errors/session.ts @@ -11,6 +11,9 @@ const session = { "La vérification n'a pas abouti. Redémarrez le processus de vérification et réessayez.", verification_expired: 'La connexion a expiré. Vérifiez à nouveau pour assurer la sécurité de votre compte.', + /** UNTRANSLATED */ + verification_blocked_too_many_attempts: + 'Too many attempts in a short time. Please try again {{relativeTime}}.', unauthorized: "Veuillez vous enregistrer d'abord.", unsupported_prompt_name: "Nom d'invite non supporté.", forgot_password_not_enabled: diff --git a/packages/phrases/src/locales/it/errors/session.ts b/packages/phrases/src/locales/it/errors/session.ts index 2f520fe86c4..3b0496a13ce 100644 --- a/packages/phrases/src/locales/it/errors/session.ts +++ b/packages/phrases/src/locales/it/errors/session.ts @@ -11,6 +11,9 @@ const session = { 'La verifica non è stata completata con successo. Riavvia il processo di verifica e riprova.', verification_expired: 'La connessione è scaduta. Verifica di nuovo per garantire la sicurezza del tuo account.', + /** UNTRANSLATED */ + verification_blocked_too_many_attempts: + 'Too many attempts in a short time. Please try again {{relativeTime}}.', unauthorized: 'Accedi prima di procedere.', unsupported_prompt_name: 'Nome del prompt non supportato.', forgot_password_not_enabled: 'Recupero password non abilitato.', diff --git a/packages/phrases/src/locales/ja/errors/session.ts b/packages/phrases/src/locales/ja/errors/session.ts index 8c1be713551..c9684f5b9e0 100644 --- a/packages/phrases/src/locales/ja/errors/session.ts +++ b/packages/phrases/src/locales/ja/errors/session.ts @@ -11,6 +11,9 @@ const session = { '検証が成功しませんでした。検証フローを再開してもう一度やり直してください。', verification_expired: '接続がタイムアウトしました。アカウントの安全性を確保するために再度検証してください。', + /** UNTRANSLATED */ + verification_blocked_too_many_attempts: + 'Too many attempts in a short time. Please try again {{relativeTime}}.', unauthorized: '最初にサインインしてください。', unsupported_prompt_name: 'サポートされていないプロンプト名です。', forgot_password_not_enabled: 'パスワードを忘れた場合の対処が有効になっていません。', diff --git a/packages/phrases/src/locales/ko/errors/session.ts b/packages/phrases/src/locales/ko/errors/session.ts index 09e814cb643..da145c3b8db 100644 --- a/packages/phrases/src/locales/ko/errors/session.ts +++ b/packages/phrases/src/locales/ko/errors/session.ts @@ -10,6 +10,9 @@ const session = { '검증을 실패했어요. 검증 과정을 다시 시작하고 다시 시도해 주세요.', verification_expired: '연결 시간이 초과되었어요. 검증을 다시 시작하고, 계정이 안전한지 확인해 주세요.', + /** UNTRANSLATED */ + verification_blocked_too_many_attempts: + 'Too many attempts in a short time. Please try again {{relativeTime}}.', unauthorized: '로그인을 먼저 해 주세요.', unsupported_prompt_name: '지원하지 않는 Prompt 이름이에요.', forgot_password_not_enabled: '비밀번호 찾기가 활성화되어있지 않아요.', diff --git a/packages/phrases/src/locales/pl-pl/errors/session.ts b/packages/phrases/src/locales/pl-pl/errors/session.ts index 0834e606671..72c6839796e 100644 --- a/packages/phrases/src/locales/pl-pl/errors/session.ts +++ b/packages/phrases/src/locales/pl-pl/errors/session.ts @@ -10,6 +10,9 @@ const session = { 'Weryfikacja nie powiodła się. Uruchom proces weryfikacji ponownie i spróbuj ponownie.', verification_expired: 'Połączenie wygasło. Zweryfikuj ponownie, aby zapewnić bezpieczeństwo Twojego konta.', + /** UNTRANSLATED */ + verification_blocked_too_many_attempts: + 'Too many attempts in a short time. Please try again {{relativeTime}}.', unauthorized: 'Proszę się najpierw zalogować.', unsupported_prompt_name: 'Nieobsługiwana nazwa podpowiedzi.', forgot_password_not_enabled: 'Odzyskiwanie hasła nie jest włączone.', diff --git a/packages/phrases/src/locales/pt-br/errors/session.ts b/packages/phrases/src/locales/pt-br/errors/session.ts index f5c29300a0a..c6ab86e4b61 100644 --- a/packages/phrases/src/locales/pt-br/errors/session.ts +++ b/packages/phrases/src/locales/pt-br/errors/session.ts @@ -10,6 +10,9 @@ const session = { 'A verificação não foi bem-sucedida. Reinicie o fluxo de verificação e tente novamente.', verification_expired: 'A conexão expirou. Verifique novamente para garantir a segurança da sua conta.', + /** UNTRANSLATED */ + verification_blocked_too_many_attempts: + 'Too many attempts in a short time. Please try again {{relativeTime}}.', unauthorized: 'Faça login primeiro.', unsupported_prompt_name: 'Prompt name incompatível.', forgot_password_not_enabled: 'Esqueceu a senha não está ativado.', diff --git a/packages/phrases/src/locales/pt-pt/errors/session.ts b/packages/phrases/src/locales/pt-pt/errors/session.ts index bdd8d149793..3707da8a336 100644 --- a/packages/phrases/src/locales/pt-pt/errors/session.ts +++ b/packages/phrases/src/locales/pt-pt/errors/session.ts @@ -12,6 +12,9 @@ const session = { 'A verificação não foi bem-sucedida. Reinicie o processo de verificação e tente novamente.', verification_expired: 'A conexão expirou. Verifique novamente para garantir a segurança de sua conta.', + /** UNTRANSLATED */ + verification_blocked_too_many_attempts: + 'Too many attempts in a short time. Please try again {{relativeTime}}.', unauthorized: 'Faça login primeiro.', unsupported_prompt_name: 'Nome de prompt não suportado.', forgot_password_not_enabled: 'Recuperação de senha não está habilitada.', diff --git a/packages/phrases/src/locales/ru/errors/session.ts b/packages/phrases/src/locales/ru/errors/session.ts index 73efd45f02d..43fda09c55e 100644 --- a/packages/phrases/src/locales/ru/errors/session.ts +++ b/packages/phrases/src/locales/ru/errors/session.ts @@ -10,6 +10,9 @@ const session = { 'Верификация не прошла успешно. Перезапустите процесс верификации и попробуйте еще раз.', verification_expired: 'Соединение истекло. Повторите верификацию, чтобы обеспечить безопасность вашей учетной записи.', + /** UNTRANSLATED */ + verification_blocked_too_many_attempts: + 'Too many attempts in a short time. Please try again {{relativeTime}}.', unauthorized: 'Сначала войдите в систему.', unsupported_prompt_name: 'Неподдерживаемое имя подсказки.', forgot_password_not_enabled: 'Забыли пароль не включен.', diff --git a/packages/phrases/src/locales/tr-tr/errors/session.ts b/packages/phrases/src/locales/tr-tr/errors/session.ts index 022c5d71bde..27213c11be7 100644 --- a/packages/phrases/src/locales/tr-tr/errors/session.ts +++ b/packages/phrases/src/locales/tr-tr/errors/session.ts @@ -11,6 +11,9 @@ const session = { 'Doğrulama başarısız oldu. Lütfen doğrulama işlemini yeniden başlatın ve tekrar deneyin.', verification_expired: 'Bağlantı zaman aşımına uğradı. Hesap güvenliğiniz için yeniden doğrulama yapın.', + /** UNTRANSLATED */ + verification_blocked_too_many_attempts: + 'Too many attempts in a short time. Please try again {{relativeTime}}.', unauthorized: 'Lütfen önce oturum açın.', unsupported_prompt_name: 'Desteklenmeyen prompt adı.', forgot_password_not_enabled: 'Parolamı unuttum özelliği etkin değil.', diff --git a/packages/phrases/src/locales/zh-cn/errors/session.ts b/packages/phrases/src/locales/zh-cn/errors/session.ts index 8fbf0e1cbae..f8ce9277437 100644 --- a/packages/phrases/src/locales/zh-cn/errors/session.ts +++ b/packages/phrases/src/locales/zh-cn/errors/session.ts @@ -8,6 +8,9 @@ const session = { connector_session_not_found: '无法找到连接器登录信息,请尝试重新登录。', verification_session_not_found: '验证失败,请重新验证。', verification_expired: '当前页面已超时。为确保你的账号安全,请重新验证。', + /** UNTRANSLATED */ + verification_blocked_too_many_attempts: + 'Too many attempts in a short time. Please try again {{relativeTime}}.', unauthorized: '请先登录', unsupported_prompt_name: '不支持的 prompt name', forgot_password_not_enabled: '忘记密码功能没有开启。', diff --git a/packages/phrases/src/locales/zh-hk/errors/session.ts b/packages/phrases/src/locales/zh-hk/errors/session.ts index 864dafcb923..ddf9786e97c 100644 --- a/packages/phrases/src/locales/zh-hk/errors/session.ts +++ b/packages/phrases/src/locales/zh-hk/errors/session.ts @@ -8,6 +8,9 @@ const session = { connector_session_not_found: '無法找到連接器登錄信息,請嘗試重新登錄。', verification_session_not_found: '驗證失敗,請重新驗證。', verification_expired: '當前頁面已超時。為確保你的賬號安全,請重新驗證。', + /** UNTRANSLATED */ + verification_blocked_too_many_attempts: + 'Too many attempts in a short time. Please try again {{relativeTime}}.', unauthorized: '請先登錄', unsupported_prompt_name: '不支持的 prompt name', forgot_password_not_enabled: '忘記密碼功能沒有開啟。', diff --git a/packages/phrases/src/locales/zh-tw/errors/session.ts b/packages/phrases/src/locales/zh-tw/errors/session.ts index e1a9592d54d..abde5db9226 100644 --- a/packages/phrases/src/locales/zh-tw/errors/session.ts +++ b/packages/phrases/src/locales/zh-tw/errors/session.ts @@ -8,6 +8,9 @@ const session = { connector_session_not_found: '無法找到連接器登錄信息,請嘗試重新登錄。', verification_session_not_found: '驗證失敗,請重新驗證。', verification_expired: '當前頁面已超時。為確保你的帳號安全,請重新驗證。', + /** UNTRANSLATED */ + verification_blocked_too_many_attempts: + 'Too many attempts in a short time. Please try again {{relativeTime}}.', unauthorized: '請先登錄', unsupported_prompt_name: '不支援的 prompt name', forgot_password_not_enabled: '忘記密碼功能沒有開啟。', diff --git a/packages/schemas/src/types/sentinel.ts b/packages/schemas/src/types/sentinel.ts index 49d453e04c7..0b1722da774 100644 --- a/packages/schemas/src/types/sentinel.ts +++ b/packages/schemas/src/types/sentinel.ts @@ -31,5 +31,5 @@ export abstract class Sentinel { * @returns A Promise that resolves to the sentinel decision. * @see {@link SentinelDecision} */ - abstract reportActivity(activity: ActivityReport): Promise; + abstract reportActivity(activity: ActivityReport): Promise>; }