Skip to content

Commit

Permalink
feat(core): add Sentinel guard (#6374)
Browse files Browse the repository at this point in the history
feat(core): add sentinel protection

add sentinel protection
  • Loading branch information
simeng-li authored Aug 7, 2024
1 parent f96a6e4 commit 6a71448
Show file tree
Hide file tree
Showing 3 changed files with 112 additions and 7 deletions.
Original file line number Diff line number Diff line change
@@ -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<T>(
{
sentinel,
action,
identifier,
payload,
}: {
sentinel: Sentinel;
action: SentinelActivityAction;
identifier: InteractionIdentifier;
payload: Record<string, unknown>;
},
verificationPromise: Promise<T>
): Promise<T> {
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;
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
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';

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';
import { type ExperienceInteractionRouterContext } from '../types.js';

export default function passwordVerificationRoutes<T extends ExperienceInteractionRouterContext>(
router: Router<unknown, T>,
{ libraries, queries }: TenantContext
{ libraries, queries, sentinel }: TenantContext
) {
router.post(
`${experienceRoutes.verification}/password`,
Expand All @@ -29,6 +34,7 @@ export default function passwordVerificationRoutes<T extends ExperienceInteracti
action: Action.Submit,
}),
async (ctx, next) => {
const { experienceInteraction } = ctx;
const { identifier, password } = ctx.guard.body;

ctx.verificationAuditLog.append({
Expand All @@ -39,9 +45,22 @@ export default function passwordVerificationRoutes<T extends ExperienceInteracti
});

const passwordVerification = PasswordVerification.create(libraries, queries, identifier);
await passwordVerification.verify(password);
ctx.experienceInteraction.setVerificationRecord(passwordVerification);
await ctx.experienceInteraction.save();

await withSentinel(
{
sentinel,
action: SentinelActivityAction.Password,
identifier,
payload: {
event: experienceInteraction.interactionEvent,
verificationId: passwordVerification.id,
},
},
passwordVerification.verify(password)
);

experienceInteraction.setVerificationRecord(passwordVerification);
await experienceInteraction.save();

ctx.body = { verificationId: passwordVerification.id };

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
InteractionEvent,
SentinelActivityAction,
type VerificationCodeIdentifier,
verificationCodeIdentifierGuard,
} from '@logto/schemas';
Expand All @@ -12,6 +13,7 @@ import koaGuard from '#src/middleware/koa-guard.js';
import type TenantContext from '#src/tenants/TenantContext.js';

import type ExperienceInteraction from '../classes/experience-interaction.js';
import { withSentinel } from '../classes/libraries/sentinel-guard.js';
import { codeVerificationIdentifierRecordTypeMap } from '../classes/utils.js';
import { createNewCodeVerificationRecord } from '../classes/verifications/code-verification.js';
import { experienceRoutes } from '../const.js';
Expand All @@ -30,7 +32,7 @@ const createVerificationCodeAuditLog = (

export default function verificationCodeRoutes<T extends ExperienceInteractionRouterContext>(
router: Router<unknown, T>,
{ libraries, queries }: TenantContext
{ libraries, queries, sentinel }: TenantContext
) {
router.post(
`${experienceRoutes.verification}/verification-code`,
Expand Down Expand Up @@ -99,6 +101,7 @@ export default function verificationCodeRoutes<T extends ExperienceInteractionRo
}),
async (ctx, next) => {
const { verificationId, code, identifier } = ctx.guard.body;
const { experienceInteraction } = ctx;

const log = createVerificationCodeAuditLog(
ctx,
Expand All @@ -120,7 +123,18 @@ export default function verificationCodeRoutes<T extends ExperienceInteractionRo
verificationId
);

await codeVerificationRecord.verify(identifier, code);
await withSentinel(
{
sentinel,
action: SentinelActivityAction.VerificationCode,
identifier,
payload: {
event: experienceInteraction.interactionEvent,
verificationId: codeVerificationRecord.id,
},
},
codeVerificationRecord.verify(identifier, code)
);

await ctx.experienceInteraction.save();

Expand Down

0 comments on commit 6a71448

Please sign in to comment.