0
Fork 0
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:
Gao Sun 2023-09-25 16:20:17 +08:00 committed by GitHub
parent b8e592d669
commit 827123faa0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 385 additions and 84 deletions

View file

@ -0,0 +1,5 @@
---
"@logto/core": patch
---
block an identifier from verification for 10 minutes after 5 failed attempts within 1 hour

View file

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

View file

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

View file

@ -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.

View file

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

View file

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

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

View file

@ -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]>(

View 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;

View file

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

View file

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

View file

@ -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'],

View file

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

View file

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

View file

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

View file

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

View file

@ -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.',

View file

@ -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.',

View file

@ -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.',

View file

@ -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:

View file

@ -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.',

View file

@ -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: 'パスワードを忘れた場合の対処が有効になっていません。',

View file

@ -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: '비밀번호 찾기가 활성화되어있지 않아요.',

View file

@ -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.',

View file

@ -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.',

View file

@ -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.',

View file

@ -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: 'Забыли пароль не включен.',

View file

@ -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.',

View file

@ -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: '忘记密码功能没有开启。',

View file

@ -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: '忘記密碼功能沒有開啟。',

View file

@ -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: '忘記密碼功能沒有開啟。',

View file

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