@drax/identity-back 3.14.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 (133) hide show
  1. package/dist/config/PasswordPolicyConfig.js +14 -0
  2. package/dist/controllers/UserController.js +10 -0
  3. package/dist/factory/PasswordPolicyResolverFactory.js +9 -0
  4. package/dist/factory/PasswordPolicyServiceFactory.js +27 -0
  5. package/dist/factory/UserPasswordHistoryServiceFactory.js +25 -0
  6. package/dist/factory/UserServiceFactory.js +3 -1
  7. package/dist/index.js +20 -8
  8. package/dist/interfaces/IUserPasswordHistory.js +1 -0
  9. package/dist/interfaces/IUserPasswordHistoryRepository.js +1 -0
  10. package/dist/models/UserPasswordHistoryModel.js +30 -0
  11. package/dist/policies/defaultPasswordPolicy.js +12 -0
  12. package/dist/repository/mongo/UserPasswordHistoryMongoRepository.js +20 -0
  13. package/dist/repository/sqlite/UserPasswordHistorySqliteRepository.js +38 -0
  14. package/dist/resolver/PasswordPolicyResolver.js +27 -0
  15. package/dist/routes/UserRoutes.js +10 -0
  16. package/dist/schemas/PasswordPolicySchema.js +18 -0
  17. package/dist/schemas/RegisterSchema.js +1 -3
  18. package/dist/schemas/UserSchema.js +1 -3
  19. package/dist/security/constants/defaultPasswordPolicy.js +12 -0
  20. package/dist/security/interfaces/IPasswordPolicy.js +1 -0
  21. package/dist/security/interfaces/IPasswordPolicyProjectContext.js +1 -0
  22. package/dist/security/schemas/PasswordPolicySchema.js +18 -0
  23. package/dist/security/services/PasswordPolicyResolver.js +21 -0
  24. package/dist/security/services/PasswordPolicyService.js +147 -0
  25. package/dist/security/utils/PasswordPolicySchemaFactory.js +36 -0
  26. package/dist/security/utils/getPasswordEnvPolicy.js +19 -0
  27. package/dist/services/PasswordPolicyService.js +147 -0
  28. package/dist/services/UserPasswordHistoryService.js +18 -0
  29. package/dist/services/UserService.js +34 -9
  30. package/dist/setup/LoadIdentityConfigFromEnv.js +10 -0
  31. package/dist/setup/SetProjectPasswordPolicy.js +7 -0
  32. package/dist/utils/PasswordPolicySchemaFactory.js +36 -0
  33. package/dist/utils/getPasswordEnvPolicy.js +19 -0
  34. package/docs/password-policy.md +33 -0
  35. package/package.json +4 -4
  36. package/src/config/PasswordPolicyConfig.ts +14 -0
  37. package/src/controllers/UserController.ts +10 -1
  38. package/src/factory/PasswordPolicyResolverFactory.ts +14 -0
  39. package/src/factory/PasswordPolicyServiceFactory.ts +38 -0
  40. package/src/factory/UserPasswordHistoryServiceFactory.ts +31 -0
  41. package/src/factory/UserServiceFactory.ts +7 -1
  42. package/src/index.ts +28 -3
  43. package/src/interfaces/IUserPasswordHistory.ts +21 -0
  44. package/src/interfaces/IUserPasswordHistoryRepository.ts +8 -0
  45. package/src/models/UserPasswordHistoryModel.ts +42 -0
  46. package/src/policies/defaultPasswordPolicy.ts +17 -0
  47. package/src/repository/mongo/UserPasswordHistoryMongoRepository.ts +25 -0
  48. package/src/repository/sqlite/UserPasswordHistorySqliteRepository.ts +47 -0
  49. package/src/resolver/PasswordPolicyResolver.ts +33 -0
  50. package/src/routes/UserRoutes.ts +11 -0
  51. package/src/schemas/PasswordPolicySchema.ts +29 -0
  52. package/src/schemas/RegisterSchema.ts +1 -3
  53. package/src/schemas/UserSchema.ts +1 -3
  54. package/src/services/PasswordPolicyService.ts +184 -0
  55. package/src/services/UserPasswordHistoryService.ts +23 -0
  56. package/src/services/UserService.ts +38 -9
  57. package/src/setup/LoadIdentityConfigFromEnv.ts +11 -0
  58. package/src/setup/SetProjectPasswordPolicy.ts +12 -0
  59. package/src/utils/PasswordPolicySchemaFactory.ts +47 -0
  60. package/src/utils/getPasswordEnvPolicy.ts +25 -0
  61. package/test/data-obj/users/root-mongo-user.ts +1 -1
  62. package/test/data-obj/users/root-sqlite-user.ts +1 -1
  63. package/test/endpoints/data/users-data.ts +3 -3
  64. package/test/endpoints/password-policy-route.test.ts +33 -0
  65. package/test/endpoints/user-route.test.ts +17 -4
  66. package/test/security/password-policy-resolver.test.ts +55 -0
  67. package/test/security/password-policy-schema-factory.test.ts +40 -0
  68. package/test/services/user-service.test.ts +218 -31
  69. package/test/setup/TestSetup.ts +22 -4
  70. package/test/setup/data/basic-user.ts +1 -1
  71. package/test/setup/data/root-user.ts +1 -1
  72. package/tsconfig.tsbuildinfo +1 -1
  73. package/types/config/PasswordPolicyConfig.d.ts +14 -0
  74. package/types/config/PasswordPolicyConfig.d.ts.map +1 -0
  75. package/types/controllers/UserController.d.ts +1 -0
  76. package/types/controllers/UserController.d.ts.map +1 -1
  77. package/types/factory/PasswordPolicyResolverFactory.d.ts +4 -0
  78. package/types/factory/PasswordPolicyResolverFactory.d.ts.map +1 -0
  79. package/types/factory/PasswordPolicyServiceFactory.d.ts +4 -0
  80. package/types/factory/PasswordPolicyServiceFactory.d.ts.map +1 -0
  81. package/types/factory/UserPasswordHistoryServiceFactory.d.ts +4 -0
  82. package/types/factory/UserPasswordHistoryServiceFactory.d.ts.map +1 -0
  83. package/types/factory/UserServiceFactory.d.ts.map +1 -1
  84. package/types/index.d.ts +15 -2
  85. package/types/index.d.ts.map +1 -1
  86. package/types/interfaces/IUserPasswordHistory.d.ts +17 -0
  87. package/types/interfaces/IUserPasswordHistory.d.ts.map +1 -0
  88. package/types/interfaces/IUserPasswordHistoryRepository.d.ts +7 -0
  89. package/types/interfaces/IUserPasswordHistoryRepository.d.ts.map +1 -0
  90. package/types/models/UserPasswordHistoryModel.d.ts +15 -0
  91. package/types/models/UserPasswordHistoryModel.d.ts.map +1 -0
  92. package/types/policies/defaultPasswordPolicy.d.ts +4 -0
  93. package/types/policies/defaultPasswordPolicy.d.ts.map +1 -0
  94. package/types/repository/mongo/UserPasswordHistoryMongoRepository.d.ts +10 -0
  95. package/types/repository/mongo/UserPasswordHistoryMongoRepository.d.ts.map +1 -0
  96. package/types/repository/sqlite/UserPasswordHistorySqliteRepository.d.ts +25 -0
  97. package/types/repository/sqlite/UserPasswordHistorySqliteRepository.d.ts.map +1 -0
  98. package/types/resolver/PasswordPolicyResolver.d.ts +10 -0
  99. package/types/resolver/PasswordPolicyResolver.d.ts.map +1 -0
  100. package/types/routes/UserRoutes.d.ts.map +1 -1
  101. package/types/schemas/PasswordPolicySchema.d.ts +25 -0
  102. package/types/schemas/PasswordPolicySchema.d.ts.map +1 -0
  103. package/types/schemas/RegisterSchema.d.ts.map +1 -1
  104. package/types/schemas/UserSchema.d.ts.map +1 -1
  105. package/types/security/constants/defaultPasswordPolicy.d.ts +4 -0
  106. package/types/security/constants/defaultPasswordPolicy.d.ts.map +1 -0
  107. package/types/security/interfaces/IPasswordPolicy.d.ts +13 -0
  108. package/types/security/interfaces/IPasswordPolicy.d.ts.map +1 -0
  109. package/types/security/interfaces/IPasswordPolicyProjectContext.d.ts +6 -0
  110. package/types/security/interfaces/IPasswordPolicyProjectContext.d.ts.map +1 -0
  111. package/types/security/schemas/PasswordPolicySchema.d.ts +25 -0
  112. package/types/security/schemas/PasswordPolicySchema.d.ts.map +1 -0
  113. package/types/security/services/PasswordPolicyResolver.d.ts +9 -0
  114. package/types/security/services/PasswordPolicyResolver.d.ts.map +1 -0
  115. package/types/security/services/PasswordPolicyService.d.ts +35 -0
  116. package/types/security/services/PasswordPolicyService.d.ts.map +1 -0
  117. package/types/security/utils/PasswordPolicySchemaFactory.d.ts +9 -0
  118. package/types/security/utils/PasswordPolicySchemaFactory.d.ts.map +1 -0
  119. package/types/security/utils/getPasswordEnvPolicy.d.ts +5 -0
  120. package/types/security/utils/getPasswordEnvPolicy.d.ts.map +1 -0
  121. package/types/services/PasswordPolicyService.d.ts +34 -0
  122. package/types/services/PasswordPolicyService.d.ts.map +1 -0
  123. package/types/services/UserPasswordHistoryService.d.ts +10 -0
  124. package/types/services/UserPasswordHistoryService.d.ts.map +1 -0
  125. package/types/services/UserService.d.ts +5 -1
  126. package/types/services/UserService.d.ts.map +1 -1
  127. package/types/setup/LoadIdentityConfigFromEnv.d.ts.map +1 -1
  128. package/types/setup/SetProjectPasswordPolicy.d.ts +5 -0
  129. package/types/setup/SetProjectPasswordPolicy.d.ts.map +1 -0
  130. package/types/utils/PasswordPolicySchemaFactory.d.ts +9 -0
  131. package/types/utils/PasswordPolicySchemaFactory.d.ts.map +1 -0
  132. package/types/utils/getPasswordEnvPolicy.d.ts +5 -0
  133. package/types/utils/getPasswordEnvPolicy.d.ts.map +1 -0
@@ -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.14.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",
@@ -29,10 +29,10 @@
29
29
  "license": "ISC",
30
30
  "dependencies": {
31
31
  "@drax/common-back": "^3.14.0",
32
- "@drax/crud-back": "^3.14.0",
32
+ "@drax/crud-back": "^3.15.0",
33
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": "cec0f824be0bfff0965d7bc7b95241406c53c04d"
66
+ "gitHead": "a3bd3419f580b111b26da9e6be2e1cc4c75a056e"
67
67
  }
@@ -0,0 +1,14 @@
1
+ enum PasswordPolicyConfig {
2
+ MinLength = "PASSWORD_POLICY_MIN_LENGTH",
3
+ MaxLength = "PASSWORD_POLICY_MAX_LENGTH",
4
+ RequireUppercase = "PASSWORD_POLICY_REQUIRE_UPPERCASE",
5
+ RequireLowercase = "PASSWORD_POLICY_REQUIRE_LOWERCASE",
6
+ RequireNumber = "PASSWORD_POLICY_REQUIRE_NUMBER",
7
+ RequireSpecialChar = "PASSWORD_POLICY_REQUIRE_SPECIAL_CHAR",
8
+ DisallowSpaces = "PASSWORD_POLICY_DISALLOW_SPACES",
9
+ PreventReuse = "PASSWORD_POLICY_PREVENT_REUSE",
10
+ ExpirationDays = "PASSWORD_POLICY_EXPIRATION_DAYS",
11
+ }
12
+
13
+ export default PasswordPolicyConfig;
14
+ export {PasswordPolicyConfig};
@@ -20,6 +20,7 @@ import UserEmailService from "../services/UserEmailService.js";
20
20
  import {IDraxCrudEvent, IDraxFieldFilter} from "@drax/crud-share";
21
21
  import TenantServiceFactory from "../factory/TenantServiceFactory.js";
22
22
  import {CustomRequest} from "@drax/crud-back/dist";
23
+ import PasswordPolicyServiceFactory from "../factory/PasswordPolicyServiceFactory.js";
23
24
 
24
25
  const BASE_FILE_DIR = DraxConfig.getOrLoad(CommonConfig.FileDir) || 'files';
25
26
  const AVATAR_DIR = DraxConfig.getOrLoad(IdentityConfig.AvatarDir) || 'avatar';
@@ -91,6 +92,15 @@ class UserController extends AbstractFastifyController<IUser, IUserCreate, IUser
91
92
  }
92
93
  }
93
94
 
95
+ async passwordPolicy(request, reply) {
96
+ try {
97
+ const passwordPolicyService = PasswordPolicyServiceFactory()
98
+ return await passwordPolicyService.getFinalPolicy()
99
+ } catch (e) {
100
+ this.handleError(e, reply)
101
+ }
102
+ }
103
+
94
104
  async me(request, reply) {
95
105
  try {
96
106
  if (request.authUser) {
@@ -504,4 +514,3 @@ export default UserController;
504
514
  export {
505
515
  UserController
506
516
  }
507
-
@@ -0,0 +1,14 @@
1
+ import PasswordPolicyResolver from "../resolver/PasswordPolicyResolver.js";
2
+
3
+ let passwordPolicyResolver: PasswordPolicyResolver
4
+
5
+ const PasswordPolicyResolverFactory = (): PasswordPolicyResolver => {
6
+ if (!passwordPolicyResolver) {
7
+
8
+ passwordPolicyResolver = new PasswordPolicyResolver()
9
+ }
10
+
11
+ return passwordPolicyResolver
12
+ }
13
+
14
+ export default PasswordPolicyResolverFactory
@@ -0,0 +1,38 @@
1
+ import PasswordPolicyService from "../services/PasswordPolicyService.js";
2
+ import PasswordPolicyResolverFactory from "./PasswordPolicyResolverFactory.js";
3
+ import UserPasswordHistoryServiceFactory from "./UserPasswordHistoryServiceFactory.js";
4
+ import UserMongoRepository from "../repository/mongo/UserMongoRepository.js";
5
+ import UserSqliteRepository from "../repository/sqlite/UserSqliteRepository.js";
6
+ import {COMMON, CommonConfig, DraxConfig} from "@drax/common-back";
7
+ import type {IUserRepository} from "../interfaces/IUserRepository.js";
8
+
9
+ let passwordPolicyService: PasswordPolicyService
10
+
11
+ const PasswordPolicyServiceFactory = (verbose: boolean = false): PasswordPolicyService => {
12
+ if (!passwordPolicyService) {
13
+ let userRepository: IUserRepository
14
+
15
+ switch (DraxConfig.getOrLoad(CommonConfig.DbEngine)) {
16
+ case COMMON.DB_ENGINES.MONGODB:
17
+ userRepository = new UserMongoRepository()
18
+ break;
19
+ case COMMON.DB_ENGINES.SQLITE:
20
+ userRepository = new UserSqliteRepository(DraxConfig.getOrLoad(CommonConfig.SqliteDbFile), verbose)
21
+ userRepository.build()
22
+ break;
23
+ default:
24
+ throw new Error("DraxConfig.DB_ENGINE must be one of " + Object.values(COMMON.DB_ENGINES).join(", "));
25
+ }
26
+
27
+ const passwordPolicyResolver = PasswordPolicyResolverFactory()
28
+ passwordPolicyService = new PasswordPolicyService(
29
+ passwordPolicyResolver,
30
+ userRepository,
31
+ UserPasswordHistoryServiceFactory(verbose)
32
+ )
33
+ }
34
+
35
+ return passwordPolicyService
36
+ }
37
+
38
+ export default PasswordPolicyServiceFactory
@@ -0,0 +1,31 @@
1
+ import {COMMON, CommonConfig, DraxConfig} from "@drax/common-back";
2
+ import type {IUserPasswordHistoryRepository} from "../interfaces/IUserPasswordHistoryRepository.js";
3
+ import UserPasswordHistoryMongoRepository from "../repository/mongo/UserPasswordHistoryMongoRepository.js";
4
+ import UserPasswordHistorySqliteRepository from "../repository/sqlite/UserPasswordHistorySqliteRepository.js";
5
+ import UserPasswordHistoryService from "../services/UserPasswordHistoryService.js";
6
+
7
+ let userPasswordHistoryService: UserPasswordHistoryService
8
+
9
+ const UserPasswordHistoryServiceFactory = (verbose: boolean = false): UserPasswordHistoryService => {
10
+ if (!userPasswordHistoryService) {
11
+ let repository: IUserPasswordHistoryRepository
12
+ switch (DraxConfig.getOrLoad(CommonConfig.DbEngine)) {
13
+ case COMMON.DB_ENGINES.MONGODB:
14
+ repository = new UserPasswordHistoryMongoRepository()
15
+ break;
16
+ case COMMON.DB_ENGINES.SQLITE:
17
+ const sqliteRepository = new UserPasswordHistorySqliteRepository(DraxConfig.getOrLoad(CommonConfig.SqliteDbFile), verbose)
18
+ sqliteRepository.build()
19
+ repository = sqliteRepository
20
+ break;
21
+ default:
22
+ throw new Error("DraxConfig.DB_ENGINE must be one of " + Object.values(COMMON.DB_ENGINES).join(", "));
23
+ }
24
+
25
+ userPasswordHistoryService = new UserPasswordHistoryService(repository)
26
+ }
27
+
28
+ return userPasswordHistoryService
29
+ }
30
+
31
+ export default UserPasswordHistoryServiceFactory
@@ -3,6 +3,8 @@ import UserService from "../services/UserService.js";
3
3
  import UserSqliteRepository from "../repository/sqlite/UserSqliteRepository.js";
4
4
  import {IUserRepository} from "../interfaces/IUserRepository";
5
5
  import {COMMON, CommonConfig, DraxConfig} from "@drax/common-back";
6
+ import PasswordPolicyServiceFactory from "./PasswordPolicyServiceFactory.js";
7
+ import UserPasswordHistoryServiceFactory from "./UserPasswordHistoryServiceFactory.js";
6
8
 
7
9
  let userService: UserService
8
10
 
@@ -22,7 +24,11 @@ const UserServiceFactory = (verbose:boolean = false) : UserService => {
22
24
  throw new Error("DraxConfig.DB_ENGINE must be one of " + Object.values(COMMON.DB_ENGINES).join(", "));
23
25
  }
24
26
 
25
- userService = new UserService(userRepository)
27
+ userService = new UserService(
28
+ userRepository,
29
+ PasswordPolicyServiceFactory(verbose),
30
+ UserPasswordHistoryServiceFactory(verbose)
31
+ )
26
32
  }
27
33
 
28
34
  return userService