UNPKG

@danielbiegler/vendure-plugin-user-registration-guard

Version:

Let's you flexibly customize if and how you prevent users from registering with your Vendure instance.

327 lines (326 loc) 14.9 kB
"use strict"; var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; var UserRegistrationGuardPlugin_1; Object.defineProperty(exports, "__esModule", { value: true }); exports.UserRegistrationGuardPlugin = exports.UserRegistrationInterceptor = void 0; const common_1 = require("@nestjs/common"); const core_1 = require("@nestjs/core"); const graphql_1 = require("@nestjs/graphql"); const core_2 = require("@vendure/core"); const rxjs_1 = require("rxjs"); const constants_1 = require("./constants"); const user_registration_blocked_event_1 = require("./events/user-registration-blocked.event"); /** * Since this interceptor will be registered globally it will run between every single request. * This means we must make sure to exit as early as possible in order to not slow down the Vendure instance. */ let UserRegistrationInterceptor = class UserRegistrationInterceptor { eventBus; moduleRef; requestContextService; options; injector; constructor(eventBus, moduleRef, requestContextService, options) { this.eventBus = eventBus; this.moduleRef = moduleRef; this.requestContextService = requestContextService; this.options = options; } /** @internal */ async onApplicationBootstrap() { this.injector = new core_2.Injector(this.moduleRef); } /** @internal */ async _isAllowed(ctx, assertFunctions, args) { /** * This should never happen, because Nestjs initializes the injector via `this.onApplicationBootstrap`, * but TypeScript can't guarantee that the value of `this.injector` won't change between checking and execution of the closure. * Each closure creates a new context where previous type narrowing isn't automatically carried forward. * Capturing the injector locally like this let's us carry it into the closure in a type-safe manner, * without using non-null assertion operator. */ if (!this.injector) throw new Error("Injector is not initialized"); const _capturedInjector = this.injector; const promises = assertFunctions.map((f) => f(ctx, args, _capturedInjector)); const results = await Promise.allSettled(promises); const rejecteds = results.filter((r) => r.status === "rejected"); if (rejecteds.length !== 0) throw new Error(`${rejecteds.length}/${results.length} AssertionFunctions rejected. This should never happen. Handle errors in your assertions.`, { cause: rejecteds }); // Typescript needed a little help here (2025-04-25) // The compiler failed because it couldnt infer the fulfilled promises and thought its `PromiseSettledResult<AssertFunctionResult>` const fulfilleds = results.filter((r) => r.status === "fulfilled"); const failures = fulfilleds.filter((f) => f.value.isAllowed === false); const operator = ctx.apiType === "shop" ? this.options.shop.assert.logicalOperator : this.options.admin.assert.logicalOperator; switch (operator) { case "AND": // In a logical "AND" everything must be true return { isAllowed: failures.length === 0, results: fulfilleds.map((f) => f.value) }; case "OR": // In a logical "OR" at least one must be true return { isAllowed: failures.length !== fulfilleds.length, results: fulfilleds.map((f) => f.value) }; default: throw new Error(`Unknown "logicalOperator" (${this.options.shop.assert.logicalOperator}) - This should never happen.`); } } /** @internal */ async intercept(context, next) { // Irrelevant contexts if (context.getType() !== "graphql") return next.handle(); const gqlCtx = graphql_1.GqlExecutionContext.create(context); const info = gqlCtx.getInfo(); if (info?.path?.typename !== "Mutation") return next.handle(); const requestContext = await this.requestContextService.fromRequest(gqlCtx.getContext().req, info); // Check if we can exit early switch (requestContext.apiType) { case "shop": if (this.options.shop.assert.functions.length === 0) return next.handle(); break; case "admin": if (this.options.admin.assert.functions.length === 0) return next.handle(); break; default: return next.handle(); } let result; let args; switch (info?.path?.key) { case "registerCustomerAccount": args = gqlCtx.getArgs(); result = await this._isAllowed(requestContext, this.options.shop.assert.functions, args); break; case "createAdministrator": args = gqlCtx.getArgs(); result = await this._isAllowed(requestContext, this.options.admin.assert.functions, args); break; default: return next.handle(); } if (!result.isAllowed) { return new rxjs_1.Observable((s) => { if (requestContext.apiType === "shop") { // Registering doesnt throw but returns a union s.next(new core_2.NativeAuthStrategyError()); this.eventBus .publish(new user_registration_blocked_event_1.BlockedCustomerRegistrationEvent(requestContext, args, result.results)) .catch(() => { }); } else { // CreateAdmin Mutation normally only returns `Administrator`, so we throw s.error(new core_2.NativeAuthStrategyError()); this.eventBus .publish(new user_registration_blocked_event_1.BlockedCreateAdministratorEvent(requestContext, args, result.results)) .catch(() => { }); } s.complete(); }); } return next.handle(); } }; exports.UserRegistrationInterceptor = UserRegistrationInterceptor; exports.UserRegistrationInterceptor = UserRegistrationInterceptor = __decorate([ (0, common_1.Injectable)(), __param(3, (0, common_1.Inject)(constants_1.PLUGIN_INIT_OPTIONS)), __metadata("design:paramtypes", [core_2.EventBus, core_1.ModuleRef, core_2.RequestContextService, Object]) ], UserRegistrationInterceptor); /** * The UserRegistrationGuardPlugin let's you flexibly customize if and how you prevent users from registering with your Vendure instance. * * ```ts * import { UserRegistrationGuardPlugin } from "@danielbiegler/vendure-plugin-user-registration-guard"; * export const config: VendureConfig = { * // ... * plugins: [ * UserRegistrationGuardPlugin.init({ * shop: { * assert: { * // AND means every single assertion must be true to allow user registration * logicalOperator: "AND", * functions: [ ], // Insert your assertions here * }, * }, * admin: { * assert: { * // OR means user registration is allowed if a single assertion is true * logicalOperator: "OR", * functions: [ ], // You may leave this empty too * }, * }, * }), * ], * } * ``` * * Please refer to the specific {@link PluginUserRegistrationGuardOptions} for how and what you can customize. * * ### 2. Create an assertion * * Here's an example assertion where we block customer registrations if the email ends with `example.com`: * * ```ts * const blockExampleDotCom: AssertFunctionShopApi = async (ctx, args) => { * const isAllowed = !args.input.emailAddress.endsWith("example.com"); * return { * isAllowed, * reason: !isAllowed ? 'Failed because email ends with "example.com"' : undefined, * }; * }; * ``` * * The `reason` field is helpful for when you're subscribing to the published {@link BlockedCustomerRegistrationEvent} or {@link BlockedCreateAdministratorEvent} and want to log or understand why somebody got blocked. * * In your assertions you'll receive the [`RequestContext`](https://docs.vendure.io/reference/typescript-api/request/request-context) and the GraphQL arguments of the mutation, which by default are either [`RegisterCustomerInput`](https://docs.vendure.io/reference/graphql-api/shop/input-types#registercustomerinput) or [`CreateAdministratorInput`](https://docs.vendure.io/reference/graphql-api/admin/input-types#createadministratorinput) depending on the API type. For example, if you'd like to block IP ranges you can access the underlying [Express Request](https://docs.vendure.io/reference/typescript-api/request/request-context#req) object through the `RequestContext` . * * If you've extended your GraphQL API types you may override the TypeScript Generic to get completions in your assertion functions like so: * * ```ts * const example: AssertFunctionShopApi<{ * example: boolean; * // ... * }> = async (ctx, args) => { * return { isAllowed: args.example }; * }; * ``` * * ### 3. Add it to the plugin * * ```ts * import { UserRegistrationGuardPlugin } from "@danielbiegler/vendure-plugin-user-registration-guard"; * export const config: VendureConfig = { * // ... * plugins: [ * UserRegistrationGuardPlugin.init({ * shop: { * assert: { * logicalOperator: "AND", * functions: [ blockExampleDotCom ], * }, * }, * admin: { * assert: { * logicalOperator: "AND", * functions: [], * }, * }, * }), * ], * } * ``` * * ### 4. Try registering new customers * * ```graphql * mutation { * registerCustomerAccount(input: { * emailAddress: "example@example.com", * # ... * }) { * __typename * } * } * ``` * * This user will now be blocked from registering according to our `blockExampleDotCom` assertion. * * #### Handling errors * * The plugin is non-intrusive and does not extend the API itself to avoid introducing unhandled errors in your code. * * It respects [`RegisterCustomerAccountResult`](https://docs.vendure.io/reference/graphql-api/shop/object-types#registercustomeraccountresult) being a Union, so we don't throw an error, but return a [`NativeAuthStrategyError`](https://docs.vendure.io/reference/graphql-api/shop/object-types#nativeauthstrategyerror). You may handle the error just like the other `RegisterCustomerAccountResult` types like [`PasswordValidationError`](https://docs.vendure.io/reference/graphql-api/shop/object-types#passwordvalidationerror) for example. * * In contrast, for admins we do throw the error! This is a little different because by default the [`createAdministrator`](https://docs.vendure.io/reference/graphql-api/admin/mutations#createadministrator) mutation does not return a Union with error types. * * Granted, the `NativeAuthStrategyError` is technically not correct for blocking registrations and doesn't communicate the blocking properly, but it's the only reasonable error type in the Union for a default non-api-extended Vendure instance. You might want to add some comments in your registration logic that the error means blockage. * * ### 5. Subscribe to events * * You may want to [subscribe](https://docs.vendure.io/guides/developer-guide/events/#subscribing-to-events) to the [EventBus](https://docs.vendure.io/reference/typescript-api/events/event-bus) to monitor blocked registration attempts. * * ```ts * this.eventBus * .ofType(BlockedCustomerRegistrationEvent<MutationRegisterCustomerAccountArgs>) * .subscribe(async (event) => { * const rejecteds = event.assertions.filter((a) => !a.isAllowed); * console.log(`Blocked customer registration! ${rejecteds.length}/${event.assertions.length} assertions failed, see reasons:`); * rejecteds.forEach(r => console.log(" -", r.reason)); * * // Example output: * // Blocked customer registration! 1/1 assertions failed, see reasons: * // - Failed because email ends with "example.com" * }); * * this.eventBus * // You can even override the passed in args if you've extended your Graphql API * .ofType(BlockedCreateAdministratorEvent<{ example: boolean }>).subscribe(async (event) => { * event.args.example // is typed now! :) * }); * ``` * * @category Plugin */ let UserRegistrationGuardPlugin = class UserRegistrationGuardPlugin { static { UserRegistrationGuardPlugin_1 = this; } /** @internal */ static options; /** * The static `init()` method is called with the options to configure the plugin. * * @example * ```ts * UserRegistrationGuardPlugin.init({ * shop: { * assert: { * logicalOperator: "AND", * functions: [firstFunc, secondFunc], * }, * }, * admin: { * assert: { * logicalOperator: "OR", * functions: [], * }, * }, * }), * ``` */ static init(options) { this.options = options; return UserRegistrationGuardPlugin_1; } }; exports.UserRegistrationGuardPlugin = UserRegistrationGuardPlugin; exports.UserRegistrationGuardPlugin = UserRegistrationGuardPlugin = UserRegistrationGuardPlugin_1 = __decorate([ (0, core_2.VendurePlugin)({ imports: [core_2.PluginCommonModule], providers: [ { provide: constants_1.PLUGIN_INIT_OPTIONS, useFactory: () => UserRegistrationGuardPlugin.options, }, { provide: core_1.APP_INTERCEPTOR, useClass: UserRegistrationInterceptor, }, ], configuration: (config) => { return config; }, compatibility: ">=3.0.0", }) ], UserRegistrationGuardPlugin);