From 9e0a1c0378ef32a3b531e73cd8c6f331198e0f41 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Tue, 6 Aug 2024 13:39:24 +0800 Subject: [PATCH] feat(core): add sentinel protection add sentinel protection --- .../classes/libraries/sentinel-guard.ts | 72 +++++++++++++++++++ .../password-verification.ts | 29 ++++++-- .../verification-routes/verification-code.ts | 18 ++++- 3 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/routes/experience/classes/libraries/sentinel-guard.ts diff --git a/packages/core/src/routes/experience/classes/libraries/sentinel-guard.ts b/packages/core/src/routes/experience/classes/libraries/sentinel-guard.ts new file mode 100644 index 00000000000..127df9137b1 --- /dev/null +++ b/packages/core/src/routes/experience/classes/libraries/sentinel-guard.ts @@ -0,0 +1,72 @@ +import { + SentinelActionResult, + SentinelActivityTargetType, + SentinelDecision, + type InteractionIdentifier, + type Sentinel, + type SentinelActivityAction, +} from '@logto/schemas'; +import { sha256 } from 'hash-wasm'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { i18next } from '#src/utils/i18n.js'; + +/** + * Applies a sentinel guard to a verification promise. + * + * @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. + * + * @throws {RequestError} If the user is blocked. + * @throws original verification error if user is not blocked + */ +export async function withSentinel( + { + sentinel, + action, + identifier, + payload, + }: { + sentinel: Sentinel; + action: SentinelActivityAction; + identifier: InteractionIdentifier; + payload: Record; + }, + verificationPromise: Promise +): Promise { + 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 sentinel.reportActivity({ + targetType: SentinelActivityTargetType.User, + targetHash: await sha256(identifier.value), + action, + actionResult, + payload, + }); + + 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; +} diff --git a/packages/core/src/routes/experience/verification-routes/password-verification.ts b/packages/core/src/routes/experience/verification-routes/password-verification.ts index e1394081546..10dcf5ec504 100644 --- a/packages/core/src/routes/experience/verification-routes/password-verification.ts +++ b/packages/core/src/routes/experience/verification-routes/password-verification.ts @@ -1,4 +1,8 @@ -import { passwordVerificationPayloadGuard, VerificationType } from '@logto/schemas'; +import { + passwordVerificationPayloadGuard, + SentinelActivityAction, + VerificationType, +} from '@logto/schemas'; import { Action } from '@logto/schemas/lib/types/log/interaction.js'; import type Router from 'koa-router'; import { z } from 'zod'; @@ -6,6 +10,7 @@ import { z } from 'zod'; import koaGuard from '#src/middleware/koa-guard.js'; import type TenantContext from '#src/tenants/TenantContext.js'; +import { withSentinel } from '../classes/libraries/sentinel-guard.js'; import { PasswordVerification } from '../classes/verifications/password-verification.js'; import { experienceRoutes } from '../const.js'; import koaExperienceVerificationsAuditLog from '../middleware/koa-experience-verifications-audit-log.js'; @@ -13,7 +18,7 @@ import { type ExperienceInteractionRouterContext } from '../types.js'; export default function passwordVerificationRoutes( router: Router, - { libraries, queries }: TenantContext + { libraries, queries, sentinel }: TenantContext ) { router.post( `${experienceRoutes.verification}/password`, @@ -29,6 +34,7 @@ export default function passwordVerificationRoutes { + const { experienceInteraction } = ctx; const { identifier, password } = ctx.guard.body; ctx.verificationAuditLog.append({ @@ -39,9 +45,22 @@ export default function passwordVerificationRoutes( router: Router, - { libraries, queries }: TenantContext + { libraries, queries, sentinel }: TenantContext ) { router.post( `${experienceRoutes.verification}/verification-code`, @@ -99,6 +101,7 @@ export default function verificationCodeRoutes { const { verificationId, code, identifier } = ctx.guard.body; + const { experienceInteraction } = ctx; const log = createVerificationCodeAuditLog( ctx, @@ -120,7 +123,18 @@ export default function verificationCodeRoutes