From b8a7b900e12858c2ee05a54dd04cab70680b6a3b Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sun, 3 Sep 2023 02:11:22 +0800 Subject: [PATCH] feat(core): guard password by policy --- packages/app-insights/package.json | 2 +- packages/cli/package.json | 2 +- packages/connectors/templates/package.json | 2 +- packages/console/package.json | 2 +- packages/core/package.json | 2 +- .../core/src/__mocks__/sign-in-experience.ts | 1 + .../src/queries/sign-in-experience.test.ts | 3 +- .../core/src/routes/interaction/index.test.ts | 5 + packages/core/src/routes/interaction/index.ts | 15 ++ .../middleware/koa-interaction-sie.ts | 17 +- .../interaction/utils/validate-password.ts | 22 ++ .../mandatory-user-profile-validation.test.ts | 9 +- packages/core/src/utils/zod.ts | 9 + packages/integration-tests/package.json | 2 +- packages/phrases-ui/package.json | 3 +- packages/phrases/package.json | 2 +- .../phrases/src/locales/de/errors/password.ts | 1 + .../phrases/src/locales/en/errors/password.ts | 1 + .../phrases/src/locales/es/errors/password.ts | 1 + .../phrases/src/locales/fr/errors/password.ts | 1 + .../phrases/src/locales/it/errors/password.ts | 1 + .../phrases/src/locales/ja/errors/password.ts | 1 + .../phrases/src/locales/ko/errors/password.ts | 1 + .../src/locales/pl-pl/errors/password.ts | 1 + .../src/locales/pt-br/errors/password.ts | 1 + .../src/locales/pt-pt/errors/password.ts | 1 + .../phrases/src/locales/ru/errors/password.ts | 1 + .../src/locales/tr-tr/errors/password.ts | 1 + .../src/locales/zh-cn/errors/password.ts | 1 + .../src/locales/zh-hk/errors/password.ts | 1 + .../src/locales/zh-tw/errors/password.ts | 1 + .../next-1693554904-add-possword-policy.ts | 21 ++ packages/schemas/package.json | 2 +- .../schemas/src/foundations/jsonb-types.ts | 7 +- .../schemas/src/seeds/sign-in-experience.ts | 1 + .../schemas/tables/sign_in_experiences.sql | 1 + packages/shared/package.json | 2 +- packages/toolkit/connector-kit/package.json | 2 +- packages/toolkit/core-kit/package.json | 2 +- .../core-kit/src/password-policy.test.ts | 82 ++++--- .../toolkit/core-kit/src/password-policy.ts | 183 +++++++++++---- packages/ui/package.json | 2 +- packages/ui/src/__mocks__/logto.tsx | 2 + pnpm-lock.yaml | 213 +++++++++--------- 44 files changed, 429 insertions(+), 204 deletions(-) create mode 100644 packages/core/src/routes/interaction/utils/validate-password.ts create mode 100644 packages/schemas/alterations/next-1693554904-add-possword-policy.ts diff --git a/packages/app-insights/package.json b/packages/app-insights/package.json index ffae34314..88006e08c 100644 --- a/packages/app-insights/package.json +++ b/packages/app-insights/package.json @@ -60,7 +60,7 @@ "@microsoft/applicationinsights-clickanalytics-js": "^3.0.2", "@microsoft/applicationinsights-react-js": "^17.0.0", "@microsoft/applicationinsights-web": "^3.0.2", - "@silverhand/essentials": "^2.5.0", + "@silverhand/essentials": "^2.8.4", "applicationinsights": "^2.7.0" }, "peerDependencies": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 106befb8e..43ff42179 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -50,7 +50,7 @@ "@logto/phrases-ui": "workspace:^1.2.0", "@logto/schemas": "workspace:1.8.0", "@logto/shared": "workspace:^2.0.0", - "@silverhand/essentials": "^2.5.0", + "@silverhand/essentials": "^2.8.4", "chalk": "^5.0.0", "decamelize": "^6.0.0", "dotenv": "^16.0.0", diff --git a/packages/connectors/templates/package.json b/packages/connectors/templates/package.json index c55b13c8e..1ba263b58 100644 --- a/packages/connectors/templates/package.json +++ b/packages/connectors/templates/package.json @@ -23,7 +23,7 @@ "prepublishOnly": "pnpm build" }, "dependencies": { - "@silverhand/essentials": "^2.5.0", + "@silverhand/essentials": "^2.8.4", "got": "^13.0.0", "snakecase-keys": "^5.4.4", "zod": "^3.20.2" diff --git a/packages/console/package.json b/packages/console/package.json index 92540fcac..d9a20851c 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -44,7 +44,7 @@ "@parcel/transformer-svg-react": "2.9.3", "@silverhand/eslint-config": "4.0.1", "@silverhand/eslint-config-react": "4.0.1", - "@silverhand/essentials": "^2.5.0", + "@silverhand/essentials": "^2.8.4", "@silverhand/ts-config": "4.0.0", "@silverhand/ts-config-react": "4.0.0", "@swc/core": "^1.3.52", diff --git a/packages/core/package.json b/packages/core/package.json index 843fde3a1..20069c9e8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -41,7 +41,7 @@ "@logto/schemas": "workspace:^1.8.0", "@logto/shared": "workspace:^2.0.0", "@logto/ui": "workspace:*", - "@silverhand/essentials": "^2.5.0", + "@silverhand/essentials": "^2.8.4", "@withtyped/client": "^0.7.22", "chalk": "^5.0.0", "clean-deep": "^3.4.0", diff --git a/packages/core/src/__mocks__/sign-in-experience.ts b/packages/core/src/__mocks__/sign-in-experience.ts index a4417fc38..5ca4476a2 100644 --- a/packages/core/src/__mocks__/sign-in-experience.ts +++ b/packages/core/src/__mocks__/sign-in-experience.ts @@ -91,4 +91,5 @@ export const mockSignInExperience: SignInExperience = { signInMode: SignInMode.SignInAndRegister, customCss: null, customContent: {}, + passwordPolicy: {}, }; diff --git a/packages/core/src/queries/sign-in-experience.test.ts b/packages/core/src/queries/sign-in-experience.test.ts index c2df3e3e5..c0502812e 100644 --- a/packages/core/src/queries/sign-in-experience.test.ts +++ b/packages/core/src/queries/sign-in-experience.test.ts @@ -32,12 +32,13 @@ describe('sign-in-experience query', () => { signUp: JSON.stringify(mockSignInExperience.signUp), socialSignInConnectorTargets: JSON.stringify(mockSignInExperience.socialSignInConnectorTargets), customContent: JSON.stringify(mockSignInExperience.customContent), + passwordPolicy: JSON.stringify(mockSignInExperience.passwordPolicy), }; it('findDefaultSignInExperience', async () => { /* eslint-disable sql/no-unsafe-query */ const expectSql = ` - select "tenant_id", "id", "color", "branding", "language_info", "terms_of_use_url", "privacy_policy_url", "sign_in", "sign_up", "social_sign_in_connector_targets", "sign_in_mode", "custom_css", "custom_content" + select "tenant_id", "id", "color", "branding", "language_info", "terms_of_use_url", "privacy_policy_url", "sign_in", "sign_up", "social_sign_in_connector_targets", "sign_in_mode", "custom_css", "custom_content", "password_policy" from "sign_in_experiences" where "id"=$1 `; diff --git a/packages/core/src/routes/interaction/index.test.ts b/packages/core/src/routes/interaction/index.test.ts index 0f7481cbc..b96158bbd 100644 --- a/packages/core/src/routes/interaction/index.test.ts +++ b/packages/core/src/routes/interaction/index.test.ts @@ -76,6 +76,10 @@ const { sendVerificationCodeToIdentifier } = await mockEsmWithActual( }) ); +const { validatePassword } = await mockEsmWithActual('./utils/validate-password.js', () => ({ + validatePassword: jest.fn(), +})); + const { createLog, prependAllLogEntries } = createMockLogContext(); await mockEsmWithActual( @@ -151,6 +155,7 @@ describe('interaction routes', () => { expect(verifyIdentifierSettings).toBeCalled(); expect(verifyProfileSettings).toBeCalled(); expect(verifyIdentifierPayload).toBeCalled(); + expect(validatePassword).toBeCalled(); expect(storeInteractionResult).toBeCalled(); expect(response.status).toEqual(204); }); diff --git a/packages/core/src/routes/interaction/index.ts b/packages/core/src/routes/interaction/index.ts index ab35a3160..e107ef02f 100644 --- a/packages/core/src/routes/interaction/index.ts +++ b/packages/core/src/routes/interaction/index.ts @@ -37,6 +37,7 @@ import { verifyProfileSettings, } from './utils/sign-in-experience-validation.js'; import { createSocialAuthorizationUrl } from './utils/social-verification.js'; +import { validatePassword } from './utils/validate-password.js'; import { sendVerificationCodeToIdentifier } from './utils/verification-code-validation.js'; import { verifyIdentifierPayload, @@ -71,6 +72,10 @@ export default function interactionRoutes( status: [204, 400, 401, 403, 422], }), koaInteractionSie(queries), + async ({ guard: { body }, passwordPolicyChecker }, next) => { + await validatePassword(body.profile?.password, passwordPolicyChecker); + return next(); + }, async (ctx, next) => { const { event, identifier, profile } = ctx.guard.body; const { signInExperience, createLog } = ctx; @@ -205,6 +210,10 @@ export default function interactionRoutes( status: [204, 400, 404], }), koaInteractionSie(queries), + async ({ guard: { body }, passwordPolicyChecker }, next) => { + await validatePassword(body.password, passwordPolicyChecker); + return next(); + }, async (ctx, next) => { const profilePayload = ctx.guard.body; const { signInExperience, interactionDetails, createLog } = ctx; @@ -243,6 +252,10 @@ export default function interactionRoutes( status: [204, 400, 404], }), koaInteractionSie(queries), + async ({ guard: { body }, passwordPolicyChecker }, next) => { + await validatePassword(body.password, passwordPolicyChecker); + return next(); + }, async (ctx, next) => { const profilePayload = ctx.guard.body; const { signInExperience, interactionDetails, createLog } = ctx; @@ -373,6 +386,8 @@ export default function interactionRoutes( // Check interaction exists const { event } = getInteractionStorage(interactionDetails.result); + // This file needs refactor + // eslint-disable-next-line max-lines await sendVerificationCodeToIdentifier( { event, ...guard.body }, interactionDetails.jti, diff --git a/packages/core/src/routes/interaction/middleware/koa-interaction-sie.ts b/packages/core/src/routes/interaction/middleware/koa-interaction-sie.ts index 8754a48ec..2834b745c 100644 --- a/packages/core/src/routes/interaction/middleware/koa-interaction-sie.ts +++ b/packages/core/src/routes/interaction/middleware/koa-interaction-sie.ts @@ -1,12 +1,23 @@ +import crypto from 'node:crypto'; + +import { PasswordPolicyChecker } from '@logto/core-kit'; import type { SignInExperience } from '@logto/schemas'; import type { MiddlewareType } from 'koa'; import { type IRouterParamContext } from 'koa-router'; import type Queries from '#src/tenants/Queries.js'; +/** + * Extend the context with the default sign-in experience and the corresponding + * password policy checker. + */ export type WithInteractionSieContext = - ContextT & { signInExperience: SignInExperience }; + ContextT & { signInExperience: SignInExperience; passwordPolicyChecker: PasswordPolicyChecker }; +/** + * Create a middleware that injects the default sign-in experience and the + * corresponding password policy checker into the context. + */ export default function koaInteractionSie({ signInExperiences: { findDefaultSignInExperience }, }: Queries): MiddlewareType, ResponseT> { @@ -14,6 +25,10 @@ export default function koaInteractionSie, + checker: PasswordPolicyChecker +) => { + if (password === undefined) { + return; + } + + const issues = await checker.check(password, {}); + if (issues.length > 0) { + throw new RequestError('password.password_rejected', issues); + } +}; diff --git a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts index 13797b9ea..eb02bb44d 100644 --- a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts +++ b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts @@ -1,3 +1,6 @@ +import crypto from 'node:crypto'; + +import { PasswordPolicyChecker } from '@logto/core-kit'; import { InteractionEvent, MissingProfile, SignInIdentifier } from '@logto/schemas'; import { createMockUtils, pickDefault } from '@logto/shared/esm'; import type Provider from 'oidc-provider'; @@ -32,6 +35,10 @@ describe('validateMandatoryUserProfile', () => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions interactionDetails: {} as Awaited>, signInExperience: mockSignInExperience, + passwordPolicyChecker: new PasswordPolicyChecker( + mockSignInExperience.passwordPolicy, + crypto.subtle + ), }; const interaction: IdentifierVerifiedInteractionResult = { @@ -274,7 +281,7 @@ describe('validateMandatoryUserProfile', () => { }); }); - describe('email or Phone required', () => { + describe('email or phone required', () => { const ctx = { ...baseCtx, signInExperience: { diff --git a/packages/core/src/utils/zod.ts b/packages/core/src/utils/zod.ts index 8abae7aa5..c32f0fb46 100644 --- a/packages/core/src/utils/zod.ts +++ b/packages/core/src/utils/zod.ts @@ -20,6 +20,7 @@ import { ZodString, ZodUnion, ZodUnknown, + ZodDefault, } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; @@ -266,5 +267,13 @@ export const zodTypeToSwagger = ( } } + if (config instanceof ZodDefault) { + return { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + default: config._def.defaultValue(), + ...zodTypeToSwagger(config._def.innerType), + }; + } + throw new RequestError('swagger.invalid_zod_type', config); }; diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index 063ee31ad..abc008a69 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -28,7 +28,7 @@ "@logto/schemas": "workspace:^1.6.0", "@logto/shared": "workspace:^2.0.0", "@silverhand/eslint-config": "4.0.1", - "@silverhand/essentials": "^2.5.0", + "@silverhand/essentials": "^2.8.4", "@silverhand/ts-config": "4.0.0", "@types/expect-puppeteer": "^5.0.3", "@types/jest": "^29.4.0", diff --git a/packages/phrases-ui/package.json b/packages/phrases-ui/package.json index da9792993..c49f095da 100644 --- a/packages/phrases-ui/package.json +++ b/packages/phrases-ui/package.json @@ -33,8 +33,9 @@ "url": "https://github.com/logto-io/logto/issues" }, "dependencies": { + "@logto/core-kit": "workspace:^2.0.1", "@logto/language-kit": "workspace:^1.0.0", - "@silverhand/essentials": "^2.5.0" + "@silverhand/essentials": "^2.8.4" }, "peerDependencies": { "zod": "^3.20.2" diff --git a/packages/phrases/package.json b/packages/phrases/package.json index 3fab6d205..60593cfbd 100644 --- a/packages/phrases/package.json +++ b/packages/phrases/package.json @@ -34,7 +34,7 @@ }, "dependencies": { "@logto/language-kit": "workspace:^1.0.0", - "@silverhand/essentials": "^2.5.0" + "@silverhand/essentials": "^2.8.4" }, "peerDependencies": { "zod": "^3.20.2" diff --git a/packages/phrases/src/locales/de/errors/password.ts b/packages/phrases/src/locales/de/errors/password.ts index af79f8c82..2c8e648b3 100644 --- a/packages/phrases/src/locales/de/errors/password.ts +++ b/packages/phrases/src/locales/de/errors/password.ts @@ -1,6 +1,7 @@ const password = { unsupported_encryption_method: 'Die Verschlüsselungsmethode {{name}} wird nicht unterstützt.', pepper_not_found: 'Password pepper not found. Please check your core envs.', + password_rejected: 'Password rejected. Please check if your password meets the requirements.', // UNTRANSLATED }; export default Object.freeze(password); diff --git a/packages/phrases/src/locales/en/errors/password.ts b/packages/phrases/src/locales/en/errors/password.ts index 289459d64..ed59b00a5 100644 --- a/packages/phrases/src/locales/en/errors/password.ts +++ b/packages/phrases/src/locales/en/errors/password.ts @@ -1,6 +1,7 @@ const password = { unsupported_encryption_method: 'The encryption method {{name}} is not supported.', pepper_not_found: 'Password pepper not found. Please check your core envs.', + password_rejected: 'Password rejected. Please check if your password meets the requirements.', }; export default Object.freeze(password); diff --git a/packages/phrases/src/locales/es/errors/password.ts b/packages/phrases/src/locales/es/errors/password.ts index b9db332ee..7183cf9f5 100644 --- a/packages/phrases/src/locales/es/errors/password.ts +++ b/packages/phrases/src/locales/es/errors/password.ts @@ -1,6 +1,7 @@ const password = { unsupported_encryption_method: 'El método de encriptación {{name}} no es compatible.', pepper_not_found: 'No se encontró el password pepper. Por favor revisa tus variables de entorno.', + password_rejected: 'Password rejected. Please check if your password meets the requirements.', // UNTRANSLATED }; export default Object.freeze(password); diff --git a/packages/phrases/src/locales/fr/errors/password.ts b/packages/phrases/src/locales/fr/errors/password.ts index d577c9db0..615b542ea 100644 --- a/packages/phrases/src/locales/fr/errors/password.ts +++ b/packages/phrases/src/locales/fr/errors/password.ts @@ -2,6 +2,7 @@ const password = { unsupported_encryption_method: "La méthode de cryptage {{name}} n'est pas prise en charge.", pepper_not_found: 'Mot de passe pepper non trouvé. Veuillez vérifier votre environnement de base.', + password_rejected: 'Password rejected. Please check if your password meets the requirements.', // UNTRANSLATED }; export default Object.freeze(password); diff --git a/packages/phrases/src/locales/it/errors/password.ts b/packages/phrases/src/locales/it/errors/password.ts index 151746c30..cfd95f2fa 100644 --- a/packages/phrases/src/locales/it/errors/password.ts +++ b/packages/phrases/src/locales/it/errors/password.ts @@ -1,6 +1,7 @@ const password = { unsupported_encryption_method: 'Il metodo di crittografia {{name}} non è supportato.', pepper_not_found: 'Pepper password non trovato. Per favore controlla le tue env di core.', + password_rejected: 'Password rejected. Please check if your password meets the requirements.', // UNTRANSLATED }; export default Object.freeze(password); diff --git a/packages/phrases/src/locales/ja/errors/password.ts b/packages/phrases/src/locales/ja/errors/password.ts index 2b4510113..44682f400 100644 --- a/packages/phrases/src/locales/ja/errors/password.ts +++ b/packages/phrases/src/locales/ja/errors/password.ts @@ -1,6 +1,7 @@ const password = { unsupported_encryption_method: '暗号化方式 {{name}} はサポートされていません。', pepper_not_found: 'パスワードペッパーが見つかりません。コアの環境を確認してください。', + password_rejected: 'Password rejected. Please check if your password meets the requirements.', // UNTRANSLATED }; export default Object.freeze(password); diff --git a/packages/phrases/src/locales/ko/errors/password.ts b/packages/phrases/src/locales/ko/errors/password.ts index c34a350c3..1eaea45d9 100644 --- a/packages/phrases/src/locales/ko/errors/password.ts +++ b/packages/phrases/src/locales/ko/errors/password.ts @@ -1,6 +1,7 @@ const password = { unsupported_encryption_method: '{{name}} 암호화 방법을 지원하지 않아요.', pepper_not_found: '비밀번호 Pepper를 찾을 수 없어요. Core 환경설정을 확인해 주세요.', + password_rejected: 'Password rejected. Please check if your password meets the requirements.', // UNTRANSLATED }; export default Object.freeze(password); diff --git a/packages/phrases/src/locales/pl-pl/errors/password.ts b/packages/phrases/src/locales/pl-pl/errors/password.ts index d5000267a..2cd5ca9a5 100644 --- a/packages/phrases/src/locales/pl-pl/errors/password.ts +++ b/packages/phrases/src/locales/pl-pl/errors/password.ts @@ -1,6 +1,7 @@ const password = { unsupported_encryption_method: 'Metoda szyfrowania {{name}} nie jest obsługiwana.', pepper_not_found: 'Nie znaleziono wartości pepper dla hasła. Sprawdź swoje zmienne środowiskowe.', + password_rejected: 'Password rejected. Please check if your password meets the requirements.', // UNTRANSLATED }; export default Object.freeze(password); diff --git a/packages/phrases/src/locales/pt-br/errors/password.ts b/packages/phrases/src/locales/pt-br/errors/password.ts index 724a326da..c3cc24193 100644 --- a/packages/phrases/src/locales/pt-br/errors/password.ts +++ b/packages/phrases/src/locales/pt-br/errors/password.ts @@ -1,6 +1,7 @@ const password = { unsupported_encryption_method: 'O método de criptografia {{name}} não é suportado.', pepper_not_found: 'Password pepper não encontrada. Por favor, verifique seus envs principais.', + password_rejected: 'Password rejected. Please check if your password meets the requirements.', // UNTRANSLATED }; export default Object.freeze(password); diff --git a/packages/phrases/src/locales/pt-pt/errors/password.ts b/packages/phrases/src/locales/pt-pt/errors/password.ts index 869e69424..9b9bd721f 100644 --- a/packages/phrases/src/locales/pt-pt/errors/password.ts +++ b/packages/phrases/src/locales/pt-pt/errors/password.ts @@ -1,6 +1,7 @@ const password = { unsupported_encryption_method: 'O método de enncriptação {{name}} não é suportado.', pepper_not_found: 'pepper da Password não encontrada. Por favor, verifique os envs.', + password_rejected: 'Password rejected. Please check if your password meets the requirements.', // UNTRANSLATED }; export default Object.freeze(password); diff --git a/packages/phrases/src/locales/ru/errors/password.ts b/packages/phrases/src/locales/ru/errors/password.ts index fe986d1e4..820819b1a 100644 --- a/packages/phrases/src/locales/ru/errors/password.ts +++ b/packages/phrases/src/locales/ru/errors/password.ts @@ -1,6 +1,7 @@ const password = { unsupported_encryption_method: 'Метод шифрования {{name}} не поддерживается.', pepper_not_found: 'Не найден пепер пароля. Пожалуйста, проверьте ваши основные envs.', + password_rejected: 'Password rejected. Please check if your password meets the requirements.', // UNTRANSLATED }; export default Object.freeze(password); diff --git a/packages/phrases/src/locales/tr-tr/errors/password.ts b/packages/phrases/src/locales/tr-tr/errors/password.ts index 075a4015a..818f805ca 100644 --- a/packages/phrases/src/locales/tr-tr/errors/password.ts +++ b/packages/phrases/src/locales/tr-tr/errors/password.ts @@ -1,6 +1,7 @@ const password = { unsupported_encryption_method: '{{name}} şifreleme metodu desteklenmiyor.', pepper_not_found: 'Şifre pepperı bulunamadı. Lütfen core envs.i kontrol edin.', + password_rejected: 'Password rejected. Please check if your password meets the requirements.', // UNTRANSLATED }; export default Object.freeze(password); diff --git a/packages/phrases/src/locales/zh-cn/errors/password.ts b/packages/phrases/src/locales/zh-cn/errors/password.ts index 343c299f1..04477a977 100644 --- a/packages/phrases/src/locales/zh-cn/errors/password.ts +++ b/packages/phrases/src/locales/zh-cn/errors/password.ts @@ -1,6 +1,7 @@ const password = { unsupported_encryption_method: '不支持的加密方法 {{name}}', pepper_not_found: '密码 pepper 未找到。请检查 core 的环境变量。', + password_rejected: 'Password rejected. Please check if your password meets the requirements.', // UNTRANSLATED }; export default Object.freeze(password); diff --git a/packages/phrases/src/locales/zh-hk/errors/password.ts b/packages/phrases/src/locales/zh-hk/errors/password.ts index 964a8dadc..3befc1bcd 100644 --- a/packages/phrases/src/locales/zh-hk/errors/password.ts +++ b/packages/phrases/src/locales/zh-hk/errors/password.ts @@ -1,6 +1,7 @@ const password = { unsupported_encryption_method: '不支持的加密方法 {{name}}', pepper_not_found: '密碼 pepper 未找到。請檢查 core 的環境變量。', + password_rejected: 'Password rejected. Please check if your password meets the requirements.', // UNTRANSLATED }; export default Object.freeze(password); diff --git a/packages/phrases/src/locales/zh-tw/errors/password.ts b/packages/phrases/src/locales/zh-tw/errors/password.ts index 58ab46454..b89cb4e14 100644 --- a/packages/phrases/src/locales/zh-tw/errors/password.ts +++ b/packages/phrases/src/locales/zh-tw/errors/password.ts @@ -1,6 +1,7 @@ const password = { unsupported_encryption_method: '不支持的加密方法 {{name}}', pepper_not_found: '密碼 pepper 未找到。請檢查 core 的環境變數。', + password_rejected: 'Password rejected. Please check if your password meets the requirements.', // UNTRANSLATED }; export default Object.freeze(password); diff --git a/packages/schemas/alterations/next-1693554904-add-possword-policy.ts b/packages/schemas/alterations/next-1693554904-add-possword-policy.ts new file mode 100644 index 000000000..f2a7a10d3 --- /dev/null +++ b/packages/schemas/alterations/next-1693554904-add-possword-policy.ts @@ -0,0 +1,21 @@ +import { sql } from 'slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +/** The alteration script for adding `password_policy` column to the sign-in experience table. */ +const alteration: AlterationScript = { + up: async (pool) => { + await pool.query(sql` + alter table sign_in_experiences + add column password_policy jsonb not null default '{}'; + `); + }, + down: async (pool) => { + await pool.query(sql` + alter table sign_in_experiences + drop column password_policy; + `); + }, +}; + +export default alteration; diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 429f3b041..47cc25a50 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -41,7 +41,7 @@ }, "devDependencies": { "@silverhand/eslint-config": "4.0.1", - "@silverhand/essentials": "^2.5.0", + "@silverhand/essentials": "^2.8.4", "@silverhand/ts-config": "4.0.0", "@types/inquirer": "^9.0.0", "@types/jest": "^29.4.0", diff --git a/packages/schemas/src/foundations/jsonb-types.ts b/packages/schemas/src/foundations/jsonb-types.ts index 5ac5fa73d..976d3b34c 100644 --- a/packages/schemas/src/foundations/jsonb-types.ts +++ b/packages/schemas/src/foundations/jsonb-types.ts @@ -1,5 +1,6 @@ -import { hexColorRegEx } from '@logto/core-kit'; +import { type PasswordPolicy, hexColorRegEx, passwordPolicyGuard } from '@logto/core-kit'; import { languageTagGuard } from '@logto/language-kit'; +import { type DeepPartial } from '@silverhand/essentials'; import type { Json } from '@withtyped/server'; import { z } from 'zod'; @@ -204,6 +205,10 @@ export const logContextPayloadGuard = z }) .catchall(z.unknown()); +export type PartialPasswordPolicy = DeepPartial; + +export const partialPasswordPolicyGuard = passwordPolicyGuard.deepPartial(); + /** * The basic log context type. It's more about a type hint instead of forcing the log shape. * diff --git a/packages/schemas/src/seeds/sign-in-experience.ts b/packages/schemas/src/seeds/sign-in-experience.ts index cdf2b2738..b6c2ae92c 100644 --- a/packages/schemas/src/seeds/sign-in-experience.ts +++ b/packages/schemas/src/seeds/sign-in-experience.ts @@ -49,6 +49,7 @@ export const createDefaultSignInExperience = ( signInMode: SignInMode.SignInAndRegister, customCss: null, customContent: {}, + passwordPolicy: {}, }); /** @deprecated Use `createDefaultSignInExperience()` instead. */ diff --git a/packages/schemas/tables/sign_in_experiences.sql b/packages/schemas/tables/sign_in_experiences.sql index af81c5fb2..200453282 100644 --- a/packages/schemas/tables/sign_in_experiences.sql +++ b/packages/schemas/tables/sign_in_experiences.sql @@ -15,5 +15,6 @@ create table sign_in_experiences ( sign_in_mode sign_in_mode not null default 'SignInAndRegister', custom_css text, custom_content jsonb /* @use CustomContent */ not null default '{}'::jsonb, + password_policy jsonb /* @use PartialPasswordPolicy */ not null default '{}'::jsonb, primary key (tenant_id, id) ); diff --git a/packages/shared/package.json b/packages/shared/package.json index 4fc306040..5897c5498 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -60,7 +60,7 @@ }, "prettier": "@silverhand/eslint-config/.prettierrc", "dependencies": { - "@silverhand/essentials": "^2.5.0", + "@silverhand/essentials": "^2.8.4", "chalk": "^5.0.0", "find-up": "^6.3.0", "nanoid": "^4.0.0", diff --git a/packages/toolkit/connector-kit/package.json b/packages/toolkit/connector-kit/package.json index 939605fa0..a73384842 100644 --- a/packages/toolkit/connector-kit/package.json +++ b/packages/toolkit/connector-kit/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@logto/language-kit": "workspace:^1.0.0", - "@silverhand/essentials": "^2.5.0", + "@silverhand/essentials": "^2.8.4", "@withtyped/client": "^0.7.22" }, "optionalDependencies": { diff --git a/packages/toolkit/core-kit/package.json b/packages/toolkit/core-kit/package.json index 5c0f31acb..629c6778f 100644 --- a/packages/toolkit/core-kit/package.json +++ b/packages/toolkit/core-kit/package.json @@ -53,7 +53,7 @@ "devDependencies": { "@jest/types": "^29.0.3", "@silverhand/eslint-config": "4.0.1", - "@silverhand/essentials": "^2.5.0", + "@silverhand/essentials": "^2.8.4", "@silverhand/ts-config": "4.0.0", "@silverhand/ts-config-react": "4.0.0", "@types/color": "^3.0.3", diff --git a/packages/toolkit/core-kit/src/password-policy.test.ts b/packages/toolkit/core-kit/src/password-policy.test.ts index 4dc85e1f0..041062266 100644 --- a/packages/toolkit/core-kit/src/password-policy.test.ts +++ b/packages/toolkit/core-kit/src/password-policy.test.ts @@ -25,7 +25,7 @@ describe('PasswordPolicyChecker', () => { it('should reject malformed policy', () => { expect(() => { // @ts-expect-error - return new PasswordPolicyChecker({ length: { min: 1, max: 2 } }); + return new PasswordPolicyChecker({ length: { min: 1, max: '2' } }); }).toThrowError(ZodError); }); }); @@ -39,35 +39,37 @@ describe('PasswordPolicyChecker -> check()', () => { rejects: { pwned: true, repetitionAndSequence: true, - words: [{ type: 'custom', value: 'test' }], + personalInfo: true, + words: ['test', 'aaaa'], }, }); it('should accept valid password', async () => { - expect(await checker.check('aL1!aL1!')).toEqual([]); + expect(await checker.check('aL1!aL1!', {})).toEqual([]); }); it('should reject with all failed checks', async () => { - expect(await checker.check('aaa')).toEqual([ - { code: 'password_rejected.too_short' }, + expect(await checker.check('aaa', {})).toEqual([ + { code: 'password_rejected.too_short', interpolation: { min: 7 } }, { code: 'password_rejected.character_types', interpolation: { min: 2 } }, { code: 'password_rejected.repetition' }, ]); - expect(await checker.check('123456')).toEqual([ - { code: 'password_rejected.too_short' }, + expect(await checker.check('123456', { phoneNumber: '12345' })).toEqual([ + { code: 'password_rejected.too_short', interpolation: { min: 7 } }, { code: 'password_rejected.character_types', interpolation: { min: 2 } }, - { code: 'password_rejected.pwned' }, { code: 'password_rejected.sequence' }, + { code: 'password_rejected.pwned' }, + { code: 'password_rejected.personal_info' }, ]); - expect(await checker.check('aaaaaatest😀')).toEqual([ - { code: 'password_rejected.too_long' }, + expect(await checker.check('aaaaaatest😀', {})).toEqual([ + { code: 'password_rejected.too_long', interpolation: { max: 8 } }, { code: 'password_rejected.unsupported_characters' }, { code: 'password_rejected.repetition' }, { code: 'password_rejected.restricted_words', - interpolation: { type: 'custom', value: 'test' }, + interpolation: { words: 'test\naaaa', count: 2 }, }, ]); }); @@ -77,12 +79,12 @@ describe('PasswordPolicyChecker -> checkCharTypes()', () => { const checker1 = new PasswordPolicyChecker({ length: { min: 1, max: 256 }, characterTypes: { min: 2 }, - rejects: { pwned: false, repetitionAndSequence: false, words: [] }, + rejects: { pwned: false, repetitionAndSequence: false, personalInfo: false, words: [] }, }); const checker2 = new PasswordPolicyChecker({ length: { min: 1, max: 256 }, characterTypes: { min: 4 }, - rejects: { pwned: false, repetitionAndSequence: false, words: [] }, + rejects: { pwned: false, repetitionAndSequence: false, personalInfo: false, words: [] }, }); it('should reject unsupported characters', () => { @@ -106,7 +108,7 @@ describe('PasswordPolicyChecker -> hasBeenPwned()', () => { const checker = new PasswordPolicyChecker({ length: { min: 1, max: 256 }, characterTypes: { min: 2 }, - rejects: { pwned: true, repetitionAndSequence: false, words: [] }, + rejects: { pwned: true, repetitionAndSequence: false, personalInfo: false, words: [] }, }); mockPwnResponse(); @@ -139,6 +141,36 @@ describe('PasswordPolicyChecker -> hasRepetition()', () => { }); }); +describe('PasswordPolicyChecker -> hasPersonalInfo()', () => { + const checker = new PasswordPolicyChecker({ + rejects: { pwned: false, repetitionAndSequence: false, personalInfo: true, words: [] }, + }); + + it('should reject password with name', () => { + expect(checker.hasPersonalInfo('test', { name: 'test' })).toBe(true); + expect(checker.hasPersonalInfo('test', { name: 'test2' })).toBe(false); + expect(checker.hasPersonalInfo('FOO', { name: 'Foo bar' })).toBe(true); + expect(checker.hasPersonalInfo('Foo', { name: 'bar fOo' })).toBe(true); + }); + + it('should reject password with username', () => { + expect(checker.hasPersonalInfo('123.456!test', { username: 'teST' })).toBe(true); + expect(checker.hasPersonalInfo('test', { username: 'test2' })).toBe(false); + }); + + it('should reject password with email', () => { + expect(checker.hasPersonalInfo('teST', { email: 'test@foo.com' })).toBe(true); + expect(checker.hasPersonalInfo('TEST', { email: 'test1@foo.com' })).toBe(false); + expect(checker.hasPersonalInfo('FOO', { email: 'test@foo.com' })).toBe(false); + expect(checker.hasPersonalInfo('Foo', { email: 'fOO@foo.com' })).toBe(true); + }); + + it('should reject password with phone number', () => { + expect(checker.hasPersonalInfo('teST1234567890.', { phoneNumber: '123456789' })).toBe(true); + expect(checker.hasPersonalInfo('TEST12345678', { phoneNumber: '123456789' })).toBe(false); + }); +}); + describe('PasswordPolicyChecker -> hasWords()', () => { const checker = new PasswordPolicyChecker({ length: { min: 1, max: 256 }, @@ -146,25 +178,15 @@ describe('PasswordPolicyChecker -> hasWords()', () => { rejects: { pwned: false, repetitionAndSequence: false, - words: [ - { type: 'custom', value: 'test' }, - { type: 'custom', value: 'teSt2' }, - { type: 'personal', value: 'TesT3' }, - ], + personalInfo: false, + words: ['test', 'teSt2', 'TesT3'], }, }); it('should reject password with blacklisted words (case insensitive)', () => { - expect(checker.hasWords('test')).toEqual([{ type: 'custom', value: 'test' }]); - expect(checker.hasWords('tEst2')).toEqual([ - { type: 'custom', value: 'test' }, - { type: 'custom', value: 'test2' }, - ]); - expect(checker.hasWords('tEST TEst2 teSt3')).toEqual([ - { type: 'custom', value: 'test' }, - { type: 'custom', value: 'test2' }, - { type: 'personal', value: 'test3' }, - ]); + expect(checker.hasWords('test')).toEqual(['test']); + expect(checker.hasWords('tEst2')).toEqual(['test', 'test2']); + expect(checker.hasWords('tEST TEst2 teSt3')).toEqual(['test', 'test2', 'test3']); }); it('should accept password without blacklisted words', () => { @@ -177,7 +199,7 @@ describe('PasswordPolicyChecker -> hasSequentialChars()', () => { const checker = new PasswordPolicyChecker({ length: { min: 1, max: 256 }, characterTypes: { min: 2 }, - rejects: { pwned: false, repetitionAndSequence: true, words: [] }, + rejects: { pwned: false, repetitionAndSequence: true, personalInfo: false, words: [] }, }); it('should reject password with too many sequential characters', () => { diff --git a/packages/toolkit/core-kit/src/password-policy.ts b/packages/toolkit/core-kit/src/password-policy.ts index c56850f84..e1f17560f 100644 --- a/packages/toolkit/core-kit/src/password-policy.ts +++ b/packages/toolkit/core-kit/src/password-policy.ts @@ -1,13 +1,8 @@ +import { type DeepPartial } from '@silverhand/essentials'; import { z } from 'zod'; -/** A word that used for password policy. */ -type Word = { - type: 'custom' | 'personal'; - value: string; -}; - /** Password policy configuration type. */ -type PasswordPolicy = { +export type PasswordPolicy = { /** Policy about password length. */ length: { /** Minimum password length. */ @@ -33,29 +28,38 @@ type PasswordPolicy = { pwned: boolean; /** Whether to reject passwords that like '123456' or 'aaaaaa'. */ repetitionAndSequence: boolean; + /** Whether to reject passwords that include personal information. */ + personalInfo: boolean; /** Whether to reject passwords that include specific words. */ - words: Word[]; + words: string[]; }; }; /** Password policy configuration guard. */ -const passwordPolicyGuard: z.ZodType = z.object({ - length: z.object({ - min: z.number().int().min(1), - max: z.number().int().min(1), - }), - characterTypes: z.object({ - min: z.number().int().min(1).max(4), - }), - rejects: z.object({ - pwned: z.boolean(), - repetitionAndSequence: z.boolean(), - words: z.array(z.object({ type: z.enum(['custom', 'personal']), value: z.string() })), - }), -}); +export const passwordPolicyGuard = z.object({ + length: z + .object({ + min: z.number().int().min(1).default(8), + max: z.number().int().min(1).default(256), + }) + .default({}), + characterTypes: z + .object({ + min: z.number().int().min(1).max(4).optional().default(2), + }) + .default({}), + rejects: z + .object({ + pwned: z.boolean().default(true), + repetitionAndSequence: z.boolean().default(true), + personalInfo: z.boolean().default(true), + words: z.string().array().default([]), + }) + .default({}), +}) satisfies z.ZodType>; /** The code of why a password is rejected. */ -type PasswordRejectionCode = +export type PasswordRejectionCode = | 'too_short' | 'too_long' | 'character_types' @@ -63,16 +67,25 @@ type PasswordRejectionCode = | 'pwned' | 'repetition' | 'sequence' + | 'personal_info' | 'restricted_words'; /** A password issue that does not meet the policy. */ -type PasswordIssue = { +export type PasswordIssue = { /** Issue code. */ code: `password_rejected.${PasswordRejectionCode}`; /** Interpolation data for the issue message. */ interpolation?: Record; }; +/** Personal information to check. */ +export type PersonalInfo = Partial<{ + name: string; + username: string; + email: string; + phoneNumber: string; +}>; + /** * The class for checking if a password meets the policy. The policy is defined as * {@link PasswordPolicy}. @@ -98,28 +111,73 @@ type PasswordIssue = { export class PasswordPolicyChecker { static symbols = Object.freeze('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' as const); - constructor(public readonly policy: PasswordPolicy) { - // Validate policy. - passwordPolicyGuard.parse(policy); + public readonly policy: PasswordPolicy; + + constructor( + policy: DeepPartial, + /** The Web Crypto API to use. By default, the global `crypto.subtle` will be used. */ + protected readonly subtle: SubtleCrypto = crypto.subtle + ) { + this.policy = passwordPolicyGuard.parse(policy); } /** - * Check if a password meets the policy. + * Check if a password meets all the policy requirements. * * @param password - Password to check. + * @param personalInfo - Personal information to check. Required if the policy + * requires to reject passwords that include personal information. * @returns An array of issues. If the password meets the policy, an empty array will be returned. + * @throws TypeError - If the policy requires to reject passwords that include personal information + * but the personal information is not provided. */ /* eslint-disable @silverhand/fp/no-mutating-methods */ - async check(password: string): Promise { + + async check(password: string, personalInfo?: PersonalInfo): Promise { + const issues: PasswordIssue[] = this.fastCheck(password); + + if (this.policy.rejects.pwned && (await this.hasBeenPwned(password))) { + issues.push({ + code: 'password_rejected.pwned', + }); + } + + if (this.policy.rejects.personalInfo) { + if (!personalInfo) { + throw new TypeError('Personal information is required to check personal information.'); + } + + if (this.hasPersonalInfo(password, personalInfo)) { + issues.push({ + code: 'password_rejected.personal_info', + }); + } + } + + return issues; + } + + /** + * Perform a fast check to see if the password passes the basic requirements. + * No pwned password and personal information check will be performed. + * + * This method is used for frontend validation. + * + * @param password - Password to check. + * @returns Whether the password passes the basic requirements. + */ + fastCheck(password: string) { const issues: PasswordIssue[] = []; if (password.length < this.policy.length.min) { issues.push({ code: 'password_rejected.too_short', + interpolation: { min: this.policy.length.min }, }); } else if (password.length > this.policy.length.max) { issues.push({ code: 'password_rejected.too_long', + interpolation: { max: this.policy.length.max }, }); } @@ -135,12 +193,6 @@ export class PasswordPolicyChecker { }); } - if (this.policy.rejects.pwned && (await this.hasBeenPwned(password))) { - issues.push({ - code: 'password_rejected.pwned', - }); - } - if (this.policy.rejects.repetitionAndSequence) { if (this.hasRepetition(password)) { issues.push({ @@ -155,12 +207,14 @@ export class PasswordPolicyChecker { } } - issues.push( - ...this.hasWords(password).map(({ type, value }) => ({ + const words = this.hasWords(password); + + if (words.length > 0) { + issues.push({ code: 'password_rejected.restricted_words', - interpolation: { type, value }, - })) - ); + interpolation: { words: words.join('\n'), count: words.length }, + }); + } return issues; } @@ -199,13 +253,13 @@ export class PasswordPolicyChecker { * @returns Whether the password has been pwned. */ async hasBeenPwned(password: string): Promise { - const hash = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(password)); + const hash = await this.subtle.digest('SHA-1', new TextEncoder().encode(password)); const hashHex = Array.from(new Uint8Array(hash)) .map((binary) => binary.toString(16).padStart(2, '0')) .join(''); const hashPrefix = hashHex.slice(0, 5); const hashSuffix = hashHex.slice(5); - const response = await fetch(`https://api.haveibeenpwned.com/range/${hashPrefix}`); + const response = await fetch(`https://api.pwnedpasswords.com/range/${hashPrefix}`); const text = await response.text(); const hashes = text.split('\n'); const found = hashes.some((hex) => hex.toLowerCase().startsWith(hashSuffix)); @@ -228,20 +282,53 @@ export class PasswordPolicyChecker { return Boolean(matchedRepetition); } + /** + * Check if the given password contains personal information. + * + * @param password - Password to check. + * @param personalInfo - Personal information to check. + * @returns Whether the password contains personal information. + */ + hasPersonalInfo(password: string, personalInfo: PersonalInfo): boolean { + const lowercasedPassword = password.toLowerCase(); + const { name, username, email, phoneNumber } = personalInfo; + + if ( + name + ?.toLowerCase() + .split(' ') + .some((word) => lowercasedPassword.includes(word)) + ) { + return true; + } + + if (username && lowercasedPassword.includes(username.toLowerCase())) { + return true; + } + + const emailPrefix = email?.split('@')[0]; + if (emailPrefix && lowercasedPassword.includes(emailPrefix.toLowerCase())) { + return true; + } + + if (phoneNumber && lowercasedPassword.includes(phoneNumber)) { + return true; + } + + return false; + } + /** * Check if the given password contains specific words. * * @param password - Password to check. * @returns An array of matched words. */ - hasWords(password: string): Word[] { - const words = this.policy.rejects.words.map(({ value, ...rest }) => ({ - ...rest, - value: value.toLowerCase(), - })); + hasWords(password: string): string[] { + const words = this.policy.rejects.words.map((word) => word.toLowerCase()); const lowercasedPassword = password.toLowerCase(); - return words.filter(({ value }) => lowercasedPassword.includes(value)); + return words.filter((word) => lowercasedPassword.includes(word)); } /** diff --git a/packages/ui/package.json b/packages/ui/package.json index 828d81e18..80dc6126e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -37,7 +37,7 @@ "@react-spring/web": "^9.6.1", "@silverhand/eslint-config": "4.0.1", "@silverhand/eslint-config-react": "4.0.1", - "@silverhand/essentials": "^2.5.0", + "@silverhand/essentials": "^2.8.4", "@silverhand/ts-config": "4.0.0", "@silverhand/ts-config-react": "4.0.0", "@swc/core": "^1.3.52", diff --git a/packages/ui/src/__mocks__/logto.tsx b/packages/ui/src/__mocks__/logto.tsx index c9bdea516..79f124442 100644 --- a/packages/ui/src/__mocks__/logto.tsx +++ b/packages/ui/src/__mocks__/logto.tsx @@ -204,6 +204,7 @@ export const mockSignInExperience: SignInExperience = { signInMode: SignInMode.SignInAndRegister, customCss: null, customContent: {}, + passwordPolicy: {}, }; export const mockSignInExperienceSettings: SignInExperienceResponse = { @@ -228,6 +229,7 @@ export const mockSignInExperienceSettings: SignInExperienceResponse = { }, customCss: null, customContent: {}, + passwordPolicy: {}, }; const usernameSettings = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index baecfce21..704a01571 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,8 +49,8 @@ importers: specifier: ^3.0.2 version: 3.0.2(tslib@2.4.1)(typescript@5.0.2) '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 applicationinsights: specifier: ^2.7.0 version: 2.7.0 @@ -125,8 +125,8 @@ importers: specifier: workspace:^2.0.0 version: link:../shared '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 chalk: specifier: ^5.0.0 version: 5.1.2 @@ -240,8 +240,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 dayjs: specifier: ^1.10.5 version: 1.11.6 @@ -328,8 +328,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 dayjs: specifier: ^1.10.5 version: 1.11.6 @@ -416,8 +416,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -495,8 +495,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -577,8 +577,8 @@ importers: specifier: workspace:^2.0.0 version: link:../../shared '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -665,8 +665,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -747,8 +747,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -826,8 +826,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -905,8 +905,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -984,8 +984,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -1063,8 +1063,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -1145,8 +1145,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -1224,8 +1224,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -1303,8 +1303,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -1382,8 +1382,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -1461,8 +1461,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -1540,8 +1540,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.7.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -1619,8 +1619,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -1698,8 +1698,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -1777,8 +1777,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -1856,8 +1856,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -1935,8 +1935,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -2014,8 +2014,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -2099,8 +2099,8 @@ importers: specifier: workspace:^2.0.0 version: link:../../shared '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -2184,8 +2184,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 fast-xml-parser: specifier: ^4.2.5 version: 4.2.5 @@ -2269,8 +2269,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -2348,8 +2348,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -2427,8 +2427,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -2512,8 +2512,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -2591,8 +2591,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -2670,8 +2670,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -2749,8 +2749,8 @@ importers: specifier: workspace:^1.1.0 version: link:../../toolkit/connector-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 got: specifier: ^13.0.0 version: 13.0.0 @@ -2888,8 +2888,8 @@ importers: specifier: 4.0.1 version: 4.0.1(eslint@8.44.0)(postcss@8.4.6)(prettier@3.0.0)(stylelint@15.0.0)(typescript@5.0.2) '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 '@silverhand/ts-config': specifier: 4.0.0 version: 4.0.0(typescript@5.0.2) @@ -3164,8 +3164,8 @@ importers: specifier: workspace:* version: link:../ui '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 '@withtyped/client': specifier: ^0.7.22 version: 0.7.22(zod@3.20.2) @@ -3495,8 +3495,8 @@ importers: specifier: 4.0.1 version: 4.0.1(eslint@8.44.0)(prettier@3.0.0)(typescript@5.0.2) '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 '@silverhand/ts-config': specifier: 4.0.0 version: 4.0.0(typescript@5.0.2) @@ -3555,8 +3555,8 @@ importers: specifier: workspace:^1.0.0 version: link:../toolkit/language-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 zod: specifier: ^3.20.2 version: 3.20.2 @@ -3582,12 +3582,15 @@ importers: packages/phrases-ui: dependencies: + '@logto/core-kit': + specifier: workspace:^2.0.1 + version: link:../toolkit/core-kit '@logto/language-kit': specifier: workspace:^1.0.0 version: link:../toolkit/language-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 zod: specifier: ^3.20.2 version: 3.20.2 @@ -3645,8 +3648,8 @@ importers: specifier: 4.0.1 version: 4.0.1(eslint@8.44.0)(prettier@3.0.0)(typescript@5.0.2) '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 '@silverhand/ts-config': specifier: 4.0.0 version: 4.0.0(typescript@5.0.2) @@ -3699,8 +3702,8 @@ importers: packages/shared: dependencies: '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 chalk: specifier: ^5.0.0 version: 5.1.2 @@ -3751,8 +3754,8 @@ importers: specifier: workspace:^1.0.0 version: link:../language-kit '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 '@withtyped/client': specifier: ^0.7.22 version: 0.7.22(zod@3.20.2) @@ -3821,8 +3824,8 @@ importers: specifier: 4.0.1 version: 4.0.1(eslint@8.44.0)(prettier@3.0.0)(typescript@5.0.2) '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 '@silverhand/ts-config': specifier: 4.0.0 version: 4.0.0(typescript@5.0.2) @@ -3960,8 +3963,8 @@ importers: specifier: 4.0.1 version: 4.0.1(eslint@8.44.0)(postcss@8.4.6)(prettier@3.0.0)(stylelint@15.0.0)(typescript@5.0.2) '@silverhand/essentials': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.8.4 + version: 2.8.4 '@silverhand/ts-config': specifier: 4.0.0 version: 4.0.0(typescript@5.0.2) @@ -7297,7 +7300,7 @@ packages: resolution: {integrity: sha512-yDWSZMI2Qo/xoYU92tnwSP/gnSvq8+CLK5DqD/4brO42QJa7xjt7eA+HSyuMmSUrKffY2nP3riU81gs+nR8DkA==} engines: {node: ^18.12.0} dependencies: - '@silverhand/essentials': 2.8.2 + '@silverhand/essentials': 2.8.4 tiny-cookie: 2.4.1 dev: false @@ -7305,7 +7308,7 @@ packages: resolution: {integrity: sha512-4XsXlCC0uZHcfazV09/4YKo4koqvSzQlkPUAToTp/WHpb6h2XDOJh5/hi55LXL4zp0PCcgpErKRxFCtgXCc6WQ==} dependencies: '@logto/client': 2.2.0 - '@silverhand/essentials': 2.7.0 + '@silverhand/essentials': 2.8.4 js-base64: 3.7.5 dev: true @@ -7313,7 +7316,7 @@ packages: resolution: {integrity: sha512-vw8xDW8k38/58Q1r592z/9JdsmUh4+LMmoVm/Nu7LbWKlT32eD3H9hZDkFK9XEHpriifhI0hP7asGWEmhrEUuQ==} dependencies: '@logto/js': 2.1.1 - '@silverhand/essentials': 2.7.0 + '@silverhand/essentials': 2.8.4 camelcase-keys: 7.0.2 jose: 4.14.4 dev: true @@ -7322,7 +7325,7 @@ packages: resolution: {integrity: sha512-7I2ELo5UWIJsFCYK/gX465l0+QhXTdyYWkgb2CcdPu5KbaPBNpASedm+fEV2NREYe2svbNODFhog6UMA/xGQnQ==} dependencies: '@logto/js': 2.1.1 - '@silverhand/essentials': 2.8.2 + '@silverhand/essentials': 2.8.4 camelcase-keys: 7.0.2 jose: 4.14.4 dev: true @@ -7331,7 +7334,7 @@ packages: resolution: {integrity: sha512-zxy9zr5swOxbzYJNYtKXofj2tSIS9565d+1pT6RSbmx3Hn+JG6uzsb75PZXW9vlmmm7p1sGZeTQ+xVzKNFPsMg==} engines: {node: ^18.12.0} dependencies: - '@silverhand/essentials': 2.7.0 + '@silverhand/essentials': 2.8.4 '@withtyped/server': 0.12.8(zod@3.20.2) transitivePeerDependencies: - zod @@ -7341,7 +7344,7 @@ packages: resolution: {integrity: sha512-dIrEUW7gi477HQpNsq/HT1gdvPK2ZmVuV73u2rH9LXGEIFIVGqmmIaaK3IcOPG110jKCBhTzF0+hKsW9Y3Pjmw==} engines: {node: ^18.12.0} dependencies: - '@silverhand/essentials': 2.8.2 + '@silverhand/essentials': 2.8.4 '@withtyped/server': 0.12.9(zod@3.20.2) transitivePeerDependencies: - zod @@ -7350,7 +7353,7 @@ packages: /@logto/js@2.1.1: resolution: {integrity: sha512-PHikheavVK+l4ivgtzi14p184hEPgXjqQEAom1Gme1MZoopx+WlwxvHSEQBsmyvVqRtI0oiojhoU5tgYi1FKJw==} dependencies: - '@silverhand/essentials': 2.7.0 + '@silverhand/essentials': 2.8.4 camelcase-keys: 7.0.2 jose: 4.14.2 dev: true @@ -7359,7 +7362,7 @@ packages: resolution: {integrity: sha512-joSzzAqaRKeEquRenoFrIXXkNxkJci5zSkk4afywz1P8tTcTysnV4eXaBmwXNpmDfQdtHBwRdSACZPLgeF8JiQ==} dependencies: '@logto/client': 2.1.0 - '@silverhand/essentials': 2.7.0 + '@silverhand/essentials': 2.8.4 js-base64: 3.7.5 node-fetch: 2.6.7 transitivePeerDependencies: @@ -7372,7 +7375,7 @@ packages: react: '>=16.8.0 || ^18.0.0' dependencies: '@logto/browser': 2.1.0 - '@silverhand/essentials': 2.7.0 + '@silverhand/essentials': 2.8.4 react: 18.2.0 dev: true @@ -8955,16 +8958,8 @@ packages: lodash: 4.17.21 dev: true - /@silverhand/essentials@2.5.0: - resolution: {integrity: sha512-8GgVFAmbo6S0EgsjYXH4aH8a69O7SzEtPFPDpVZmJuGEt8e3ODVx0F2V4rXyC3/SzFbcb2md2gRbA+Z6aTad6g==} - engines: {node: ^16.13.0 || ^18.12.0 || ^19.2.0, pnpm: ^7} - - /@silverhand/essentials@2.7.0: - resolution: {integrity: sha512-F5Qo5ZNnERUURK/9F1ZIi4FBDM22aeD59Zv0VtkgIhUL9tYK9svA2Jz88NNdYBwqCPrh8ExZlpFNi+pNmXKNlQ==} - engines: {node: ^16.13.0 || ^18.12.0 || ^19.2.0, pnpm: ^8.0.0} - - /@silverhand/essentials@2.8.2: - resolution: {integrity: sha512-mrXzAQ6ZGyLoKQpfEOr/LmbQL7FIurVsBaymnsQyKNV56bFYCv5M+2irsAKROtmLMgSunAU/10XiDIaEYW9tbA==} + /@silverhand/essentials@2.8.4: + resolution: {integrity: sha512-VaI00QyD2trA7n7/wHNcGNGRXoSr8dUGs/hQCu4Rju4Edl3vso7CeCXdfGU2aNDuT2uMs75of6Ph8gqVJhWlYQ==} engines: {node: ^16.13.0 || ^18.12.0 || ^19.2.0, pnpm: ^8.0.0} /@silverhand/ts-config-react@4.0.0(typescript@5.0.2): @@ -10076,7 +10071,7 @@ packages: peerDependencies: zod: ^3.19.1 dependencies: - '@silverhand/essentials': 2.8.2 + '@silverhand/essentials': 2.8.4 '@withtyped/shared': 0.2.2 zod: 3.20.2 dev: true @@ -10086,7 +10081,7 @@ packages: peerDependencies: zod: ^3.19.1 dependencies: - '@silverhand/essentials': 2.8.2 + '@silverhand/essentials': 2.8.4 '@withtyped/shared': 0.2.2 zod: 3.20.2