@drax/identity-back 3.13.0 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/dist/config/PasswordPolicyConfig.js +14 -0
  2. package/dist/controllers/UserApiKeyController.js +8 -119
  3. package/dist/controllers/UserController.js +10 -0
  4. package/dist/factory/PasswordPolicyResolverFactory.js +9 -0
  5. package/dist/factory/PasswordPolicyServiceFactory.js +27 -0
  6. package/dist/factory/UserPasswordHistoryServiceFactory.js +25 -0
  7. package/dist/factory/UserServiceFactory.js +3 -1
  8. package/dist/graphql/resolvers/user-api-key.resolvers.js +2 -3
  9. package/dist/index.js +20 -8
  10. package/dist/interfaces/IUserPasswordHistory.js +1 -0
  11. package/dist/interfaces/IUserPasswordHistoryRepository.js +1 -0
  12. package/dist/middleware/apiKeyMiddleware.js +1 -1
  13. package/dist/middleware/rbacMiddleware.js +1 -1
  14. package/dist/models/UserPasswordHistoryModel.js +30 -0
  15. package/dist/permissions/UserApiKeyPermissions.js +1 -2
  16. package/dist/policies/defaultPasswordPolicy.js +12 -0
  17. package/dist/repository/mongo/UserPasswordHistoryMongoRepository.js +20 -0
  18. package/dist/repository/sqlite/UserPasswordHistorySqliteRepository.js +38 -0
  19. package/dist/resolver/PasswordPolicyResolver.js +27 -0
  20. package/dist/routes/UserRoutes.js +10 -0
  21. package/dist/schemas/PasswordPolicySchema.js +18 -0
  22. package/dist/schemas/RegisterSchema.js +1 -3
  23. package/dist/schemas/UserSchema.js +1 -3
  24. package/dist/security/constants/defaultPasswordPolicy.js +12 -0
  25. package/dist/security/interfaces/IPasswordPolicy.js +1 -0
  26. package/dist/security/interfaces/IPasswordPolicyProjectContext.js +1 -0
  27. package/dist/security/schemas/PasswordPolicySchema.js +18 -0
  28. package/dist/security/services/PasswordPolicyResolver.js +21 -0
  29. package/dist/security/services/PasswordPolicyService.js +147 -0
  30. package/dist/security/utils/PasswordPolicySchemaFactory.js +36 -0
  31. package/dist/security/utils/getPasswordEnvPolicy.js +19 -0
  32. package/dist/services/PasswordPolicyService.js +147 -0
  33. package/dist/services/UserPasswordHistoryService.js +18 -0
  34. package/dist/services/UserService.js +34 -9
  35. package/dist/setup/LoadIdentityConfigFromEnv.js +10 -0
  36. package/dist/setup/SetProjectPasswordPolicy.js +7 -0
  37. package/dist/utils/PasswordPolicySchemaFactory.js +36 -0
  38. package/dist/utils/getPasswordEnvPolicy.js +19 -0
  39. package/docs/password-policy.md +33 -0
  40. package/package.json +6 -6
  41. package/src/config/PasswordPolicyConfig.ts +14 -0
  42. package/src/controllers/UserApiKeyController.ts +15 -129
  43. package/src/controllers/UserController.ts +10 -1
  44. package/src/factory/PasswordPolicyResolverFactory.ts +14 -0
  45. package/src/factory/PasswordPolicyServiceFactory.ts +38 -0
  46. package/src/factory/UserPasswordHistoryServiceFactory.ts +31 -0
  47. package/src/factory/UserServiceFactory.ts +7 -1
  48. package/src/graphql/resolvers/user-api-key.resolvers.ts +2 -3
  49. package/src/index.ts +28 -3
  50. package/src/interfaces/IUserPasswordHistory.ts +21 -0
  51. package/src/interfaces/IUserPasswordHistoryRepository.ts +8 -0
  52. package/src/middleware/apiKeyMiddleware.ts +1 -1
  53. package/src/middleware/rbacMiddleware.ts +1 -1
  54. package/src/models/UserPasswordHistoryModel.ts +42 -0
  55. package/src/permissions/UserApiKeyPermissions.ts +1 -2
  56. package/src/policies/defaultPasswordPolicy.ts +17 -0
  57. package/src/repository/mongo/UserPasswordHistoryMongoRepository.ts +25 -0
  58. package/src/repository/sqlite/UserPasswordHistorySqliteRepository.ts +47 -0
  59. package/src/resolver/PasswordPolicyResolver.ts +33 -0
  60. package/src/routes/UserRoutes.ts +11 -0
  61. package/src/schemas/PasswordPolicySchema.ts +29 -0
  62. package/src/schemas/RegisterSchema.ts +1 -3
  63. package/src/schemas/UserSchema.ts +1 -3
  64. package/src/services/PasswordPolicyService.ts +184 -0
  65. package/src/services/UserPasswordHistoryService.ts +23 -0
  66. package/src/services/UserService.ts +38 -9
  67. package/src/setup/LoadIdentityConfigFromEnv.ts +11 -0
  68. package/src/setup/SetProjectPasswordPolicy.ts +12 -0
  69. package/src/utils/PasswordPolicySchemaFactory.ts +47 -0
  70. package/src/utils/getPasswordEnvPolicy.ts +25 -0
  71. package/test/data-obj/users/root-mongo-user.ts +1 -1
  72. package/test/data-obj/users/root-sqlite-user.ts +1 -1
  73. package/test/endpoints/data/users-data.ts +3 -3
  74. package/test/endpoints/password-policy-route.test.ts +33 -0
  75. package/test/endpoints/user-route.test.ts +17 -4
  76. package/test/security/password-policy-resolver.test.ts +55 -0
  77. package/test/security/password-policy-schema-factory.test.ts +40 -0
  78. package/test/services/user-service.test.ts +218 -31
  79. package/test/setup/TestSetup.ts +22 -4
  80. package/test/setup/data/basic-user.ts +1 -1
  81. package/test/setup/data/root-user.ts +1 -1
  82. package/tsconfig.tsbuildinfo +1 -1
  83. package/types/config/PasswordPolicyConfig.d.ts +14 -0
  84. package/types/config/PasswordPolicyConfig.d.ts.map +1 -0
  85. package/types/controllers/UserApiKeyController.d.ts +10 -6
  86. package/types/controllers/UserApiKeyController.d.ts.map +1 -1
  87. package/types/controllers/UserController.d.ts +1 -0
  88. package/types/controllers/UserController.d.ts.map +1 -1
  89. package/types/factory/PasswordPolicyResolverFactory.d.ts +4 -0
  90. package/types/factory/PasswordPolicyResolverFactory.d.ts.map +1 -0
  91. package/types/factory/PasswordPolicyServiceFactory.d.ts +4 -0
  92. package/types/factory/PasswordPolicyServiceFactory.d.ts.map +1 -0
  93. package/types/factory/UserPasswordHistoryServiceFactory.d.ts +4 -0
  94. package/types/factory/UserPasswordHistoryServiceFactory.d.ts.map +1 -0
  95. package/types/factory/UserServiceFactory.d.ts.map +1 -1
  96. package/types/graphql/resolvers/user-api-key.resolvers.d.ts.map +1 -1
  97. package/types/index.d.ts +15 -2
  98. package/types/index.d.ts.map +1 -1
  99. package/types/interfaces/IUserPasswordHistory.d.ts +17 -0
  100. package/types/interfaces/IUserPasswordHistory.d.ts.map +1 -0
  101. package/types/interfaces/IUserPasswordHistoryRepository.d.ts +7 -0
  102. package/types/interfaces/IUserPasswordHistoryRepository.d.ts.map +1 -0
  103. package/types/models/UserPasswordHistoryModel.d.ts +15 -0
  104. package/types/models/UserPasswordHistoryModel.d.ts.map +1 -0
  105. package/types/permissions/UserApiKeyPermissions.d.ts +1 -2
  106. package/types/permissions/UserApiKeyPermissions.d.ts.map +1 -1
  107. package/types/permissions/index.d.ts +0 -2
  108. package/types/permissions/index.d.ts.map +1 -1
  109. package/types/policies/defaultPasswordPolicy.d.ts +4 -0
  110. package/types/policies/defaultPasswordPolicy.d.ts.map +1 -0
  111. package/types/repository/mongo/UserPasswordHistoryMongoRepository.d.ts +10 -0
  112. package/types/repository/mongo/UserPasswordHistoryMongoRepository.d.ts.map +1 -0
  113. package/types/repository/sqlite/UserPasswordHistorySqliteRepository.d.ts +25 -0
  114. package/types/repository/sqlite/UserPasswordHistorySqliteRepository.d.ts.map +1 -0
  115. package/types/resolver/PasswordPolicyResolver.d.ts +10 -0
  116. package/types/resolver/PasswordPolicyResolver.d.ts.map +1 -0
  117. package/types/routes/UserRoutes.d.ts.map +1 -1
  118. package/types/schemas/PasswordPolicySchema.d.ts +25 -0
  119. package/types/schemas/PasswordPolicySchema.d.ts.map +1 -0
  120. package/types/schemas/RegisterSchema.d.ts.map +1 -1
  121. package/types/schemas/UserSchema.d.ts.map +1 -1
  122. package/types/security/constants/defaultPasswordPolicy.d.ts +4 -0
  123. package/types/security/constants/defaultPasswordPolicy.d.ts.map +1 -0
  124. package/types/security/interfaces/IPasswordPolicy.d.ts +13 -0
  125. package/types/security/interfaces/IPasswordPolicy.d.ts.map +1 -0
  126. package/types/security/interfaces/IPasswordPolicyProjectContext.d.ts +6 -0
  127. package/types/security/interfaces/IPasswordPolicyProjectContext.d.ts.map +1 -0
  128. package/types/security/schemas/PasswordPolicySchema.d.ts +25 -0
  129. package/types/security/schemas/PasswordPolicySchema.d.ts.map +1 -0
  130. package/types/security/services/PasswordPolicyResolver.d.ts +9 -0
  131. package/types/security/services/PasswordPolicyResolver.d.ts.map +1 -0
  132. package/types/security/services/PasswordPolicyService.d.ts +35 -0
  133. package/types/security/services/PasswordPolicyService.d.ts.map +1 -0
  134. package/types/security/utils/PasswordPolicySchemaFactory.d.ts +9 -0
  135. package/types/security/utils/PasswordPolicySchemaFactory.d.ts.map +1 -0
  136. package/types/security/utils/getPasswordEnvPolicy.d.ts +5 -0
  137. package/types/security/utils/getPasswordEnvPolicy.d.ts.map +1 -0
  138. package/types/services/PasswordPolicyService.d.ts +34 -0
  139. package/types/services/PasswordPolicyService.d.ts.map +1 -0
  140. package/types/services/UserPasswordHistoryService.d.ts +10 -0
  141. package/types/services/UserPasswordHistoryService.d.ts.map +1 -0
  142. package/types/services/UserService.d.ts +5 -1
  143. package/types/services/UserService.d.ts.map +1 -1
  144. package/types/setup/LoadIdentityConfigFromEnv.d.ts.map +1 -1
  145. package/types/setup/SetProjectPasswordPolicy.d.ts +5 -0
  146. package/types/setup/SetProjectPasswordPolicy.d.ts.map +1 -0
  147. package/types/utils/PasswordPolicySchemaFactory.d.ts +9 -0
  148. package/types/utils/PasswordPolicySchemaFactory.d.ts.map +1 -0
  149. package/types/utils/getPasswordEnvPolicy.d.ts +5 -0
  150. package/types/utils/getPasswordEnvPolicy.d.ts.map +1 -0
@@ -0,0 +1,18 @@
1
+ import z from "zod";
2
+ const PasswordPolicySchemaBase = z.object({
3
+ minLength: z.number().int().min(1),
4
+ maxLength: z.number().int().min(1),
5
+ requireUppercase: z.boolean(),
6
+ requireLowercase: z.boolean(),
7
+ requireNumber: z.boolean(),
8
+ requireSpecialChar: z.boolean(),
9
+ disallowSpaces: z.boolean(),
10
+ preventReuse: z.number().int().min(0),
11
+ expirationDays: z.number().int().min(1).nullable(),
12
+ });
13
+ const PasswordPolicySchema = PasswordPolicySchemaBase.refine((policy) => policy.maxLength >= policy.minLength, {
14
+ message: "validation.password.maxLength",
15
+ path: ["maxLength"]
16
+ });
17
+ const PartialPasswordPolicySchema = PasswordPolicySchemaBase.partial();
18
+ export { PasswordPolicySchema, PartialPasswordPolicySchema };
@@ -0,0 +1,21 @@
1
+ import { defaultPasswordPolicy } from "../constants/defaultPasswordPolicy.js";
2
+ import { PartialPasswordPolicySchema, PasswordPolicySchema } from "../schemas/PasswordPolicySchema.js";
3
+ import getPasswordEnvPolicy from "../utils/getPasswordEnvPolicy.js";
4
+ class PasswordPolicyResolver {
5
+ async resolve(projectContext) {
6
+ const projectPolicy = await this.getProjectPolicy(projectContext);
7
+ const envPolicy = getPasswordEnvPolicy();
8
+ return PasswordPolicySchema.parse({
9
+ ...defaultPasswordPolicy,
10
+ ...projectPolicy,
11
+ ...envPolicy
12
+ });
13
+ }
14
+ async getProjectPolicy(projectContext) {
15
+ return projectContext?.projectPolicy
16
+ ? PartialPasswordPolicySchema.parse(projectContext.projectPolicy)
17
+ : {};
18
+ }
19
+ }
20
+ export default PasswordPolicyResolver;
21
+ export { PasswordPolicyResolver };
@@ -0,0 +1,147 @@
1
+ import { randomInt } from "crypto";
2
+ import { ZodError } from "zod";
3
+ import { ValidationError, ZodErrorToValidationError } from "@drax/common-back";
4
+ import AuthUtils from "../../utils/AuthUtils.js";
5
+ import PasswordPolicySchemaFactory from "../utils/PasswordPolicySchemaFactory.js";
6
+ class PasswordPolicyService {
7
+ constructor(resolver, userRepository, userPasswordHistoryService) {
8
+ this.resolver = resolver;
9
+ this.userRepository = userRepository;
10
+ this.userPasswordHistoryService = userPasswordHistoryService;
11
+ }
12
+ async getFinalPolicy(projectContext) {
13
+ return this.resolver.resolve(projectContext);
14
+ }
15
+ async getPasswordSchema(projectContext) {
16
+ const policy = await this.getFinalPolicy(projectContext);
17
+ return PasswordPolicySchemaFactory.create(policy);
18
+ }
19
+ async validatePassword(password, projectContext, options) {
20
+ const field = options?.field || "password";
21
+ try {
22
+ const schema = await this.getPasswordSchema(projectContext);
23
+ await schema.parseAsync(password);
24
+ }
25
+ catch (e) {
26
+ if (e instanceof ZodError) {
27
+ const validationError = ZodErrorToValidationError(e, { [field]: password });
28
+ validationError.errors = validationError.errors.map((error) => ({ ...error, field }));
29
+ throw validationError;
30
+ }
31
+ throw e;
32
+ }
33
+ if (options?.userId) {
34
+ await this.validateBusinessRules(password, options.userId, projectContext, {
35
+ field,
36
+ currentPasswordHash: options.currentPasswordHash
37
+ });
38
+ }
39
+ }
40
+ async generateCompatiblePassword(projectContext) {
41
+ const policy = await this.getFinalPolicy(projectContext);
42
+ const uppercaseChars = "ABCDEFGHJKLMNPQRSTUVWXYZ";
43
+ const lowercaseChars = "abcdefghijkmnopqrstuvwxyz";
44
+ const numericChars = "23456789";
45
+ const specialChars = "!@#$%&*_-+=";
46
+ const fallbackSpecial = policy.disallowSpaces ? "!" : " ";
47
+ const combinedChars = [
48
+ uppercaseChars,
49
+ lowercaseChars,
50
+ numericChars,
51
+ policy.requireSpecialChar ? specialChars : "",
52
+ policy.disallowSpaces ? "" : " "
53
+ ].join("") || `${uppercaseChars}${lowercaseChars}${numericChars}${fallbackSpecial}`;
54
+ const chars = [];
55
+ if (policy.requireUppercase) {
56
+ chars.push(this.randomChar(uppercaseChars));
57
+ }
58
+ if (policy.requireLowercase) {
59
+ chars.push(this.randomChar(lowercaseChars));
60
+ }
61
+ if (policy.requireNumber) {
62
+ chars.push(this.randomChar(numericChars));
63
+ }
64
+ if (policy.requireSpecialChar) {
65
+ chars.push(this.randomChar(specialChars));
66
+ }
67
+ if (!chars.length) {
68
+ chars.push(this.randomChar(`${uppercaseChars}${lowercaseChars}${numericChars}`));
69
+ }
70
+ while (chars.length < policy.minLength) {
71
+ chars.push(this.randomChar(combinedChars));
72
+ }
73
+ const password = this.shuffle(chars).join("").slice(0, policy.maxLength);
74
+ await this.validatePassword(password, projectContext);
75
+ return password;
76
+ }
77
+ async getPasswordStatus(user, projectContext) {
78
+ const policy = await this.getFinalPolicy(projectContext);
79
+ if (!policy.expirationDays) {
80
+ return { expired: false, expiresAt: null };
81
+ }
82
+ const lastPasswordChange = await this.getLastPasswordChangeDate(user);
83
+ if (!lastPasswordChange) {
84
+ return { expired: false, expiresAt: null };
85
+ }
86
+ const expiresAt = new Date(lastPasswordChange);
87
+ expiresAt.setDate(expiresAt.getDate() + policy.expirationDays);
88
+ return {
89
+ expired: expiresAt.getTime() <= Date.now(),
90
+ expiresAt
91
+ };
92
+ }
93
+ async recordPassword(userId, passwordHash) {
94
+ await this.userPasswordHistoryService?.create(userId, passwordHash);
95
+ }
96
+ async validateBusinessRules(password, userId, projectContext, options) {
97
+ const policy = await this.getFinalPolicy(projectContext);
98
+ if (!policy.preventReuse) {
99
+ return;
100
+ }
101
+ const field = options?.field || "password";
102
+ const recentHashes = await this.getRecentPasswordHashes(userId, policy.preventReuse, options?.currentPasswordHash);
103
+ const reused = recentHashes.some((item) => AuthUtils.checkPassword(password, item.passwordHash));
104
+ if (reused) {
105
+ throw new ValidationError([{ field, reason: "validation.password.preventReuse" }]);
106
+ }
107
+ }
108
+ async getRecentPasswordHashes(userId, limit, currentPasswordHash) {
109
+ const recent = await this.userPasswordHistoryService?.findLatestByUserId(userId, limit) || [];
110
+ const hashes = [...recent];
111
+ if (currentPasswordHash && !hashes.some((item) => item.passwordHash === currentPasswordHash)) {
112
+ hashes.unshift({ user: userId, passwordHash: currentPasswordHash });
113
+ }
114
+ else if (!currentPasswordHash && this.userRepository) {
115
+ const user = await this.userRepository.findByIdWithPassword(userId);
116
+ if (user?.password && !hashes.some((item) => item.passwordHash === user.password)) {
117
+ hashes.unshift({ user: userId, passwordHash: user.password });
118
+ }
119
+ }
120
+ return hashes.slice(0, limit);
121
+ }
122
+ async getLastPasswordChangeDate(user) {
123
+ if (!user?._id || !this.userPasswordHistoryService) {
124
+ return user?.updatedAt ? new Date(user.updatedAt) : user?.createdAt ? new Date(user.createdAt) : null;
125
+ }
126
+ const latest = await this.userPasswordHistoryService.findLatestByUserId(user._id.toString(), 1);
127
+ if (latest[0]?.createdAt) {
128
+ return new Date(latest[0].createdAt);
129
+ }
130
+ return user?.updatedAt ? new Date(user.updatedAt) : user?.createdAt ? new Date(user.createdAt) : null;
131
+ }
132
+ randomChar(source) {
133
+ return source[randomInt(0, source.length)];
134
+ }
135
+ shuffle(chars) {
136
+ const items = [...chars];
137
+ for (let i = items.length - 1; i > 0; i -= 1) {
138
+ const j = randomInt(0, i + 1);
139
+ const temp = items[i];
140
+ items[i] = items[j];
141
+ items[j] = temp;
142
+ }
143
+ return items;
144
+ }
145
+ }
146
+ export default PasswordPolicyService;
147
+ export { PasswordPolicyService };
@@ -0,0 +1,36 @@
1
+ import z from "zod";
2
+ class PasswordPolicySchemaFactory {
3
+ static create(policy) {
4
+ const cacheKey = JSON.stringify(policy);
5
+ const cached = this.cache.get(cacheKey);
6
+ if (cached) {
7
+ return cached;
8
+ }
9
+ let schema = z.string({ error: "validation.required" })
10
+ .min(1, "validation.required")
11
+ .min(policy.minLength, "validation.password.minLength")
12
+ .max(policy.maxLength, "validation.password.maxLength");
13
+ if (policy.requireUppercase) {
14
+ schema = schema.regex(/[A-Z]/, "validation.password.requireUppercase");
15
+ }
16
+ if (policy.requireLowercase) {
17
+ schema = schema.regex(/[a-z]/, "validation.password.requireLowercase");
18
+ }
19
+ if (policy.requireNumber) {
20
+ schema = schema.regex(/[0-9]/, "validation.password.requireNumber");
21
+ }
22
+ if (policy.requireSpecialChar) {
23
+ schema = schema.regex(/[^A-Za-z0-9]/, "validation.password.requireSpecialChar");
24
+ }
25
+ if (policy.disallowSpaces) {
26
+ schema = schema.refine((value) => !/\s/.test(value), {
27
+ message: "validation.password.disallowSpaces"
28
+ });
29
+ }
30
+ this.cache.set(cacheKey, schema);
31
+ return schema;
32
+ }
33
+ }
34
+ PasswordPolicySchemaFactory.cache = new Map();
35
+ export default PasswordPolicySchemaFactory;
36
+ export { PasswordPolicySchemaFactory };
@@ -0,0 +1,19 @@
1
+ import { DraxConfig } from "@drax/common-back";
2
+ import PasswordPolicyConfig from "../../config/PasswordPolicyConfig.js";
3
+ import { PartialPasswordPolicySchema } from "../schemas/PasswordPolicySchema.js";
4
+ function getPasswordEnvPolicy() {
5
+ const envPolicy = {
6
+ minLength: DraxConfig.getOrLoad(PasswordPolicyConfig.MinLength, "number"),
7
+ maxLength: DraxConfig.getOrLoad(PasswordPolicyConfig.MaxLength, "number"),
8
+ requireUppercase: DraxConfig.getOrLoad(PasswordPolicyConfig.RequireUppercase, "boolean"),
9
+ requireLowercase: DraxConfig.getOrLoad(PasswordPolicyConfig.RequireLowercase, "boolean"),
10
+ requireNumber: DraxConfig.getOrLoad(PasswordPolicyConfig.RequireNumber, "boolean"),
11
+ requireSpecialChar: DraxConfig.getOrLoad(PasswordPolicyConfig.RequireSpecialChar, "boolean"),
12
+ disallowSpaces: DraxConfig.getOrLoad(PasswordPolicyConfig.DisallowSpaces, "boolean"),
13
+ preventReuse: DraxConfig.getOrLoad(PasswordPolicyConfig.PreventReuse, "number"),
14
+ expirationDays: DraxConfig.getOrLoad(PasswordPolicyConfig.ExpirationDays, "number")
15
+ };
16
+ return PartialPasswordPolicySchema.parse(Object.fromEntries(Object.entries(envPolicy).filter(([, value]) => value !== undefined)));
17
+ }
18
+ export default getPasswordEnvPolicy;
19
+ export { getPasswordEnvPolicy };
@@ -0,0 +1,147 @@
1
+ import { randomInt } from "crypto";
2
+ import { ZodError } from "zod";
3
+ import { ValidationError, ZodErrorToValidationError } from "@drax/common-back";
4
+ import AuthUtils from "../utils/AuthUtils.js";
5
+ import PasswordPolicySchemaFactory from "../utils/PasswordPolicySchemaFactory.js";
6
+ class PasswordPolicyService {
7
+ constructor(resolver, userRepository, userPasswordHistoryService) {
8
+ this.resolver = resolver;
9
+ this.userRepository = userRepository;
10
+ this.userPasswordHistoryService = userPasswordHistoryService;
11
+ }
12
+ async getFinalPolicy() {
13
+ return this.resolver.resolve();
14
+ }
15
+ async getPasswordSchema() {
16
+ const policy = await this.getFinalPolicy();
17
+ return PasswordPolicySchemaFactory.create(policy);
18
+ }
19
+ async validatePassword(password, options) {
20
+ const field = options?.field || "password";
21
+ try {
22
+ const schema = await this.getPasswordSchema();
23
+ await schema.parseAsync(password);
24
+ }
25
+ catch (e) {
26
+ if (e instanceof ZodError) {
27
+ const validationError = ZodErrorToValidationError(e, { [field]: password });
28
+ validationError.errors = validationError.errors.map((error) => ({ ...error, field }));
29
+ throw validationError;
30
+ }
31
+ throw e;
32
+ }
33
+ if (options?.userId) {
34
+ await this.validateReuse(password, options.userId, {
35
+ field,
36
+ currentPasswordHash: options.currentPasswordHash
37
+ });
38
+ }
39
+ }
40
+ async generateCompatiblePassword() {
41
+ const policy = await this.getFinalPolicy();
42
+ const uppercaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
43
+ const lowercaseChars = "abcdefghijkmnopqrstuvwxyz";
44
+ const numericChars = "0123456789";
45
+ const specialChars = "!@#$%&*_-+=";
46
+ const fallbackSpecial = policy.disallowSpaces ? "!" : " ";
47
+ const combinedChars = [
48
+ uppercaseChars,
49
+ lowercaseChars,
50
+ numericChars,
51
+ policy.requireSpecialChar ? specialChars : "",
52
+ policy.disallowSpaces ? "" : " "
53
+ ].join("") || `${uppercaseChars}${lowercaseChars}${numericChars}${fallbackSpecial}`;
54
+ const chars = [];
55
+ if (policy.requireUppercase) {
56
+ chars.push(this.randomChar(uppercaseChars));
57
+ }
58
+ if (policy.requireLowercase) {
59
+ chars.push(this.randomChar(lowercaseChars));
60
+ }
61
+ if (policy.requireNumber) {
62
+ chars.push(this.randomChar(numericChars));
63
+ }
64
+ if (policy.requireSpecialChar) {
65
+ chars.push(this.randomChar(specialChars));
66
+ }
67
+ if (!chars.length) {
68
+ chars.push(this.randomChar(`${uppercaseChars}${lowercaseChars}${numericChars}`));
69
+ }
70
+ while (chars.length < policy.minLength) {
71
+ chars.push(this.randomChar(combinedChars));
72
+ }
73
+ const password = this.shuffle(chars).join("").slice(0, policy.maxLength);
74
+ await this.validatePassword(password);
75
+ return password;
76
+ }
77
+ async getPasswordExpiration(user) {
78
+ const policy = await this.getFinalPolicy();
79
+ if (!policy.expirationDays) {
80
+ return { expired: false, expiresAt: null };
81
+ }
82
+ const lastPasswordChange = await this.getLastPasswordChangeDate(user);
83
+ if (!lastPasswordChange) {
84
+ return { expired: false, expiresAt: null };
85
+ }
86
+ const expiresAt = new Date(lastPasswordChange);
87
+ expiresAt.setDate(expiresAt.getDate() + policy.expirationDays);
88
+ return {
89
+ expired: expiresAt.getTime() <= Date.now(),
90
+ expiresAt
91
+ };
92
+ }
93
+ async recordPassword(userId, passwordHash) {
94
+ await this.userPasswordHistoryService?.create(userId, passwordHash);
95
+ }
96
+ async validateReuse(password, userId, options) {
97
+ const policy = await this.getFinalPolicy();
98
+ if (!policy.preventReuse) {
99
+ return;
100
+ }
101
+ const field = options?.field || "password";
102
+ const recentHashes = await this.getRecentPasswordHashes(userId, policy.preventReuse, options?.currentPasswordHash);
103
+ const reused = recentHashes.some((item) => AuthUtils.checkPassword(password, item.passwordHash));
104
+ if (reused) {
105
+ throw new ValidationError([{ field, reason: "validation.password.preventReuse" }]);
106
+ }
107
+ }
108
+ async getRecentPasswordHashes(userId, limit, currentPasswordHash) {
109
+ const recent = await this.userPasswordHistoryService?.findLatestByUserId(userId, limit) || [];
110
+ const hashes = [...recent];
111
+ if (currentPasswordHash && !hashes.some((item) => item.passwordHash === currentPasswordHash)) {
112
+ hashes.unshift({ user: userId, passwordHash: currentPasswordHash });
113
+ }
114
+ else if (!currentPasswordHash && this.userRepository) {
115
+ const user = await this.userRepository.findByIdWithPassword(userId);
116
+ if (user?.password && !hashes.some((item) => item.passwordHash === user.password)) {
117
+ hashes.unshift({ user: userId, passwordHash: user.password });
118
+ }
119
+ }
120
+ return hashes.slice(0, limit);
121
+ }
122
+ async getLastPasswordChangeDate(user) {
123
+ if (!user?._id || !this.userPasswordHistoryService) {
124
+ return user?.updatedAt ? new Date(user.updatedAt) : user?.createdAt ? new Date(user.createdAt) : null;
125
+ }
126
+ const latest = await this.userPasswordHistoryService.findLatestByUserId(user._id.toString(), 1);
127
+ if (latest[0]?.createdAt) {
128
+ return new Date(latest[0].createdAt);
129
+ }
130
+ return user?.updatedAt ? new Date(user.updatedAt) : user?.createdAt ? new Date(user.createdAt) : null;
131
+ }
132
+ randomChar(source) {
133
+ return source[randomInt(0, source.length)];
134
+ }
135
+ shuffle(chars) {
136
+ const items = [...chars];
137
+ for (let i = items.length - 1; i > 0; i -= 1) {
138
+ const j = randomInt(0, i + 1);
139
+ const temp = items[i];
140
+ items[i] = items[j];
141
+ items[j] = temp;
142
+ }
143
+ return items;
144
+ }
145
+ }
146
+ export default PasswordPolicyService;
147
+ export { PasswordPolicyService };
@@ -0,0 +1,18 @@
1
+ class UserPasswordHistoryService {
2
+ constructor(repository) {
3
+ this.repository = repository;
4
+ }
5
+ async create(userId, passwordHash) {
6
+ return this.repository.create({
7
+ user: userId,
8
+ passwordHash
9
+ });
10
+ }
11
+ async findLatestByUserId(userId, limit) {
12
+ if (limit <= 0) {
13
+ return [];
14
+ }
15
+ return this.repository.findLatestByUserId(userId, limit);
16
+ }
17
+ }
18
+ export default UserPasswordHistoryService;
@@ -7,9 +7,13 @@ import { AbstractService } from "@drax/crud-back";
7
7
  import { randomUUID } from "crypto";
8
8
  import UserLoginFailServiceFactory from "../factory/UserLoginFailServiceFactory.js";
9
9
  import UserSessionServiceFactory from "../factory/UserSessionServiceFactory.js";
10
+ import PasswordPolicyServiceFactory from "../factory/PasswordPolicyServiceFactory.js";
11
+ import UserPasswordHistoryServiceFactory from "../factory/UserPasswordHistoryServiceFactory.js";
10
12
  class UserService extends AbstractService {
11
- constructor(userRepository) {
13
+ constructor(userRepository, passwordPolicyService = PasswordPolicyServiceFactory(), userPasswordHistoryService = UserPasswordHistoryServiceFactory()) {
12
14
  super(userRepository, UserBaseSchema);
15
+ this.passwordPolicyService = passwordPolicyService;
16
+ this.userPasswordHistoryService = userPasswordHistoryService;
13
17
  this._repository = userRepository;
14
18
  }
15
19
  async auth(username, password, { userAgent, ip }) {
@@ -60,7 +64,7 @@ class UserService extends AbstractService {
60
64
  console.log("auth email", email);
61
65
  user = await this.findByEmail(email);
62
66
  if (!user && createIfNotFound) {
63
- userData.password = userData.password ? userData.password : randomUUID();
67
+ userData.password = userData.password ? userData.password : await this.passwordPolicyService.generateCompatiblePassword();
64
68
  userData.active = userData.active === undefined ? true : userData.active;
65
69
  user = await this.create(userData);
66
70
  }
@@ -85,8 +89,14 @@ class UserService extends AbstractService {
85
89
  async changeUserPassword(userId, newPassword) {
86
90
  const user = await this._repository.findByIdWithPassword(userId);
87
91
  if (user) {
88
- newPassword = AuthUtils.hashPassword(newPassword);
89
- await this._repository.changePassword(userId, newPassword);
92
+ await this.passwordPolicyService.validatePassword(newPassword, {
93
+ field: 'newPassword',
94
+ userId,
95
+ currentPasswordHash: user.password
96
+ });
97
+ const newPasswordHash = AuthUtils.hashPassword(newPassword);
98
+ await this._repository.changePassword(userId, newPasswordHash);
99
+ await this.userPasswordHistoryService.create(userId, newPasswordHash);
90
100
  delete user.password;
91
101
  return user;
92
102
  }
@@ -101,8 +111,14 @@ class UserService extends AbstractService {
101
111
  throw new ValidationError([{ field: 'newPassword', reason: 'validation.password.currentDifferent' }]);
102
112
  }
103
113
  if (AuthUtils.checkPassword(currentPassword, user.password)) {
104
- newPassword = AuthUtils.hashPassword(newPassword);
105
- await this._repository.changePassword(userId, newPassword);
114
+ await this.passwordPolicyService.validatePassword(newPassword, {
115
+ field: 'newPassword',
116
+ userId,
117
+ currentPasswordHash: user.password
118
+ });
119
+ const newPasswordHash = AuthUtils.hashPassword(newPassword);
120
+ await this._repository.changePassword(userId, newPasswordHash);
121
+ await this.userPasswordHistoryService.create(userId, newPasswordHash);
106
122
  delete user.password;
107
123
  return user;
108
124
  }
@@ -145,8 +161,14 @@ class UserService extends AbstractService {
145
161
  try {
146
162
  const user = await this._repository.findByRecoveryCode(recoveryCode);
147
163
  if (user && user.active) {
148
- newPassword = AuthUtils.hashPassword(newPassword);
149
- await this._repository.changePassword(user._id, newPassword);
164
+ await this.passwordPolicyService.validatePassword(newPassword, {
165
+ field: 'newPassword',
166
+ userId: user._id.toString(),
167
+ currentPasswordHash: user.password
168
+ });
169
+ const newPasswordHash = AuthUtils.hashPassword(newPassword);
170
+ await this._repository.changePassword(user._id, newPasswordHash);
171
+ await this.userPasswordHistoryService.create(user._id.toString(), newPasswordHash);
150
172
  await this._repository.updatePartial(user._id, { recoveryCode: null });
151
173
  return user;
152
174
  }
@@ -210,8 +232,11 @@ class UserService extends AbstractService {
210
232
  userData.password = userData?.password.trim();
211
233
  userData.tenant = userData.tenant === "" ? null : userData.tenant;
212
234
  await UserCreateSchema.parseAsync(userData);
213
- userData.password = AuthUtils.hashPassword(userData.password.trim());
235
+ await this.passwordPolicyService.validatePassword(userData.password);
236
+ const passwordHash = AuthUtils.hashPassword(userData.password.trim());
237
+ userData.password = passwordHash;
214
238
  const user = await this._repository.create(userData);
239
+ await this.userPasswordHistoryService.create(user._id.toString(), passwordHash);
215
240
  return user;
216
241
  }
217
242
  catch (e) {
@@ -1,5 +1,6 @@
1
1
  import { DraxConfig } from "@drax/common-back";
2
2
  import IdentityConfig from "../config/IdentityConfig.js";
3
+ import PasswordPolicyConfig from "../config/PasswordPolicyConfig.js";
3
4
  function LoadIdentityConfigFromEnv() {
4
5
  DraxConfig.set(IdentityConfig.JwtSecret, process.env[IdentityConfig.JwtSecret]);
5
6
  DraxConfig.set(IdentityConfig.JwtExpiration, process.env[IdentityConfig.JwtExpiration]);
@@ -7,6 +8,15 @@ function LoadIdentityConfigFromEnv() {
7
8
  DraxConfig.set(IdentityConfig.ApiKeySecret, process.env[IdentityConfig.ApiKeySecret]);
8
9
  DraxConfig.set(IdentityConfig.RbacCacheTTL, process.env[IdentityConfig.RbacCacheTTL]);
9
10
  DraxConfig.set(IdentityConfig.AvatarDir, process.env[IdentityConfig.AvatarDir]);
11
+ DraxConfig.set(PasswordPolicyConfig.MinLength, process.env[PasswordPolicyConfig.MinLength]);
12
+ DraxConfig.set(PasswordPolicyConfig.MaxLength, process.env[PasswordPolicyConfig.MaxLength]);
13
+ DraxConfig.set(PasswordPolicyConfig.RequireUppercase, process.env[PasswordPolicyConfig.RequireUppercase]);
14
+ DraxConfig.set(PasswordPolicyConfig.RequireLowercase, process.env[PasswordPolicyConfig.RequireLowercase]);
15
+ DraxConfig.set(PasswordPolicyConfig.RequireNumber, process.env[PasswordPolicyConfig.RequireNumber]);
16
+ DraxConfig.set(PasswordPolicyConfig.RequireSpecialChar, process.env[PasswordPolicyConfig.RequireSpecialChar]);
17
+ DraxConfig.set(PasswordPolicyConfig.DisallowSpaces, process.env[PasswordPolicyConfig.DisallowSpaces]);
18
+ DraxConfig.set(PasswordPolicyConfig.PreventReuse, process.env[PasswordPolicyConfig.PreventReuse]);
19
+ DraxConfig.set(PasswordPolicyConfig.ExpirationDays, process.env[PasswordPolicyConfig.ExpirationDays]);
10
20
  }
11
21
  export default LoadIdentityConfigFromEnv;
12
22
  export { LoadIdentityConfigFromEnv };
@@ -0,0 +1,7 @@
1
+ import PasswordPolicyResolverFactory from "../factory/PasswordPolicyResolverFactory.js";
2
+ function SetProjectPasswordPolicy(projectPasswordPolicy) {
3
+ const passwordPolicyResolver = PasswordPolicyResolverFactory();
4
+ passwordPolicyResolver.setProjectPolicy(projectPasswordPolicy);
5
+ }
6
+ export default SetProjectPasswordPolicy;
7
+ export { SetProjectPasswordPolicy };
@@ -0,0 +1,36 @@
1
+ import z from "zod";
2
+ class PasswordPolicySchemaFactory {
3
+ static create(policy) {
4
+ const cacheKey = JSON.stringify(policy);
5
+ const cached = this.cache.get(cacheKey);
6
+ if (cached) {
7
+ return cached;
8
+ }
9
+ let schema = z.string({ error: "validation.required" })
10
+ .min(1, "validation.required")
11
+ .min(policy.minLength, "validation.password.minLength")
12
+ .max(policy.maxLength, "validation.password.maxLength");
13
+ if (policy.requireUppercase) {
14
+ schema = schema.regex(/[A-Z]/, "validation.password.requireUppercase");
15
+ }
16
+ if (policy.requireLowercase) {
17
+ schema = schema.regex(/[a-z]/, "validation.password.requireLowercase");
18
+ }
19
+ if (policy.requireNumber) {
20
+ schema = schema.regex(/[0-9]/, "validation.password.requireNumber");
21
+ }
22
+ if (policy.requireSpecialChar) {
23
+ schema = schema.regex(/[^A-Za-z0-9]/, "validation.password.requireSpecialChar");
24
+ }
25
+ if (policy.disallowSpaces) {
26
+ schema = schema.refine((value) => !/\s/.test(value), {
27
+ message: "validation.password.disallowSpaces"
28
+ });
29
+ }
30
+ this.cache.set(cacheKey, schema);
31
+ return schema;
32
+ }
33
+ }
34
+ PasswordPolicySchemaFactory.cache = new Map();
35
+ export default PasswordPolicySchemaFactory;
36
+ export { PasswordPolicySchemaFactory };
@@ -0,0 +1,19 @@
1
+ import { DraxConfig } from "@drax/common-back";
2
+ import PasswordPolicyConfig from "../config/PasswordPolicyConfig.js";
3
+ import { PartialPasswordPolicySchema } from "../schemas/PasswordPolicySchema.js";
4
+ function getPasswordEnvPolicy() {
5
+ const envPolicy = {
6
+ minLength: DraxConfig.getOrLoad(PasswordPolicyConfig.MinLength, "number"),
7
+ maxLength: DraxConfig.getOrLoad(PasswordPolicyConfig.MaxLength, "number"),
8
+ requireUppercase: DraxConfig.getOrLoad(PasswordPolicyConfig.RequireUppercase, "boolean"),
9
+ requireLowercase: DraxConfig.getOrLoad(PasswordPolicyConfig.RequireLowercase, "boolean"),
10
+ requireNumber: DraxConfig.getOrLoad(PasswordPolicyConfig.RequireNumber, "boolean"),
11
+ requireSpecialChar: DraxConfig.getOrLoad(PasswordPolicyConfig.RequireSpecialChar, "boolean"),
12
+ disallowSpaces: DraxConfig.getOrLoad(PasswordPolicyConfig.DisallowSpaces, "boolean"),
13
+ preventReuse: DraxConfig.getOrLoad(PasswordPolicyConfig.PreventReuse, "number"),
14
+ expirationDays: DraxConfig.getOrLoad(PasswordPolicyConfig.ExpirationDays, "number")
15
+ };
16
+ return PartialPasswordPolicySchema.parse(Object.fromEntries(Object.entries(envPolicy).filter(([, value]) => value !== undefined)));
17
+ }
18
+ export default getPasswordEnvPolicy;
19
+ export { getPasswordEnvPolicy };
@@ -0,0 +1,33 @@
1
+ # Password Policy
2
+
3
+ La policy efectiva se resuelve con esta prioridad:
4
+
5
+ ```ts
6
+ const finalPolicy = {
7
+ ...defaultPasswordPolicy,
8
+ ...projectPolicy,
9
+ ...envPolicy
10
+ }
11
+ ```
12
+
13
+ ## Fuentes
14
+
15
+ - `defaultPasswordPolicy`: `src/security/constants/defaultPasswordPolicy.ts`
16
+ - `projectPolicy`: override opcional in-memory vía `projectContext`
17
+ - `envPolicy`: variables declaradas en `src/config/PasswordPolicyConfig.ts`
18
+
19
+ ## Endpoint
20
+
21
+ - `GET /api/auth/password-policy`
22
+
23
+ Devuelve la policy efectiva para que frontend pueda mostrar requisitos de password antes de enviar formularios.
24
+
25
+ ## Validación
26
+
27
+ - Formato: `PasswordPolicySchemaFactory` genera un `ZodType<string>` dinámico
28
+ - Negocio: `PasswordPolicyService` aplica `preventReuse` y expone base para `expirationDays`
29
+
30
+ ## Notas
31
+
32
+ - `preventReuse` usa persistencia en `user_password_history`
33
+ - `expirationDays` ya forma parte de la policy efectiva y del servicio, pero en esta primera versión queda informativo; no bloquea login todavía
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "3.13.0",
6
+ "version": "3.15.0",
7
7
  "description": "Identity module for user management, authentication and authorization.",
8
8
  "main": "dist/index.js",
9
9
  "types": "types/index.d.ts",
@@ -28,11 +28,11 @@
28
28
  "author": "Cristian Incarnato & Drax Team",
29
29
  "license": "ISC",
30
30
  "dependencies": {
31
- "@drax/common-back": "^3.10.0",
32
- "@drax/crud-back": "^3.13.0",
33
- "@drax/crud-share": "^3.13.0",
31
+ "@drax/common-back": "^3.14.0",
32
+ "@drax/crud-back": "^3.15.0",
33
+ "@drax/crud-share": "^3.14.0",
34
34
  "@drax/email-back": "^3.1.0",
35
- "@drax/identity-share": "^3.0.0",
35
+ "@drax/identity-share": "^3.15.0",
36
36
  "bcryptjs": "^2.4.3",
37
37
  "graphql": "^16.8.2",
38
38
  "jsonwebtoken": "^9.0.2"
@@ -63,5 +63,5 @@
63
63
  "debug": "0"
64
64
  }
65
65
  },
66
- "gitHead": "5f68eefbcb01c876471e387a815fee1040489c2c"
66
+ "gitHead": "a3bd3419f580b111b26da9e6be2e1cc4c75a056e"
67
67
  }