Explorar o código

feat(core): Add PasswordValidationStrategy to enable password policies

Closes #863. The default policy is intentionally permissive to reduce the risk of
backward-compatibility breaks.
Michael Bromley %!s(int64=3) %!d(string=hai) anos
pai
achega
dc4bc2d002
Modificáronse 27 ficheiros con 629 adicións e 39 borrados
  1. 25 0
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  2. 25 0
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  3. 46 2
      packages/common/src/generated-shop-types.ts
  4. 25 0
      packages/common/src/generated-types.ts
  5. 34 0
      packages/core/e2e/custom-fields.e2e-spec.ts
  6. 25 0
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  7. 61 2
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  8. 9 0
      packages/core/e2e/graphql/shop-definitions.ts
  9. 74 0
      packages/core/e2e/shop-auth.e2e-spec.ts
  10. 30 16
      packages/core/src/api/config/graphql-custom-fields.ts
  11. 7 0
      packages/core/src/api/schema/shop-api/shop-error-results.graphql
  12. 4 2
      packages/core/src/api/schema/shop-api/shop.api.graphql
  13. 12 1
      packages/core/src/common/error/generated-graphql-shop-errors.ts
  14. 35 0
      packages/core/src/config/auth/default-password-validation-strategy.ts
  15. 23 0
      packages/core/src/config/auth/password-validation-strategy.ts
  16. 2 0
      packages/core/src/config/config.module.ts
  17. 2 0
      packages/core/src/config/default-config.ts
  18. 4 0
      packages/core/src/config/index.ts
  19. 23 2
      packages/core/src/config/vendure-config.ts
  20. 1 0
      packages/core/src/i18n/messages/en.json
  21. 22 5
      packages/core/src/service/services/customer.service.ts
  22. 45 7
      packages/core/src/service/services/user.service.ts
  23. 25 0
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  24. 25 0
      packages/payments-plugin/e2e/graphql/generated-admin-types.ts
  25. 45 2
      packages/payments-plugin/e2e/graphql/generated-shop-types.ts
  26. 0 0
      schema-admin.json
  27. 0 0
      schema-shop.json

+ 25 - 0
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -277,6 +277,11 @@ export type BooleanCustomFieldConfig = CustomField & {
   ui?: Maybe<Scalars['JSON']>;
 };
 
+/** Operators for filtering on a list of Boolean fields */
+export type BooleanListOperators = {
+  inList: Scalars['Boolean'];
+};
+
 /** Operators for filtering on a Boolean field */
 export type BooleanOperators = {
   eq?: Maybe<Scalars['Boolean']>;
@@ -1293,6 +1298,11 @@ export type CustomerSortParameter = {
   emailAddress?: Maybe<SortOrder>;
 };
 
+/** Operators for filtering on a list of Date fields */
+export type DateListOperators = {
+  inList: Scalars['DateTime'];
+};
+
 /** Operators for filtering on a DateTime field */
 export type DateOperators = {
   eq?: Maybe<Scalars['DateTime']>;
@@ -1669,6 +1679,11 @@ export enum HistoryEntryType {
   ORDER_MODIFIED = 'ORDER_MODIFIED'
 }
 
+/** Operators for filtering on a list of ID fields */
+export type IdListOperators = {
+  inList: Scalars['ID'];
+};
+
 /** Operators for filtering on an ID field */
 export type IdOperators = {
   eq?: Maybe<Scalars['String']>;
@@ -3105,6 +3120,11 @@ export type NothingToRefundError = ErrorResult & {
   message: Scalars['String'];
 };
 
+/** Operators for filtering on a list of Number fields */
+export type NumberListOperators = {
+  inList: Scalars['Float'];
+};
+
 /** Operators for filtering on a Int or Float field */
 export type NumberOperators = {
   eq?: Maybe<Scalars['Float']>;
@@ -4776,6 +4796,11 @@ export type StringFieldOption = {
   label?: Maybe<Array<LocalizedString>>;
 };
 
+/** Operators for filtering on a list of String fields */
+export type StringListOperators = {
+  inList: Scalars['String'];
+};
+
 /** Operators for filtering on a String field */
 export type StringOperators = {
   eq?: Maybe<Scalars['String']>;

+ 25 - 0
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -274,6 +274,11 @@ export type BooleanCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']>;
 };
 
+/** Operators for filtering on a list of Boolean fields */
+export type BooleanListOperators = {
+    inList: Scalars['Boolean'];
+};
+
 /** Operators for filtering on a Boolean field */
 export type BooleanOperators = {
     eq?: Maybe<Scalars['Boolean']>;
@@ -1269,6 +1274,11 @@ export type CustomerSortParameter = {
     emailAddress?: Maybe<SortOrder>;
 };
 
+/** Operators for filtering on a list of Date fields */
+export type DateListOperators = {
+    inList: Scalars['DateTime'];
+};
+
 /** Operators for filtering on a DateTime field */
 export type DateOperators = {
     eq?: Maybe<Scalars['DateTime']>;
@@ -1627,6 +1637,11 @@ export enum HistoryEntryType {
     ORDER_MODIFIED = 'ORDER_MODIFIED',
 }
 
+/** Operators for filtering on a list of ID fields */
+export type IdListOperators = {
+    inList: Scalars['ID'];
+};
+
 /** Operators for filtering on an ID field */
 export type IdOperators = {
     eq?: Maybe<Scalars['String']>;
@@ -2886,6 +2901,11 @@ export type NothingToRefundError = ErrorResult & {
     message: Scalars['String'];
 };
 
+/** Operators for filtering on a list of Number fields */
+export type NumberListOperators = {
+    inList: Scalars['Float'];
+};
+
 /** Operators for filtering on a Int or Float field */
 export type NumberOperators = {
     eq?: Maybe<Scalars['Float']>;
@@ -4462,6 +4482,11 @@ export type StringFieldOption = {
     label?: Maybe<Array<LocalizedString>>;
 };
 
+/** Operators for filtering on a list of String fields */
+export type StringListOperators = {
+    inList: Scalars['String'];
+};
+
 /** Operators for filtering on a String field */
 export type StringOperators = {
     eq?: Maybe<Scalars['String']>;

+ 46 - 2
packages/common/src/generated-shop-types.ts

@@ -131,6 +131,11 @@ export type BooleanCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']>;
 };
 
+/** Operators for filtering on a list of Boolean fields */
+export type BooleanListOperators = {
+    inList: Scalars['Boolean'];
+};
+
 /** Operators for filtering on a Boolean field */
 export type BooleanOperators = {
     eq?: Maybe<Scalars['Boolean']>;
@@ -804,6 +809,11 @@ export type CustomerSortParameter = {
     emailAddress?: Maybe<SortOrder>;
 };
 
+/** Operators for filtering on a list of Date fields */
+export type DateListOperators = {
+    inList: Scalars['DateTime'];
+};
+
 /** Operators for filtering on a DateTime field */
 export type DateOperators = {
     eq?: Maybe<Scalars['DateTime']>;
@@ -886,6 +896,7 @@ export enum ErrorCode {
     COUPON_CODE_LIMIT_ERROR = 'COUPON_CODE_LIMIT_ERROR',
     ALREADY_LOGGED_IN_ERROR = 'ALREADY_LOGGED_IN_ERROR',
     MISSING_PASSWORD_ERROR = 'MISSING_PASSWORD_ERROR',
+    PASSWORD_VALIDATION_ERROR = 'PASSWORD_VALIDATION_ERROR',
     PASSWORD_ALREADY_SET_ERROR = 'PASSWORD_ALREADY_SET_ERROR',
     VERIFICATION_TOKEN_INVALID_ERROR = 'VERIFICATION_TOKEN_INVALID_ERROR',
     VERIFICATION_TOKEN_EXPIRED_ERROR = 'VERIFICATION_TOKEN_EXPIRED_ERROR',
@@ -1107,6 +1118,11 @@ export enum HistoryEntryType {
     ORDER_MODIFIED = 'ORDER_MODIFIED',
 }
 
+/** Operators for filtering on a list of ID fields */
+export type IdListOperators = {
+    inList: Scalars['ID'];
+};
+
 /** Operators for filtering on an ID field */
 export type IdOperators = {
     eq?: Maybe<Scalars['String']>;
@@ -1792,6 +1808,11 @@ export type NotVerifiedError = ErrorResult & {
     message: Scalars['String'];
 };
 
+/** Operators for filtering on a list of Number fields */
+export type NumberListOperators = {
+    inList: Scalars['Float'];
+};
+
 /** Operators for filtering on a Int or Float field */
 export type NumberOperators = {
     eq?: Maybe<Scalars['Float']>;
@@ -2114,6 +2135,14 @@ export type PasswordResetTokenInvalidError = ErrorResult & {
     message: Scalars['String'];
 };
 
+/** Returned when attempting to register or verify a customer account where the given password fails password validation. */
+export type PasswordValidationError = ErrorResult & {
+    __typename?: 'PasswordValidationError';
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    validationErrorMessage: Scalars['String'];
+};
+
 export type Payment = Node & {
     __typename?: 'Payment';
     id: Scalars['ID'];
@@ -2696,7 +2725,11 @@ export type Refund = Node & {
     metadata?: Maybe<Scalars['JSON']>;
 };
 
-export type RegisterCustomerAccountResult = Success | MissingPasswordError | NativeAuthStrategyError;
+export type RegisterCustomerAccountResult =
+    | Success
+    | MissingPasswordError
+    | PasswordValidationError
+    | NativeAuthStrategyError;
 
 export type RegisterCustomerInput = {
     emailAddress: Scalars['String'];
@@ -2736,6 +2769,7 @@ export type ResetPasswordResult =
     | CurrentUser
     | PasswordResetTokenInvalidError
     | PasswordResetTokenExpiredError
+    | PasswordValidationError
     | NativeAuthStrategyError
     | NotVerifiedError;
 
@@ -2918,6 +2952,11 @@ export type StringFieldOption = {
     label?: Maybe<Array<LocalizedString>>;
 };
 
+/** Operators for filtering on a list of String fields */
+export type StringListOperators = {
+    inList: Scalars['String'];
+};
+
 /** Operators for filtering on a String field */
 export type StringOperators = {
     eq?: Maybe<Scalars['String']>;
@@ -3043,7 +3082,11 @@ export type UpdateCustomerInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
-export type UpdateCustomerPasswordResult = Success | InvalidCredentialsError | NativeAuthStrategyError;
+export type UpdateCustomerPasswordResult =
+    | Success
+    | InvalidCredentialsError
+    | PasswordValidationError
+    | NativeAuthStrategyError;
 
 export type UpdateOrderInput = {
     customFields?: Maybe<Scalars['JSON']>;
@@ -3094,6 +3137,7 @@ export type VerifyCustomerAccountResult =
     | VerificationTokenInvalidError
     | VerificationTokenExpiredError
     | MissingPasswordError
+    | PasswordValidationError
     | PasswordAlreadySetError
     | NativeAuthStrategyError;
 

+ 25 - 0
packages/common/src/generated-types.ts

@@ -276,6 +276,11 @@ export type BooleanCustomFieldConfig = CustomField & {
   ui?: Maybe<Scalars['JSON']>;
 };
 
+/** Operators for filtering on a list of Boolean fields */
+export type BooleanListOperators = {
+  inList: Scalars['Boolean'];
+};
+
 /** Operators for filtering on a Boolean field */
 export type BooleanOperators = {
   eq?: Maybe<Scalars['Boolean']>;
@@ -1285,6 +1290,11 @@ export type CustomerSortParameter = {
   emailAddress?: Maybe<SortOrder>;
 };
 
+/** Operators for filtering on a list of Date fields */
+export type DateListOperators = {
+  inList: Scalars['DateTime'];
+};
+
 /** Operators for filtering on a DateTime field */
 export type DateOperators = {
   eq?: Maybe<Scalars['DateTime']>;
@@ -1661,6 +1671,11 @@ export enum HistoryEntryType {
   ORDER_MODIFIED = 'ORDER_MODIFIED'
 }
 
+/** Operators for filtering on a list of ID fields */
+export type IdListOperators = {
+  inList: Scalars['ID'];
+};
+
 /** Operators for filtering on an ID field */
 export type IdOperators = {
   eq?: Maybe<Scalars['String']>;
@@ -3041,6 +3056,11 @@ export type NothingToRefundError = ErrorResult & {
   message: Scalars['String'];
 };
 
+/** Operators for filtering on a list of Number fields */
+export type NumberListOperators = {
+  inList: Scalars['Float'];
+};
+
 /** Operators for filtering on a Int or Float field */
 export type NumberOperators = {
   eq?: Maybe<Scalars['Float']>;
@@ -4708,6 +4728,11 @@ export type StringFieldOption = {
   label?: Maybe<Array<LocalizedString>>;
 };
 
+/** Operators for filtering on a list of String fields */
+export type StringListOperators = {
+  inList: Scalars['String'];
+};
+
 /** Operators for filtering on a String field */
 export type StringOperators = {
   eq?: Maybe<Scalars['String']>;

+ 34 - 0
packages/core/e2e/custom-fields.e2e-spec.ts

@@ -772,6 +772,40 @@ describe('Custom fields', () => {
             expect(products.totalItems).toBe(1);
         });
 
+        it('can filter by custom list fields', async () => {
+            const { products: result1 } = await adminClient.query(gql`
+                query {
+                    products(options: { filter: { intListWithValidation: { inList: 42 } } }) {
+                        totalItems
+                    }
+                }
+            `);
+
+            expect(result1.totalItems).toBe(1);
+            const { products: result2 } = await adminClient.query(gql`
+                query {
+                    products(options: { filter: { intListWithValidation: { inList: 43 } } }) {
+                        totalItems
+                    }
+                }
+            `);
+
+            expect(result2.totalItems).toBe(0);
+        });
+
+        it(
+            'cannot sort by custom list fields',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query(gql`
+                    query {
+                        products(options: { sort: { intListWithValidation: ASC } }) {
+                            totalItems
+                        }
+                    }
+                `);
+            }, `Field "intListWithValidation" is not defined by type "ProductSortParameter".`),
+        );
+
         it(
             'cannot filter by internal field in Admin API',
             assertThrowsWithMessage(async () => {

+ 25 - 0
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -274,6 +274,11 @@ export type BooleanCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']>;
 };
 
+/** Operators for filtering on a list of Boolean fields */
+export type BooleanListOperators = {
+    inList: Scalars['Boolean'];
+};
+
 /** Operators for filtering on a Boolean field */
 export type BooleanOperators = {
     eq?: Maybe<Scalars['Boolean']>;
@@ -1269,6 +1274,11 @@ export type CustomerSortParameter = {
     emailAddress?: Maybe<SortOrder>;
 };
 
+/** Operators for filtering on a list of Date fields */
+export type DateListOperators = {
+    inList: Scalars['DateTime'];
+};
+
 /** Operators for filtering on a DateTime field */
 export type DateOperators = {
     eq?: Maybe<Scalars['DateTime']>;
@@ -1627,6 +1637,11 @@ export enum HistoryEntryType {
     ORDER_MODIFIED = 'ORDER_MODIFIED',
 }
 
+/** Operators for filtering on a list of ID fields */
+export type IdListOperators = {
+    inList: Scalars['ID'];
+};
+
 /** Operators for filtering on an ID field */
 export type IdOperators = {
     eq?: Maybe<Scalars['String']>;
@@ -2886,6 +2901,11 @@ export type NothingToRefundError = ErrorResult & {
     message: Scalars['String'];
 };
 
+/** Operators for filtering on a list of Number fields */
+export type NumberListOperators = {
+    inList: Scalars['Float'];
+};
+
 /** Operators for filtering on a Int or Float field */
 export type NumberOperators = {
     eq?: Maybe<Scalars['Float']>;
@@ -4462,6 +4482,11 @@ export type StringFieldOption = {
     label?: Maybe<Array<LocalizedString>>;
 };
 
+/** Operators for filtering on a list of String fields */
+export type StringListOperators = {
+    inList: Scalars['String'];
+};
+
 /** Operators for filtering on a String field */
 export type StringOperators = {
     eq?: Maybe<Scalars['String']>;

+ 61 - 2
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -124,6 +124,11 @@ export type BooleanCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']>;
 };
 
+/** Operators for filtering on a list of Boolean fields */
+export type BooleanListOperators = {
+    inList: Scalars['Boolean'];
+};
+
 /** Operators for filtering on a Boolean field */
 export type BooleanOperators = {
     eq?: Maybe<Scalars['Boolean']>;
@@ -775,6 +780,11 @@ export type CustomerSortParameter = {
     emailAddress?: Maybe<SortOrder>;
 };
 
+/** Operators for filtering on a list of Date fields */
+export type DateListOperators = {
+    inList: Scalars['DateTime'];
+};
+
 /** Operators for filtering on a DateTime field */
 export type DateOperators = {
     eq?: Maybe<Scalars['DateTime']>;
@@ -853,6 +863,7 @@ export enum ErrorCode {
     COUPON_CODE_LIMIT_ERROR = 'COUPON_CODE_LIMIT_ERROR',
     ALREADY_LOGGED_IN_ERROR = 'ALREADY_LOGGED_IN_ERROR',
     MISSING_PASSWORD_ERROR = 'MISSING_PASSWORD_ERROR',
+    PASSWORD_VALIDATION_ERROR = 'PASSWORD_VALIDATION_ERROR',
     PASSWORD_ALREADY_SET_ERROR = 'PASSWORD_ALREADY_SET_ERROR',
     VERIFICATION_TOKEN_INVALID_ERROR = 'VERIFICATION_TOKEN_INVALID_ERROR',
     VERIFICATION_TOKEN_EXPIRED_ERROR = 'VERIFICATION_TOKEN_EXPIRED_ERROR',
@@ -1064,6 +1075,11 @@ export enum HistoryEntryType {
     ORDER_MODIFIED = 'ORDER_MODIFIED',
 }
 
+/** Operators for filtering on a list of ID fields */
+export type IdListOperators = {
+    inList: Scalars['ID'];
+};
+
 /** Operators for filtering on an ID field */
 export type IdOperators = {
     eq?: Maybe<Scalars['String']>;
@@ -1734,6 +1750,11 @@ export type NotVerifiedError = ErrorResult & {
     message: Scalars['String'];
 };
 
+/** Operators for filtering on a list of Number fields */
+export type NumberListOperators = {
+    inList: Scalars['Float'];
+};
+
 /** Operators for filtering on a Int or Float field */
 export type NumberOperators = {
     eq?: Maybe<Scalars['Float']>;
@@ -2043,6 +2064,13 @@ export type PasswordResetTokenInvalidError = ErrorResult & {
     message: Scalars['String'];
 };
 
+/** Returned when attempting to register or verify a customer account where the given password fails password validation. */
+export type PasswordValidationError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    validationErrorMessage: Scalars['String'];
+};
+
 export type Payment = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
@@ -2605,7 +2633,11 @@ export type Refund = Node & {
     metadata?: Maybe<Scalars['JSON']>;
 };
 
-export type RegisterCustomerAccountResult = Success | MissingPasswordError | NativeAuthStrategyError;
+export type RegisterCustomerAccountResult =
+    | Success
+    | MissingPasswordError
+    | PasswordValidationError
+    | NativeAuthStrategyError;
 
 export type RegisterCustomerInput = {
     emailAddress: Scalars['String'];
@@ -2644,6 +2676,7 @@ export type ResetPasswordResult =
     | CurrentUser
     | PasswordResetTokenInvalidError
     | PasswordResetTokenExpiredError
+    | PasswordValidationError
     | NativeAuthStrategyError
     | NotVerifiedError;
 
@@ -2812,6 +2845,11 @@ export type StringFieldOption = {
     label?: Maybe<Array<LocalizedString>>;
 };
 
+/** Operators for filtering on a list of String fields */
+export type StringListOperators = {
+    inList: Scalars['String'];
+};
+
 /** Operators for filtering on a String field */
 export type StringOperators = {
     eq?: Maybe<Scalars['String']>;
@@ -2928,7 +2966,11 @@ export type UpdateCustomerInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
-export type UpdateCustomerPasswordResult = Success | InvalidCredentialsError | NativeAuthStrategyError;
+export type UpdateCustomerPasswordResult =
+    | Success
+    | InvalidCredentialsError
+    | PasswordValidationError
+    | NativeAuthStrategyError;
 
 export type UpdateOrderInput = {
     customFields?: Maybe<Scalars['JSON']>;
@@ -2976,6 +3018,7 @@ export type VerifyCustomerAccountResult =
     | VerificationTokenInvalidError
     | VerificationTokenExpiredError
     | MissingPasswordError
+    | PasswordValidationError
     | PasswordAlreadySetError
     | NativeAuthStrategyError;
 
@@ -3091,6 +3134,7 @@ export type RegisterMutation = {
     registerCustomerAccount:
         | Pick<Success, 'success'>
         | Pick<MissingPasswordError, 'errorCode' | 'message'>
+        | Pick<PasswordValidationError, 'errorCode' | 'message' | 'validationErrorMessage'>
         | Pick<NativeAuthStrategyError, 'errorCode' | 'message'>;
 };
 
@@ -3109,6 +3153,7 @@ export type VerifyMutation = {
         | Pick<VerificationTokenInvalidError, 'errorCode' | 'message'>
         | Pick<VerificationTokenExpiredError, 'errorCode' | 'message'>
         | Pick<MissingPasswordError, 'errorCode' | 'message'>
+        | Pick<PasswordValidationError, 'errorCode' | 'message' | 'validationErrorMessage'>
         | Pick<PasswordAlreadySetError, 'errorCode' | 'message'>
         | Pick<NativeAuthStrategyError, 'errorCode' | 'message'>;
 };
@@ -3143,6 +3188,7 @@ export type ResetPasswordMutation = {
         | CurrentUserShopFragment
         | Pick<PasswordResetTokenInvalidError, 'errorCode' | 'message'>
         | Pick<PasswordResetTokenExpiredError, 'errorCode' | 'message'>
+        | Pick<PasswordValidationError, 'errorCode' | 'message' | 'validationErrorMessage'>
         | Pick<NativeAuthStrategyError, 'errorCode' | 'message'>
         | Pick<NotVerifiedError, 'errorCode' | 'message'>;
 };
@@ -3213,6 +3259,7 @@ export type UpdatePasswordMutation = {
     updateCustomerPassword:
         | Pick<Success, 'success'>
         | Pick<InvalidCredentialsError, 'errorCode' | 'message'>
+        | Pick<PasswordValidationError, 'errorCode' | 'message'>
         | Pick<NativeAuthStrategyError, 'errorCode' | 'message'>;
 };
 
@@ -3627,6 +3674,10 @@ export namespace Register {
         NonNullable<RegisterMutation['registerCustomerAccount']>,
         { __typename?: 'ErrorResult' }
     >;
+    export type PasswordValidationErrorInlineFragment = DiscriminateUnion<
+        NonNullable<RegisterMutation['registerCustomerAccount']>,
+        { __typename?: 'PasswordValidationError' }
+    >;
 }
 
 export namespace CurrentUserShop {
@@ -3642,6 +3693,10 @@ export namespace Verify {
         NonNullable<VerifyMutation['verifyCustomerAccount']>,
         { __typename?: 'ErrorResult' }
     >;
+    export type PasswordValidationErrorInlineFragment = DiscriminateUnion<
+        NonNullable<VerifyMutation['verifyCustomerAccount']>,
+        { __typename?: 'PasswordValidationError' }
+    >;
 }
 
 export namespace RefreshToken {
@@ -3682,6 +3737,10 @@ export namespace ResetPassword {
         NonNullable<ResetPasswordMutation['resetPassword']>,
         { __typename?: 'ErrorResult' }
     >;
+    export type PasswordValidationErrorInlineFragment = DiscriminateUnion<
+        NonNullable<ResetPasswordMutation['resetPassword']>,
+        { __typename?: 'PasswordValidationError' }
+    >;
 }
 
 export namespace RequestUpdateEmailAddress {

+ 9 - 0
packages/core/e2e/graphql/shop-definitions.ts

@@ -154,6 +154,9 @@ export const REGISTER_ACCOUNT = gql`
                 errorCode
                 message
             }
+            ... on PasswordValidationError {
+                validationErrorMessage
+            }
         }
     }
 `;
@@ -178,6 +181,9 @@ export const VERIFY_EMAIL = gql`
                 errorCode
                 message
             }
+            ... on PasswordValidationError {
+                validationErrorMessage
+            }
         }
     }
     ${CURRENT_USER_FRAGMENT}
@@ -217,6 +223,9 @@ export const RESET_PASSWORD = gql`
                 errorCode
                 message
             }
+            ... on PasswordValidationError {
+                validationErrorMessage
+            }
         }
     }
     ${CURRENT_USER_FRAGMENT}

+ 74 - 0
packages/core/e2e/shop-auth.e2e-spec.ts

@@ -10,6 +10,8 @@ import {
     IdentifierChangeRequestEvent,
     mergeConfig,
     PasswordResetEvent,
+    PasswordValidationStrategy,
+    RequestContext,
     VendurePlugin,
 } from '@vendure/core';
 import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
@@ -19,6 +21,7 @@ import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { PasswordValidationError } from '../src/common/error/generated-graphql-shop-errors';
 
 import {
     CreateAdministrator,
@@ -94,10 +97,25 @@ const currentUserErrorGuard: ErrorResultGuard<CurrentUserShopFragment> = createE
     input => input.identifier != null,
 );
 
+class TestPasswordValidationStrategy implements PasswordValidationStrategy {
+    validate(ctx: RequestContext, password: string): boolean | string {
+        if (password.length < 8) {
+            return 'Password must be more than 8 characters';
+        }
+        if (password === '12345678') {
+            return `Don't use 12345678!`;
+        }
+        return true;
+    }
+}
+
 describe('Shop auth & accounts', () => {
     const { server, adminClient, shopClient } = createTestEnvironment(
         mergeConfig(testConfig(), {
             plugins: [TestEmailPlugin as any],
+            authOptions: {
+                passwordValidationStrategy: new TestPasswordValidationStrategy(),
+            },
         }),
     );
 
@@ -276,6 +294,23 @@ describe('Shop auth & accounts', () => {
             expect(verifyCustomerAccount.errorCode).toBe(ErrorCode.MISSING_PASSWORD_ERROR);
         });
 
+        it('verification fails with invalid password', async () => {
+            const { verifyCustomerAccount } = await shopClient.query<Verify.Mutation, Verify.Variables>(
+                VERIFY_EMAIL,
+                {
+                    token: verificationToken,
+                    password: '2short',
+                },
+            );
+            currentUserErrorGuard.assertErrorResult(verifyCustomerAccount);
+
+            expect(verifyCustomerAccount.message).toBe(`Password is invalid`);
+            expect((verifyCustomerAccount as PasswordValidationError).validationErrorMessage).toBe(
+                `Password must be more than 8 characters`,
+            );
+            expect(verifyCustomerAccount.errorCode).toBe(ErrorCode.PASSWORD_VALIDATION_ERROR);
+        });
+
         it('verification succeeds with password and correct token', async () => {
             const { verifyCustomerAccount } = await shopClient.query<Verify.Mutation, Verify.Variables>(
                 VERIFY_EMAIL,
@@ -361,6 +396,28 @@ describe('Shop auth & accounts', () => {
         const emailAddress = 'test2@test.com';
         let verificationToken: string;
 
+        it('registerCustomerAccount fails with invalid password', async () => {
+            const input: RegisterCustomerInput = {
+                firstName: 'Lu',
+                lastName: 'Tester',
+                phoneNumber: '443324',
+                emailAddress,
+                password: '12345678',
+            };
+            const { registerCustomerAccount } = await shopClient.query<Register.Mutation, Register.Variables>(
+                REGISTER_ACCOUNT,
+                {
+                    input,
+                },
+            );
+            successErrorGuard.assertErrorResult(registerCustomerAccount);
+            expect(registerCustomerAccount.errorCode).toBe(ErrorCode.PASSWORD_VALIDATION_ERROR);
+            expect(registerCustomerAccount.message).toBe(`Password is invalid`);
+            expect((registerCustomerAccount as PasswordValidationError).validationErrorMessage).toBe(
+                `Don't use 12345678!`,
+            );
+        });
+
         it('register a new account with password', async () => {
             const verificationTokenPromise = getVerificationTokenPromise();
             const input: RegisterCustomerInput = {
@@ -497,6 +554,23 @@ describe('Shop auth & accounts', () => {
             expect(resetPassword.errorCode).toBe(ErrorCode.PASSWORD_RESET_TOKEN_INVALID_ERROR);
         });
 
+        it('resetPassword fails with invalid password', async () => {
+            const { resetPassword } = await shopClient.query<ResetPassword.Mutation, ResetPassword.Variables>(
+                RESET_PASSWORD,
+                {
+                    token: passwordResetToken,
+                    password: '2short',
+                },
+            );
+            currentUserErrorGuard.assertErrorResult(resetPassword);
+
+            expect(resetPassword.message).toBe(`Password is invalid`);
+            expect((resetPassword as PasswordValidationError).validationErrorMessage).toBe(
+                `Password must be more than 8 characters`,
+            );
+            expect(resetPassword.errorCode).toBe(ErrorCode.PASSWORD_VALIDATION_ERROR);
+        });
+
         it('resetPassword works with valid token', async () => {
             const { resetPassword } = await shopClient.query<ResetPassword.Mutation, ResetPassword.Variables>(
                 RESET_PASSWORD,

+ 30 - 16
packages/core/src/api/config/graphql-custom-fields.ts

@@ -63,7 +63,7 @@ export function addGraphQLCustomFields(
             if (customEntityFields.length) {
                 customFieldTypeDefs += `
                     type ${entityName}CustomFields {
-                        ${mapToFields(customEntityFields, getGraphQlType)}
+                        ${mapToFields(customEntityFields, wrapListType(getGraphQlType))}
                     }
 
                     extend type ${entityName} {
@@ -82,7 +82,7 @@ export function addGraphQLCustomFields(
         if (localeStringFields.length && schema.getType(`${entityName}Translation`)) {
             customFieldTypeDefs += `
                     type ${entityName}TranslationCustomFields {
-                         ${mapToFields(localeStringFields, getGraphQlType)}
+                         ${mapToFields(localeStringFields, wrapListType(getGraphQlType))}
                     }
 
                     extend type ${entityName}Translation {
@@ -97,7 +97,7 @@ export function addGraphQLCustomFields(
                     input Create${entityName}CustomFieldsInput {
                        ${mapToFields(
                            writeableNonLocaleStringFields,
-                           getGraphQlInputType,
+                           wrapListType(getGraphQlInputType),
                            getGraphQlInputName,
                        )}
                     }
@@ -121,7 +121,7 @@ export function addGraphQLCustomFields(
                     input Update${entityName}CustomFieldsInput {
                        ${mapToFields(
                            writeableNonLocaleStringFields,
-                           getGraphQlInputType,
+                           wrapListType(getGraphQlInputType),
                            getGraphQlInputName,
                        )}
                     }
@@ -139,10 +139,13 @@ export function addGraphQLCustomFields(
             }
         }
 
-        if (customEntityFields.length && schema.getType(`${entityName}SortParameter`)) {
+        const customEntityNonListFields = customEntityFields.filter(f => f.list !== true);
+        if (customEntityNonListFields.length && schema.getType(`${entityName}SortParameter`)) {
+            // Sorting list fields makes no sense, so we only add "sort" fields
+            // to non-list fields.
             customFieldTypeDefs += `
                     extend input ${entityName}SortParameter {
-                         ${mapToFields(customEntityFields, () => 'SortOrder')}
+                         ${mapToFields(customEntityNonListFields, () => 'SortOrder')}
                     }
                 `;
         }
@@ -166,7 +169,7 @@ export function addGraphQLCustomFields(
                     if (writeableLocaleStringFields.length) {
                         customFieldTypeDefs += `
                             input ${inputName}CustomFields {
-                                ${mapToFields(writeableLocaleStringFields, getGraphQlType)}
+                                ${mapToFields(writeableLocaleStringFields, wrapListType(getGraphQlType))}
                             }
 
                             extend input ${inputName} {
@@ -275,7 +278,7 @@ export function addRegisterCustomerCustomFieldsInput(
     }
     const customFieldTypeDefs = `
         input RegisterCustomerCustomFieldsInput {
-            ${mapToFields(publicWritableCustomFields, getGraphQlInputType, getGraphQlInputName)}
+            ${mapToFields(publicWritableCustomFields, wrapListType(getGraphQlInputType), getGraphQlInputName)}
         }
 
         extend input RegisterCustomerInput {
@@ -444,13 +447,12 @@ function mapToFields(
 ): string {
     const res = fieldDefs
         .map(field => {
-            const primitiveType = typeFn(field);
-            if (!primitiveType) {
+            const type = typeFn(field);
+            if (!type) {
                 return;
             }
-            const finalType = field.list ? `[${primitiveType}!]` : primitiveType;
             const name = nameFn ? nameFn(field) : field.name;
-            return `${name}: ${finalType}`;
+            return `${name}: ${type}`;
         })
         .filter(x => x != null);
     return res.join('\n');
@@ -459,16 +461,16 @@ function mapToFields(
 function getFilterOperator(config: CustomFieldConfig): string | undefined {
     switch (config.type) {
         case 'datetime':
-            return 'DateOperators';
+            return config.list ? 'DateListOperators' : 'DateOperators';
         case 'string':
         case 'localeString':
         case 'text':
-            return 'StringOperators';
+            return config.list ? 'StringListOperators' : 'StringOperators';
         case 'boolean':
-            return 'BooleanOperators';
+            return config.list ? 'BooleanListOperators' : 'BooleanOperators';
         case 'int':
         case 'float':
-            return 'NumberOperators';
+            return config.list ? 'NumberListOperators' : 'NumberOperators';
         case 'relation':
             return undefined;
         default:
@@ -481,6 +483,18 @@ function getGraphQlInputType(config: CustomFieldConfig): string {
     return config.type === 'relation' ? `ID` : getGraphQlType(config);
 }
 
+function wrapListType(
+    getTypeFn: (def: CustomFieldConfig) => string | undefined,
+): (def: CustomFieldConfig) => string | undefined {
+    return (def: CustomFieldConfig) => {
+        const type = getTypeFn(def);
+        if (!type) {
+            return;
+        }
+        return def.list ? `[${type}!]` : type;
+    };
+}
+
 function getGraphQlType(config: CustomFieldConfig): string {
     switch (config.type) {
         case 'string':

+ 7 - 0
packages/core/src/api/schema/shop-api/shop-error-results.graphql

@@ -78,6 +78,13 @@ type MissingPasswordError implements ErrorResult {
     message: String!
 }
 
+"Returned when attempting to register or verify a customer account where the given password fails password validation."
+type PasswordValidationError implements ErrorResult {
+    errorCode: ErrorCode!
+    message: String!
+    validationErrorMessage: String!
+}
+
 "Returned when attempting to verify a customer account with a password, when a password has already been set."
 type PasswordAlreadySetError implements ErrorResult {
     errorCode: ErrorCode!

+ 4 - 2
packages/core/src/api/schema/shop-api/shop.api.graphql

@@ -213,16 +213,17 @@ union AddPaymentToOrderResult =
     | NoActiveOrderError
 union TransitionOrderToStateResult = Order | OrderStateTransitionError
 union SetCustomerForOrderResult = Order | AlreadyLoggedInError | EmailAddressConflictError | NoActiveOrderError
-union RegisterCustomerAccountResult = Success | MissingPasswordError | NativeAuthStrategyError
+union RegisterCustomerAccountResult = Success | MissingPasswordError | PasswordValidationError | NativeAuthStrategyError
 union RefreshCustomerVerificationResult = Success | NativeAuthStrategyError
 union VerifyCustomerAccountResult =
       CurrentUser
     | VerificationTokenInvalidError
     | VerificationTokenExpiredError
     | MissingPasswordError
+    | PasswordValidationError
     | PasswordAlreadySetError
     | NativeAuthStrategyError
-union UpdateCustomerPasswordResult = Success | InvalidCredentialsError | NativeAuthStrategyError
+union UpdateCustomerPasswordResult = Success | InvalidCredentialsError | PasswordValidationError | NativeAuthStrategyError
 union RequestUpdateCustomerEmailAddressResult =
       Success
     | InvalidCredentialsError
@@ -238,6 +239,7 @@ union ResetPasswordResult =
       CurrentUser
     | PasswordResetTokenInvalidError
     | PasswordResetTokenExpiredError
+    | PasswordValidationError
     | NativeAuthStrategyError
     | NotVerifiedError
 union NativeAuthenticationResult =

+ 12 - 1
packages/core/src/common/error/generated-graphql-shop-errors.ts

@@ -260,6 +260,17 @@ export class PasswordResetTokenInvalidError extends ErrorResult {
   }
 }
 
+export class PasswordValidationError extends ErrorResult {
+  readonly __typename = 'PasswordValidationError';
+  readonly errorCode = 'PASSWORD_VALIDATION_ERROR' as any;
+  readonly message = 'PASSWORD_VALIDATION_ERROR';
+  constructor(
+    public validationErrorMessage: Scalars['String'],
+  ) {
+    super();
+  }
+}
+
 export class PaymentDeclinedError extends ErrorResult {
   readonly __typename = 'PaymentDeclinedError';
   readonly errorCode = 'PAYMENT_DECLINED_ERROR' as any;
@@ -303,7 +314,7 @@ export class VerificationTokenInvalidError extends ErrorResult {
 }
 
 
-const errorTypeNames = new Set(['AlreadyLoggedInError', 'CouponCodeExpiredError', 'CouponCodeInvalidError', 'CouponCodeLimitError', 'EmailAddressConflictError', 'IdentifierChangeTokenExpiredError', 'IdentifierChangeTokenInvalidError', 'IneligiblePaymentMethodError', 'IneligibleShippingMethodError', 'InsufficientStockError', 'InvalidCredentialsError', 'MissingPasswordError', 'NativeAuthStrategyError', 'NegativeQuantityError', 'NoActiveOrderError', 'NotVerifiedError', 'OrderLimitError', 'OrderModificationError', 'OrderPaymentStateError', 'OrderStateTransitionError', 'PasswordAlreadySetError', 'PasswordResetTokenExpiredError', 'PasswordResetTokenInvalidError', 'PaymentDeclinedError', 'PaymentFailedError', 'VerificationTokenExpiredError', 'VerificationTokenInvalidError']);
+const errorTypeNames = new Set(['AlreadyLoggedInError', 'CouponCodeExpiredError', 'CouponCodeInvalidError', 'CouponCodeLimitError', 'EmailAddressConflictError', 'IdentifierChangeTokenExpiredError', 'IdentifierChangeTokenInvalidError', 'IneligiblePaymentMethodError', 'IneligibleShippingMethodError', 'InsufficientStockError', 'InvalidCredentialsError', 'MissingPasswordError', 'NativeAuthStrategyError', 'NegativeQuantityError', 'NoActiveOrderError', 'NotVerifiedError', 'OrderLimitError', 'OrderModificationError', 'OrderPaymentStateError', 'OrderStateTransitionError', 'PasswordAlreadySetError', 'PasswordResetTokenExpiredError', 'PasswordResetTokenInvalidError', 'PasswordValidationError', 'PaymentDeclinedError', 'PaymentFailedError', 'VerificationTokenExpiredError', 'VerificationTokenInvalidError']);
 function isGraphQLError(input: any): input is import('@vendure/common/lib/generated-types').ErrorResult {
   return input instanceof ErrorResult || errorTypeNames.has(input.__typename);
 }

+ 35 - 0
packages/core/src/config/auth/default-password-validation-strategy.ts

@@ -0,0 +1,35 @@
+import { RequestContext } from '../../api/index';
+
+import { PasswordValidationStrategy } from './password-validation-strategy';
+
+/**
+ * @description
+ * The DefaultPasswordValidationStrategy allows you to specify a minimum length and/or
+ * a regular expression to match passwords against.
+ *
+ * TODO:
+ * By default, the `minLength` will be set to `4`. This is rather permissive and is only
+ * this way in order to reduce the risk of backward-compatibility breaks. In the next major version
+ * this default will be made more strict.
+ *
+ * @docsCategory auth
+ * @since 1.5.0
+ */
+export class DefaultPasswordValidationStrategy implements PasswordValidationStrategy {
+    constructor(private options: { minLength?: number; regexp?: RegExp }) {}
+
+    validate(ctx: RequestContext, password: string): boolean | string {
+        const { minLength, regexp } = this.options;
+        if (minLength != null) {
+            if (password.length < minLength) {
+                return false;
+            }
+        }
+        if (regexp != null) {
+            if (!regexp.test(password)) {
+                return false;
+            }
+        }
+        return true;
+    }
+}

+ 23 - 0
packages/core/src/config/auth/password-validation-strategy.ts

@@ -0,0 +1,23 @@
+import { RequestContext } from '../../api/index';
+import { InjectableStrategy } from '../../common/types/injectable-strategy';
+
+/**
+ * @description
+ * Defines validation to apply to new password (when creating an account or updating an existing account's
+ * password when using the {@link NativeAuthenticationStrategy}.
+ *
+ * @docsCategory auth
+ * @since 1.5.0
+ */
+export interface PasswordValidationStrategy extends InjectableStrategy {
+    /**
+     * @description
+     * Validates a password submitted during account registration or when a customer updates their password.
+     * The method should resolve to `true` if the password is acceptable. If not, it should return `false` or
+     * optionally a string which will be passed to the returned ErrorResult, which can e.g. advise on why
+     * exactly the proposed password is not valid.
+     *
+     * @since 1.5.0
+     */
+    validate(ctx: RequestContext, password: string): Promise<boolean | string> | boolean | string;
+}

+ 2 - 0
packages/core/src/config/config.module.ts

@@ -65,6 +65,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
             shopAuthenticationStrategy,
             sessionCacheStrategy,
             passwordHashingStrategy,
+            passwordValidationStrategy,
         } = this.configService.authOptions;
         const { taxZoneStrategy } = this.configService.taxOptions;
         const { jobQueueStrategy, jobBufferStorageStrategy } = this.configService.jobQueueOptions;
@@ -86,6 +87,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
             ...shopAuthenticationStrategy,
             sessionCacheStrategy,
             passwordHashingStrategy,
+            passwordValidationStrategy,
             assetNamingStrategy,
             assetPreviewStrategy,
             assetStorageStrategy,

+ 2 - 0
packages/core/src/config/default-config.ts

@@ -12,6 +12,7 @@ import { DefaultAssetNamingStrategy } from './asset-naming-strategy/default-asse
 import { NoAssetPreviewStrategy } from './asset-preview-strategy/no-asset-preview-strategy';
 import { NoAssetStorageStrategy } from './asset-storage-strategy/no-asset-storage-strategy';
 import { BcryptPasswordHashingStrategy } from './auth/bcrypt-password-hashing-strategy';
+import { DefaultPasswordValidationStrategy } from './auth/default-password-validation-strategy';
 import { NativeAuthenticationStrategy } from './auth/native-authentication-strategy';
 import { defaultCollectionFilters } from './catalog/default-collection-filters';
 import { DefaultProductVariantPriceCalculationStrategy } from './catalog/default-product-variant-price-calculation-strategy';
@@ -87,6 +88,7 @@ export const defaultConfig: RuntimeVendureConfig = {
         adminAuthenticationStrategy: [new NativeAuthenticationStrategy()],
         customPermissions: [],
         passwordHashingStrategy: new BcryptPasswordHashingStrategy(),
+        passwordValidationStrategy: new DefaultPasswordValidationStrategy({ minLength: 4 }),
     },
     catalogOptions: {
         collectionFilters: defaultCollectionFilters,

+ 4 - 0
packages/core/src/config/index.ts

@@ -3,7 +3,11 @@ export * from './asset-naming-strategy/default-asset-naming-strategy';
 export * from './asset-preview-strategy/asset-preview-strategy';
 export * from './asset-storage-strategy/asset-storage-strategy';
 export * from './auth/authentication-strategy';
+export * from './auth/bcrypt-password-hashing-strategy';
+export * from './auth/default-password-validation-strategy';
 export * from './auth/native-authentication-strategy';
+export * from './auth/password-hashing-strategy';
+export * from './auth/password-validation-strategy';
 export * from './catalog/collection-filter';
 export * from './catalog/default-collection-filters';
 export * from './config.module';

+ 23 - 2
packages/core/src/config/vendure-config.ts

@@ -1,8 +1,7 @@
-import { DynamicModule, NestMiddleware, Type } from '@nestjs/common';
+import { DynamicModule, Type } from '@nestjs/common';
 import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { PluginDefinition } from 'apollo-server-core';
-import { RequestHandler } from 'express';
 import { ValidationContext } from 'graphql';
 import { ConnectionOptions } from 'typeorm';
 
@@ -15,6 +14,7 @@ import { AssetPreviewStrategy } from './asset-preview-strategy/asset-preview-str
 import { AssetStorageStrategy } from './asset-storage-strategy/asset-storage-strategy';
 import { AuthenticationStrategy } from './auth/authentication-strategy';
 import { PasswordHashingStrategy } from './auth/password-hashing-strategy';
+import { PasswordValidationStrategy } from './auth/password-validation-strategy';
 import { CollectionFilter } from './catalog/collection-filter';
 import { ProductVariantPriceCalculationStrategy } from './catalog/product-variant-price-calculation-strategy';
 import { StockDisplayStrategy } from './catalog/stock-display-strategy';
@@ -400,6 +400,27 @@ export interface AuthOptions {
      * @since 1.3.0
      */
     passwordHashingStrategy?: PasswordHashingStrategy;
+    /**
+     * @description
+     * Allows you to set a custom policy for passwords when using the {@link NativeAuthenticationStrategy}.
+     * By default, it uses the {@link DefaultPasswordValidationStrategy}, which will impose a minimum length
+     * of four characters. To improve security for production, you are encouraged to specify a more strict
+     * policy, which you can do like this:
+     *
+     * @example
+     * ```ts
+     * {
+     *   passwordValidationStrategy: new DefaultPasswordValidationStrategy({
+     *     // Minimum eight characters, at least one letter and one number
+     *     regexp: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/,
+     *   }),
+     * }
+     * ```
+     *
+     * @since 1.5.0
+     * @default DefaultPasswordValidationStrategy
+     */
+    passwordValidationStrategy?: PasswordValidationStrategy;
 }
 
 /**

+ 1 - 0
packages/core/src/i18n/messages/en.json

@@ -80,6 +80,7 @@
     "PASSWORD_ALREADY_SET_ERROR": "A password has already been set during registration",
     "PASSWORD_RESET_TOKEN_EXPIRED_ERROR": "Password reset token has expired",
     "PASSWORD_RESET_TOKEN_INVALID_ERROR": "Password reset token not recognized",
+    "PASSWORD_VALIDATION_ERROR": "Password is invalid",
     "PAYMENT_DECLINED_ERROR": "The payment was declined",
     "PAYMENT_FAILED_ERROR": "The payment failed",
     "PAYMENT_ORDER_MISMATCH_ERROR": "The Payment and OrderLines do not belong to the same Order",

+ 22 - 5
packages/core/src/service/services/customer.service.ts

@@ -32,6 +32,7 @@ import {
     MissingPasswordError,
     PasswordResetTokenExpiredError,
     PasswordResetTokenInvalidError,
+    PasswordValidationError,
 } from '../../common/error/generated-graphql-shop-errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { assertFound, idsAreEqual, normalizeEmailAddress } from '../../common/utils';
@@ -233,7 +234,11 @@ export class CustomerService {
             // Not sure when this situation would occur
             return new EmailAddressConflictAdminError();
         }
-        customer.user = await this.userService.createCustomerUser(ctx, input.emailAddress, password);
+        const customerUser = await this.userService.createCustomerUser(ctx, input.emailAddress, password);
+        if (isGraphQlErrorResult(customerUser)) {
+            throw customerUser;
+        }
+        customer.user = customerUser;
 
         if (password && password !== '') {
             const verificationToken = customer.user.getNativeAuthenticationMethod().verificationToken;
@@ -352,7 +357,7 @@ export class CustomerService {
     async registerCustomerAccount(
         ctx: RequestContext,
         input: RegisterCustomerInput,
-    ): Promise<RegisterCustomerAccountResult | EmailAddressConflictError> {
+    ): Promise<RegisterCustomerAccountResult | EmailAddressConflictError | PasswordValidationError> {
         if (!this.configService.authOptions.requireVerification) {
             if (!input.password) {
                 return new MissingPasswordError();
@@ -390,19 +395,29 @@ export class CustomerService {
             },
         });
         if (!user) {
-            user = await this.userService.createCustomerUser(
+            const customerUser = await this.userService.createCustomerUser(
                 ctx,
                 input.emailAddress,
                 input.password || undefined,
             );
+            if (isGraphQlErrorResult(customerUser)) {
+                return customerUser;
+            } else {
+                user = customerUser;
+            }
         }
         if (!hasNativeAuthMethod) {
-            user = await this.userService.addNativeAuthenticationMethod(
+            const addAuthenticationResult = await this.userService.addNativeAuthenticationMethod(
                 ctx,
                 user,
                 input.emailAddress,
                 input.password || undefined,
             );
+            if (isGraphQlErrorResult(addAuthenticationResult)) {
+                return addAuthenticationResult;
+            } else {
+                user = addAuthenticationResult;
+            }
         }
         if (!user.verified) {
             user = await this.userService.setVerificationToken(ctx, user);
@@ -504,7 +519,9 @@ export class CustomerService {
         ctx: RequestContext,
         passwordResetToken: string,
         password: string,
-    ): Promise<User | PasswordResetTokenExpiredError | PasswordResetTokenInvalidError> {
+    ): Promise<
+        User | PasswordResetTokenExpiredError | PasswordResetTokenInvalidError | PasswordValidationError
+    > {
         const result = await this.userService.resetPasswordByToken(ctx, passwordResetToken, password);
         if (isGraphQlErrorResult(result)) {
             return result;

+ 45 - 7
packages/core/src/service/services/user.service.ts

@@ -3,7 +3,7 @@ import { VerifyCustomerAccountResult } from '@vendure/common/lib/generated-shop-
 import { ID } from '@vendure/common/lib/shared-types';
 
 import { RequestContext } from '../../api/common/request-context';
-import { ErrorResultUnion } from '../../common/error/error-result';
+import { ErrorResultUnion, isGraphQlErrorResult } from '../../common/error/error-result';
 import { EntityNotFoundError, InternalServerError } from '../../common/error/errors';
 import {
     IdentifierChangeTokenExpiredError,
@@ -13,6 +13,7 @@ import {
     PasswordAlreadySetError,
     PasswordResetTokenExpiredError,
     PasswordResetTokenInvalidError,
+    PasswordValidationError,
     VerificationTokenExpiredError,
     VerificationTokenInvalidError,
 } from '../../common/error/generated-graphql-shop-errors';
@@ -61,14 +62,20 @@ export class UserService {
      * @description
      * Creates a new User with the special `customer` Role and using the {@link NativeAuthenticationStrategy}.
      */
-    async createCustomerUser(ctx: RequestContext, identifier: string, password?: string): Promise<User> {
+    async createCustomerUser(
+        ctx: RequestContext,
+        identifier: string,
+        password?: string,
+    ): Promise<User | PasswordValidationError> {
         const user = new User();
         user.identifier = identifier;
         const customerRole = await this.roleService.getCustomerRole();
         user.roles = [customerRole];
-        return this.connection
-            .getRepository(ctx, User)
-            .save(await this.addNativeAuthenticationMethod(ctx, user, identifier, password));
+        const addNativeAuthResult = await this.addNativeAuthenticationMethod(ctx, user, identifier, password);
+        if (isGraphQlErrorResult(addNativeAuthResult)) {
+            return addNativeAuthResult;
+        }
+        return this.connection.getRepository(ctx, User).save(addNativeAuthResult);
     }
 
     /**
@@ -82,7 +89,7 @@ export class UserService {
         user: User,
         identifier: string,
         password?: string,
-    ): Promise<User> {
+    ): Promise<User | PasswordValidationError> {
         const checkUser = user.id != null && (await this.getUserById(ctx, user.id));
         if (checkUser) {
             if (
@@ -103,6 +110,10 @@ export class UserService {
             user.verified = true;
         }
         if (password) {
+            const passwordValidationResult = await this.validatePassword(ctx, password);
+            if (passwordValidationResult !== true) {
+                return passwordValidationResult;
+            }
             authenticationMethod.passwordHash = await this.passwordCipher.hash(password);
         } else {
             authenticationMethod.passwordHash = '';
@@ -182,6 +193,10 @@ export class UserService {
                     if (!!nativeAuthMethod.passwordHash) {
                         return new PasswordAlreadySetError();
                     }
+                    const passwordValidationResult = await this.validatePassword(ctx, password);
+                    if (passwordValidationResult !== true) {
+                        return passwordValidationResult;
+                    }
                     nativeAuthMethod.passwordHash = await this.passwordCipher.hash(password);
                 }
                 nativeAuthMethod.verificationToken = null;
@@ -223,7 +238,9 @@ export class UserService {
         ctx: RequestContext,
         passwordResetToken: string,
         password: string,
-    ): Promise<User | PasswordResetTokenExpiredError | PasswordResetTokenInvalidError> {
+    ): Promise<
+        User | PasswordResetTokenExpiredError | PasswordResetTokenInvalidError | PasswordValidationError
+    > {
         const user = await this.connection
             .getRepository(ctx, User)
             .createQueryBuilder('user')
@@ -233,6 +250,10 @@ export class UserService {
         if (!user) {
             return new PasswordResetTokenInvalidError();
         }
+        const passwordValidationResult = await this.validatePassword(ctx, password);
+        if (passwordValidationResult !== true) {
+            return passwordValidationResult;
+        }
         if (this.verificationTokenGenerator.verifyVerificationToken(passwordResetToken)) {
             const nativeAuthMethod = user.getNativeAuthenticationMethod();
             nativeAuthMethod.passwordHash = await this.passwordCipher.hash(password);
@@ -359,4 +380,21 @@ export class UserService {
             .save(nativeAuthMethod, { reload: false });
         return true;
     }
+
+    private async validatePassword(
+        ctx: RequestContext,
+        password: string,
+    ): Promise<true | PasswordValidationError> {
+        const passwordValidationResult =
+            await this.configService.authOptions.passwordValidationStrategy.validate(ctx, password);
+        if (passwordValidationResult !== true) {
+            const message =
+                typeof passwordValidationResult === 'string'
+                    ? passwordValidationResult
+                    : 'Password is invalid';
+            return new PasswordValidationError(message);
+        } else {
+            return true;
+        }
+    }
 }

+ 25 - 0
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -274,6 +274,11 @@ export type BooleanCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']>;
 };
 
+/** Operators for filtering on a list of Boolean fields */
+export type BooleanListOperators = {
+    inList: Scalars['Boolean'];
+};
+
 /** Operators for filtering on a Boolean field */
 export type BooleanOperators = {
     eq?: Maybe<Scalars['Boolean']>;
@@ -1269,6 +1274,11 @@ export type CustomerSortParameter = {
     emailAddress?: Maybe<SortOrder>;
 };
 
+/** Operators for filtering on a list of Date fields */
+export type DateListOperators = {
+    inList: Scalars['DateTime'];
+};
+
 /** Operators for filtering on a DateTime field */
 export type DateOperators = {
     eq?: Maybe<Scalars['DateTime']>;
@@ -1627,6 +1637,11 @@ export enum HistoryEntryType {
     ORDER_MODIFIED = 'ORDER_MODIFIED',
 }
 
+/** Operators for filtering on a list of ID fields */
+export type IdListOperators = {
+    inList: Scalars['ID'];
+};
+
 /** Operators for filtering on an ID field */
 export type IdOperators = {
     eq?: Maybe<Scalars['String']>;
@@ -2886,6 +2901,11 @@ export type NothingToRefundError = ErrorResult & {
     message: Scalars['String'];
 };
 
+/** Operators for filtering on a list of Number fields */
+export type NumberListOperators = {
+    inList: Scalars['Float'];
+};
+
 /** Operators for filtering on a Int or Float field */
 export type NumberOperators = {
     eq?: Maybe<Scalars['Float']>;
@@ -4462,6 +4482,11 @@ export type StringFieldOption = {
     label?: Maybe<Array<LocalizedString>>;
 };
 
+/** Operators for filtering on a list of String fields */
+export type StringListOperators = {
+    inList: Scalars['String'];
+};
+
 /** Operators for filtering on a String field */
 export type StringOperators = {
     eq?: Maybe<Scalars['String']>;

+ 25 - 0
packages/payments-plugin/e2e/graphql/generated-admin-types.ts

@@ -274,6 +274,11 @@ export type BooleanCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']>;
 };
 
+/** Operators for filtering on a list of Boolean fields */
+export type BooleanListOperators = {
+    inList: Scalars['Boolean'];
+};
+
 /** Operators for filtering on a Boolean field */
 export type BooleanOperators = {
     eq?: Maybe<Scalars['Boolean']>;
@@ -1269,6 +1274,11 @@ export type CustomerSortParameter = {
     emailAddress?: Maybe<SortOrder>;
 };
 
+/** Operators for filtering on a list of Date fields */
+export type DateListOperators = {
+    inList: Scalars['DateTime'];
+};
+
 /** Operators for filtering on a DateTime field */
 export type DateOperators = {
     eq?: Maybe<Scalars['DateTime']>;
@@ -1627,6 +1637,11 @@ export enum HistoryEntryType {
     ORDER_MODIFIED = 'ORDER_MODIFIED',
 }
 
+/** Operators for filtering on a list of ID fields */
+export type IdListOperators = {
+    inList: Scalars['ID'];
+};
+
 /** Operators for filtering on an ID field */
 export type IdOperators = {
     eq?: Maybe<Scalars['String']>;
@@ -2886,6 +2901,11 @@ export type NothingToRefundError = ErrorResult & {
     message: Scalars['String'];
 };
 
+/** Operators for filtering on a list of Number fields */
+export type NumberListOperators = {
+    inList: Scalars['Float'];
+};
+
 /** Operators for filtering on a Int or Float field */
 export type NumberOperators = {
     eq?: Maybe<Scalars['Float']>;
@@ -4462,6 +4482,11 @@ export type StringFieldOption = {
     label?: Maybe<Array<LocalizedString>>;
 };
 
+/** Operators for filtering on a list of String fields */
+export type StringListOperators = {
+    inList: Scalars['String'];
+};
+
 /** Operators for filtering on a String field */
 export type StringOperators = {
     eq?: Maybe<Scalars['String']>;

+ 45 - 2
packages/payments-plugin/e2e/graphql/generated-shop-types.ts

@@ -124,6 +124,11 @@ export type BooleanCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']>;
 };
 
+/** Operators for filtering on a list of Boolean fields */
+export type BooleanListOperators = {
+    inList: Scalars['Boolean'];
+};
+
 /** Operators for filtering on a Boolean field */
 export type BooleanOperators = {
     eq?: Maybe<Scalars['Boolean']>;
@@ -775,6 +780,11 @@ export type CustomerSortParameter = {
     emailAddress?: Maybe<SortOrder>;
 };
 
+/** Operators for filtering on a list of Date fields */
+export type DateListOperators = {
+    inList: Scalars['DateTime'];
+};
+
 /** Operators for filtering on a DateTime field */
 export type DateOperators = {
     eq?: Maybe<Scalars['DateTime']>;
@@ -853,6 +863,7 @@ export enum ErrorCode {
     COUPON_CODE_LIMIT_ERROR = 'COUPON_CODE_LIMIT_ERROR',
     ALREADY_LOGGED_IN_ERROR = 'ALREADY_LOGGED_IN_ERROR',
     MISSING_PASSWORD_ERROR = 'MISSING_PASSWORD_ERROR',
+    PASSWORD_VALIDATION_ERROR = 'PASSWORD_VALIDATION_ERROR',
     PASSWORD_ALREADY_SET_ERROR = 'PASSWORD_ALREADY_SET_ERROR',
     VERIFICATION_TOKEN_INVALID_ERROR = 'VERIFICATION_TOKEN_INVALID_ERROR',
     VERIFICATION_TOKEN_EXPIRED_ERROR = 'VERIFICATION_TOKEN_EXPIRED_ERROR',
@@ -1064,6 +1075,11 @@ export enum HistoryEntryType {
     ORDER_MODIFIED = 'ORDER_MODIFIED',
 }
 
+/** Operators for filtering on a list of ID fields */
+export type IdListOperators = {
+    inList: Scalars['ID'];
+};
+
 /** Operators for filtering on an ID field */
 export type IdOperators = {
     eq?: Maybe<Scalars['String']>;
@@ -1734,6 +1750,11 @@ export type NotVerifiedError = ErrorResult & {
     message: Scalars['String'];
 };
 
+/** Operators for filtering on a list of Number fields */
+export type NumberListOperators = {
+    inList: Scalars['Float'];
+};
+
 /** Operators for filtering on a Int or Float field */
 export type NumberOperators = {
     eq?: Maybe<Scalars['Float']>;
@@ -2043,6 +2064,13 @@ export type PasswordResetTokenInvalidError = ErrorResult & {
     message: Scalars['String'];
 };
 
+/** Returned when attempting to register or verify a customer account where the given password fails password validation. */
+export type PasswordValidationError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    validationErrorMessage: Scalars['String'];
+};
+
 export type Payment = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
@@ -2605,7 +2633,11 @@ export type Refund = Node & {
     metadata?: Maybe<Scalars['JSON']>;
 };
 
-export type RegisterCustomerAccountResult = Success | MissingPasswordError | NativeAuthStrategyError;
+export type RegisterCustomerAccountResult =
+    | Success
+    | MissingPasswordError
+    | PasswordValidationError
+    | NativeAuthStrategyError;
 
 export type RegisterCustomerInput = {
     emailAddress: Scalars['String'];
@@ -2644,6 +2676,7 @@ export type ResetPasswordResult =
     | CurrentUser
     | PasswordResetTokenInvalidError
     | PasswordResetTokenExpiredError
+    | PasswordValidationError
     | NativeAuthStrategyError
     | NotVerifiedError;
 
@@ -2812,6 +2845,11 @@ export type StringFieldOption = {
     label?: Maybe<Array<LocalizedString>>;
 };
 
+/** Operators for filtering on a list of String fields */
+export type StringListOperators = {
+    inList: Scalars['String'];
+};
+
 /** Operators for filtering on a String field */
 export type StringOperators = {
     eq?: Maybe<Scalars['String']>;
@@ -2928,7 +2966,11 @@ export type UpdateCustomerInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
-export type UpdateCustomerPasswordResult = Success | InvalidCredentialsError | NativeAuthStrategyError;
+export type UpdateCustomerPasswordResult =
+    | Success
+    | InvalidCredentialsError
+    | PasswordValidationError
+    | NativeAuthStrategyError;
 
 export type UpdateOrderInput = {
     customFields?: Maybe<Scalars['JSON']>;
@@ -2976,6 +3018,7 @@ export type VerifyCustomerAccountResult =
     | VerificationTokenInvalidError
     | VerificationTokenExpiredError
     | MissingPasswordError
+    | PasswordValidationError
     | PasswordAlreadySetError
     | NativeAuthStrategyError;
 

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
schema-admin.json


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
schema-shop.json


Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio