mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
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
This commit is contained in:
parent
b8e592d669
commit
827123faa0
32 changed files with 385 additions and 84 deletions
5
.changeset/proud-meals-bathe.md
Normal file
5
.changeset/proud-meals-bathe.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@logto/core": patch
|
||||
---
|
||||
|
||||
block an identifier from verification for 10 minutes after 5 failed attempts within 1 hour
|
|
@ -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;
|
||||
|
|
|
@ -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<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;
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
7
packages/core/src/test-utils/sentinel.ts
Normal file
7
packages/core/src/test-utils/sentinel.ts
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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<string, string>(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<Type extends 'queries' | 'libraries', Key extends keyof this[Type]>(
|
||||
|
|
7
packages/core/src/utils/i18n.ts
Normal file
7
packages/core/src/utils/i18n.ts
Normal file
|
@ -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;
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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,11 +137,33 @@ 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<string | [password: string, errorMessage: string | RegExp]>
|
||||
) {
|
||||
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
|
||||
*
|
||||
* In the following example, the first password is expected to be rejected with the error message
|
||||
|
@ -146,22 +176,18 @@ export default class ExpectExperience extends ExpectPage {
|
|||
* );
|
||||
* ```
|
||||
*/
|
||||
async toFillPasswords(
|
||||
async toFillPasswordsToInputs(
|
||||
{ inputNames, shouldNavigate = true }: { inputNames: string[]; shouldNavigate?: boolean },
|
||||
...passwords: Array<string | [password: string, errorMessage: string | RegExp]>
|
||||
) {
|
||||
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);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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<HTMLInputElement>) => {
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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: 'パスワードを忘れた場合の対処が有効になっていません。',
|
||||
|
|
|
@ -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: '비밀번호 찾기가 활성화되어있지 않아요.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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: 'Забыли пароль не включен.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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: '忘记密码功能没有开启。',
|
||||
|
|
|
@ -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: '忘記密碼功能沒有開啟。',
|
||||
|
|
|
@ -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: '忘記密碼功能沒有開啟。',
|
||||
|
|
|
@ -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<SentinelDecisionTuple>;
|
||||
abstract reportActivity(activity: ActivityReport): Promise<Readonly<SentinelDecisionTuple>>;
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue