@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,47 @@
1
+ import type {SqliteTableField} from "@drax/common-back";
2
+ import {AbstractSqliteRepository} from "@drax/crud-back";
3
+ import type {IUserPasswordHistoryRepository} from "../../interfaces/IUserPasswordHistoryRepository.js";
4
+ import type {IUserPasswordHistory, IUserPasswordHistoryCreate} from "../../interfaces/IUserPasswordHistory.js";
5
+
6
+ class UserPasswordHistorySqliteRepository extends AbstractSqliteRepository<IUserPasswordHistory, IUserPasswordHistoryCreate, IUserPasswordHistoryCreate> implements IUserPasswordHistoryRepository {
7
+ protected db: any;
8
+ protected tableName: string = "user_password_history";
9
+ protected dataBaseFile: string;
10
+ protected searchFields: string[] = [];
11
+ protected booleanFields: string[] = [];
12
+ protected identifier: string = "_id";
13
+ protected populateFields = [
14
+ {field: "user", table: "users", identifier: "_id"}
15
+ ]
16
+ protected tableFields: SqliteTableField[] = [
17
+ {name: "user", type: "TEXT", unique: false, primary: false},
18
+ {name: "passwordHash", type: "TEXT", unique: false, primary: false},
19
+ {name: "createdAt", type: "TEXT", unique: false, primary: false},
20
+ {name: "updatedAt", type: "TEXT", unique: false, primary: false},
21
+ ]
22
+ protected verbose: boolean;
23
+
24
+ async prepareData(): Promise<void> {
25
+ }
26
+
27
+ async prepareItem(item: IUserPasswordHistory): Promise<void> {
28
+ if (item.createdAt && typeof item.createdAt === "string") {
29
+ item.createdAt = new Date(item.createdAt)
30
+ }
31
+
32
+ if (item.updatedAt && typeof item.updatedAt === "string") {
33
+ item.updatedAt = new Date(item.updatedAt)
34
+ }
35
+ }
36
+
37
+ async findLatestByUserId(userId: string, limit: number): Promise<IUserPasswordHistory[]> {
38
+ const rows = this.db.prepare(`SELECT * FROM ${this.tableName} WHERE user = ? ORDER BY createdAt DESC LIMIT ?`).all(userId, limit);
39
+ for (const row of rows) {
40
+ await this.decorate(row)
41
+ }
42
+ return rows
43
+ }
44
+ }
45
+
46
+ export default UserPasswordHistorySqliteRepository
47
+ export {UserPasswordHistorySqliteRepository}
@@ -0,0 +1,33 @@
1
+ import type {IPasswordPolicy, IPasswordPolicyProject} from "@drax/identity-share";
2
+ import {defaultPasswordPolicy} from "../policies/defaultPasswordPolicy.js";
3
+ import {PartialPasswordPolicySchema, PasswordPolicySchema} from "../schemas/PasswordPolicySchema.js";
4
+ import getPasswordEnvPolicy from "../utils/getPasswordEnvPolicy.js";
5
+
6
+ class PasswordPolicyResolver {
7
+
8
+ private projectPolicy : IPasswordPolicyProject = {};
9
+
10
+ setProjectPolicy (projectPolicy : IPasswordPolicyProject){
11
+ this.projectPolicy = projectPolicy;
12
+ }
13
+
14
+ async resolve(): Promise<IPasswordPolicy> {
15
+ const projectPolicy = await this.getProjectPolicy()
16
+ const envPolicy = getPasswordEnvPolicy()
17
+
18
+ return PasswordPolicySchema.parse({
19
+ ...defaultPasswordPolicy,
20
+ ...projectPolicy,
21
+ ...envPolicy
22
+ }) as IPasswordPolicy
23
+ }
24
+
25
+ private async getProjectPolicy(): Promise<Partial<IPasswordPolicy>> {
26
+ return this.projectPolicy
27
+ ? PartialPasswordPolicySchema.parse(this.projectPolicy)
28
+ : {}
29
+ }
30
+ }
31
+
32
+ export default PasswordPolicyResolver
33
+ export {PasswordPolicyResolver}
@@ -10,6 +10,7 @@ import {
10
10
  } from "../schemas/PasswordSchema.js";
11
11
  import {SwitchTenantBodyRequestSchema, SwitchTenantBodyResponseSchema} from "../schemas/SwitchTenantSchema.js";
12
12
  import zod from "zod"
13
+ import {PasswordPolicySchema} from "../schemas/PasswordPolicySchema.js";
13
14
 
14
15
  async function UserRoutes(fastify, options) {
15
16
 
@@ -44,6 +45,16 @@ async function UserRoutes(fastify, options) {
44
45
  },
45
46
  (req, rep) => controller.auth(req, rep))
46
47
 
48
+ fastify.get('/api/auth/password-policy', {
49
+ schema: {
50
+ tags: ['Auth'],
51
+ response: {
52
+ 200: zod.toJSONSchema(PasswordPolicySchema),
53
+ 500: schemas.jsonErrorBodyResponse,
54
+ },
55
+ },
56
+ }, (req, rep) => controller.passwordPolicy(req, rep))
57
+
47
58
  fastify.get('/api/auth/me', {
48
59
  schema: {
49
60
  tags: ['Auth'],
@@ -0,0 +1,29 @@
1
+ import z from "zod";
2
+
3
+ const PasswordPolicySchemaBase = z.object({
4
+ minLength: z.number().int().min(1),
5
+ maxLength: z.number().int().min(1),
6
+ requireUppercase: z.boolean(),
7
+ requireLowercase: z.boolean(),
8
+ requireNumber: z.boolean(),
9
+ requireSpecialChar: z.boolean(),
10
+ disallowSpaces: z.boolean(),
11
+ preventReuse: z.number().int().min(0),
12
+ expirationDays: z.number().int().min(1).nullable(),
13
+ })
14
+
15
+ const PasswordPolicySchema = PasswordPolicySchemaBase.refine((
16
+ policy) =>
17
+ policy.maxLength >= policy.minLength,
18
+ {
19
+ message: "validation.password.maxLength",
20
+ path: ["maxLength"]
21
+ }
22
+ );
23
+
24
+ const PartialPasswordPolicySchema = PasswordPolicySchemaBase.partial();
25
+
26
+ export {
27
+ PasswordPolicySchema,
28
+ PartialPasswordPolicySchema
29
+ }
@@ -8,9 +8,7 @@ const RegisterBodyRequestSchema = z.object({
8
8
  email: email("validation.email.invalid"),
9
9
  phone: string({ error: "validation.required" }).optional(),
10
10
  password: string({ error: "validation.required" })
11
- .min(1, "validation.required")
12
- .min(8, "validation.password.min8")
13
- .max(64, "validation.password.max64"),
11
+ .min(1, "validation.required"),
14
12
  });
15
13
 
16
14
  const RegisterBodyResponseSchema = z.object({
@@ -17,9 +17,7 @@ const UserBaseSchema = object({
17
17
 
18
18
  const UserCreateSchema = UserBaseSchema.extend({
19
19
  password: string({error: "validation.required"})
20
- .min(1, "validation.required")
21
- .min(8, "validation.password.min8")
22
- .max(64, "validation.password.max64"),
20
+ .min(1, "validation.required"),
23
21
  });
24
22
 
25
23
 
@@ -0,0 +1,184 @@
1
+ import {randomInt} from "crypto";
2
+ import {ZodError, ZodType} from "zod";
3
+ import {ValidationError, ZodErrorToValidationError} from "@drax/common-back";
4
+ import type {IUser} from "@drax/identity-share";
5
+ import type {IUserPasswordHistory} from "../interfaces/IUserPasswordHistory";
6
+ import type {IPasswordPolicy} from "@drax/identity-share";
7
+ import type UserPasswordHistoryService from "./UserPasswordHistoryService";
8
+ import type {IUserRepository} from "../interfaces/IUserRepository";
9
+ import AuthUtils from "../utils/AuthUtils.js";
10
+ import PasswordPolicyResolver from "../resolver/PasswordPolicyResolver.js";
11
+ import PasswordPolicySchemaFactory from "../utils/PasswordPolicySchemaFactory.js";
12
+
13
+ interface IValidatePasswordOptions {
14
+ field?: string
15
+ userId?: string
16
+ currentPasswordHash?: string
17
+ }
18
+
19
+ class PasswordPolicyService {
20
+ constructor(
21
+ private readonly resolver: PasswordPolicyResolver,
22
+ private readonly userRepository?: IUserRepository,
23
+ private readonly userPasswordHistoryService?: UserPasswordHistoryService
24
+ ) {
25
+ }
26
+
27
+ async getFinalPolicy(): Promise<IPasswordPolicy> {
28
+ return this.resolver.resolve()
29
+ }
30
+
31
+ async getPasswordSchema(): Promise<ZodType<string>> {
32
+ const policy = await this.getFinalPolicy()
33
+ return PasswordPolicySchemaFactory.create(policy)
34
+ }
35
+
36
+ async validatePassword(password: string, options?: IValidatePasswordOptions): Promise<void> {
37
+ const field = options?.field || "password"
38
+ try {
39
+ const schema = await this.getPasswordSchema()
40
+ await schema.parseAsync(password)
41
+ } catch (e) {
42
+ if (e instanceof ZodError) {
43
+ const validationError = ZodErrorToValidationError(e, {[field]: password})
44
+ validationError.errors = validationError.errors.map((error) => ({...error, field}))
45
+ throw validationError
46
+ }
47
+ throw e
48
+ }
49
+
50
+ if (options?.userId) {
51
+ await this.validateReuse(password, options.userId, {
52
+ field,
53
+ currentPasswordHash: options.currentPasswordHash
54
+ })
55
+ }
56
+ }
57
+
58
+ async generateCompatiblePassword(): Promise<string> {
59
+ const policy = await this.getFinalPolicy()
60
+ const uppercaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
61
+ const lowercaseChars = "abcdefghijkmnopqrstuvwxyz"
62
+ const numericChars = "0123456789"
63
+ const specialChars = "!@#$%&*_-+="
64
+ const fallbackSpecial = policy.disallowSpaces ? "!" : " "
65
+ const combinedChars = [
66
+ uppercaseChars,
67
+ lowercaseChars,
68
+ numericChars,
69
+ policy.requireSpecialChar ? specialChars : "",
70
+ policy.disallowSpaces ? "" : " "
71
+ ].join("") || `${uppercaseChars}${lowercaseChars}${numericChars}${fallbackSpecial}`
72
+
73
+ const chars: string[] = []
74
+
75
+ if (policy.requireUppercase) {
76
+ chars.push(this.randomChar(uppercaseChars))
77
+ }
78
+ if (policy.requireLowercase) {
79
+ chars.push(this.randomChar(lowercaseChars))
80
+ }
81
+ if (policy.requireNumber) {
82
+ chars.push(this.randomChar(numericChars))
83
+ }
84
+ if (policy.requireSpecialChar) {
85
+ chars.push(this.randomChar(specialChars))
86
+ }
87
+ if (!chars.length) {
88
+ chars.push(this.randomChar(`${uppercaseChars}${lowercaseChars}${numericChars}`))
89
+ }
90
+
91
+ while (chars.length < policy.minLength) {
92
+ chars.push(this.randomChar(combinedChars))
93
+ }
94
+
95
+ const password = this.shuffle(chars).join("").slice(0, policy.maxLength)
96
+ await this.validatePassword(password)
97
+ return password
98
+ }
99
+
100
+ async getPasswordExpiration(user: IUser): Promise<{expired: boolean, expiresAt: Date | null}> {
101
+ const policy = await this.getFinalPolicy()
102
+ if (!policy.expirationDays) {
103
+ return {expired: false, expiresAt: null}
104
+ }
105
+
106
+ const lastPasswordChange = await this.getLastPasswordChangeDate(user)
107
+ if (!lastPasswordChange) {
108
+ return {expired: false, expiresAt: null}
109
+ }
110
+
111
+ const expiresAt = new Date(lastPasswordChange)
112
+ expiresAt.setDate(expiresAt.getDate() + policy.expirationDays)
113
+
114
+ return {
115
+ expired: expiresAt.getTime() <= Date.now(),
116
+ expiresAt
117
+ }
118
+ }
119
+
120
+ async recordPassword(userId: string, passwordHash: string): Promise<void> {
121
+ await this.userPasswordHistoryService?.create(userId, passwordHash)
122
+ }
123
+
124
+ private async validateReuse(password: string, userId: string, options?: {field: string, currentPasswordHash?: string}): Promise<void> {
125
+ const policy = await this.getFinalPolicy()
126
+ if (!policy.preventReuse) {
127
+ return
128
+ }
129
+
130
+ const field = options?.field || "password"
131
+ const recentHashes = await this.getRecentPasswordHashes(userId, policy.preventReuse, options?.currentPasswordHash)
132
+ const reused = recentHashes.some((item) => AuthUtils.checkPassword(password, item.passwordHash))
133
+ if (reused) {
134
+ throw new ValidationError([{field, reason: "validation.password.preventReuse"}])
135
+ }
136
+ }
137
+
138
+ private async getRecentPasswordHashes(userId: string, limit: number, currentPasswordHash?: string): Promise<IUserPasswordHistory[]> {
139
+ const recent = await this.userPasswordHistoryService?.findLatestByUserId(userId, limit) || []
140
+ const hashes = [...recent]
141
+
142
+ if (currentPasswordHash && !hashes.some((item) => item.passwordHash === currentPasswordHash)) {
143
+ hashes.unshift({user: userId, passwordHash: currentPasswordHash})
144
+ } else if (!currentPasswordHash && this.userRepository) {
145
+ const user = await this.userRepository.findByIdWithPassword(userId)
146
+ if (user?.password && !hashes.some((item) => item.passwordHash === user.password)) {
147
+ hashes.unshift({user: userId, passwordHash: user.password})
148
+ }
149
+ }
150
+
151
+ return hashes.slice(0, limit)
152
+ }
153
+
154
+ private async getLastPasswordChangeDate(user: IUser): Promise<Date | null> {
155
+ if (!user?._id || !this.userPasswordHistoryService) {
156
+ return user?.updatedAt ? new Date(user.updatedAt) : user?.createdAt ? new Date(user.createdAt) : null
157
+ }
158
+
159
+ const latest = await this.userPasswordHistoryService.findLatestByUserId(user._id.toString(), 1)
160
+ if (latest[0]?.createdAt) {
161
+ return new Date(latest[0].createdAt)
162
+ }
163
+
164
+ return user?.updatedAt ? new Date(user.updatedAt) : user?.createdAt ? new Date(user.createdAt) : null
165
+ }
166
+
167
+ private randomChar(source: string): string {
168
+ return source[randomInt(0, source.length)]
169
+ }
170
+
171
+ private shuffle(chars: string[]): string[] {
172
+ const items = [...chars]
173
+ for (let i = items.length - 1; i > 0; i -= 1) {
174
+ const j = randomInt(0, i + 1)
175
+ const temp = items[i]
176
+ items[i] = items[j]
177
+ items[j] = temp
178
+ }
179
+ return items
180
+ }
181
+ }
182
+
183
+ export default PasswordPolicyService
184
+ export {PasswordPolicyService}
@@ -0,0 +1,23 @@
1
+ import type {IUserPasswordHistoryRepository} from "../interfaces/IUserPasswordHistoryRepository.js";
2
+ import type {IUserPasswordHistory} from "../interfaces/IUserPasswordHistory.js";
3
+
4
+ class UserPasswordHistoryService {
5
+ constructor(private readonly repository: IUserPasswordHistoryRepository) {
6
+ }
7
+
8
+ async create(userId: string, passwordHash: string): Promise<IUserPasswordHistory> {
9
+ return this.repository.create({
10
+ user: userId,
11
+ passwordHash
12
+ })
13
+ }
14
+
15
+ async findLatestByUserId(userId: string, limit: number): Promise<IUserPasswordHistory[]> {
16
+ if (limit <= 0) {
17
+ return []
18
+ }
19
+ return this.repository.findLatestByUserId(userId, limit)
20
+ }
21
+ }
22
+
23
+ export default UserPasswordHistoryService
@@ -11,12 +11,20 @@ import {AbstractService} from "@drax/crud-back";
11
11
  import {randomUUID} from "crypto"
12
12
  import UserLoginFailServiceFactory from "../factory/UserLoginFailServiceFactory.js";
13
13
  import UserSessionServiceFactory from "../factory/UserSessionServiceFactory.js";
14
+ import type PasswordPolicyService from "./PasswordPolicyService";
15
+ import PasswordPolicyServiceFactory from "../factory/PasswordPolicyServiceFactory.js";
16
+ import type UserPasswordHistoryService from "./UserPasswordHistoryService.js";
17
+ import UserPasswordHistoryServiceFactory from "../factory/UserPasswordHistoryServiceFactory.js";
14
18
 
15
19
  class UserService extends AbstractService<IUser, IUserCreate, IUserUpdate> {
16
20
 
17
21
  _repository: IUserRepository
18
22
 
19
- constructor(userRepository: IUserRepository) {
23
+ constructor(
24
+ userRepository: IUserRepository,
25
+ private readonly passwordPolicyService: PasswordPolicyService = PasswordPolicyServiceFactory(),
26
+ private readonly userPasswordHistoryService: UserPasswordHistoryService = UserPasswordHistoryServiceFactory()
27
+ ) {
20
28
  super(userRepository, UserBaseSchema);
21
29
  this._repository = userRepository;
22
30
  }
@@ -75,7 +83,7 @@ class UserService extends AbstractService<IUser, IUserCreate, IUserUpdate> {
75
83
  user = await this.findByEmail(email)
76
84
 
77
85
  if (!user && createIfNotFound) {
78
- userData.password = userData.password ? userData.password : randomUUID()
86
+ userData.password = userData.password ? userData.password : await this.passwordPolicyService.generateCompatiblePassword()
79
87
  userData.active = userData.active === undefined ? true : userData.active
80
88
  user = await this.create(userData)
81
89
  }
@@ -105,8 +113,14 @@ class UserService extends AbstractService<IUser, IUserCreate, IUserUpdate> {
105
113
  async changeUserPassword(userId: string, newPassword: string):Promise<IUser> {
106
114
  const user = await this._repository.findByIdWithPassword(userId)
107
115
  if (user) {
108
- newPassword = AuthUtils.hashPassword(newPassword)
109
- await this._repository.changePassword(userId, newPassword)
116
+ await this.passwordPolicyService.validatePassword(newPassword, {
117
+ field: 'newPassword',
118
+ userId,
119
+ currentPasswordHash: user.password
120
+ })
121
+ const newPasswordHash = AuthUtils.hashPassword(newPassword)
122
+ await this._repository.changePassword(userId, newPasswordHash)
123
+ await this.userPasswordHistoryService.create(userId, newPasswordHash)
110
124
  delete user.password
111
125
  return user
112
126
  } else {
@@ -124,8 +138,14 @@ class UserService extends AbstractService<IUser, IUserCreate, IUserUpdate> {
124
138
  }
125
139
 
126
140
  if (AuthUtils.checkPassword(currentPassword, user.password)) {
127
- newPassword = AuthUtils.hashPassword(newPassword)
128
- await this._repository.changePassword(userId, newPassword)
141
+ await this.passwordPolicyService.validatePassword(newPassword, {
142
+ field: 'newPassword',
143
+ userId,
144
+ currentPasswordHash: user.password
145
+ })
146
+ const newPasswordHash = AuthUtils.hashPassword(newPassword)
147
+ await this._repository.changePassword(userId, newPasswordHash)
148
+ await this.userPasswordHistoryService.create(userId, newPasswordHash)
129
149
  delete user.password
130
150
  return user
131
151
  } else {
@@ -167,8 +187,14 @@ class UserService extends AbstractService<IUser, IUserCreate, IUserUpdate> {
167
187
  try {
168
188
  const user = await this._repository.findByRecoveryCode(recoveryCode)
169
189
  if (user && user.active) {
170
- newPassword = AuthUtils.hashPassword(newPassword)
171
- await this._repository.changePassword(user._id, newPassword)
190
+ await this.passwordPolicyService.validatePassword(newPassword, {
191
+ field: 'newPassword',
192
+ userId: user._id.toString(),
193
+ currentPasswordHash: user.password
194
+ })
195
+ const newPasswordHash = AuthUtils.hashPassword(newPassword)
196
+ await this._repository.changePassword(user._id, newPasswordHash)
197
+ await this.userPasswordHistoryService.create(user._id.toString(), newPasswordHash)
172
198
  await this._repository.updatePartial(user._id, {recoveryCode: null})
173
199
  return user
174
200
  } else {
@@ -237,10 +263,13 @@ class UserService extends AbstractService<IUser, IUserCreate, IUserUpdate> {
237
263
  userData.tenant = userData.tenant === "" ? null : userData.tenant
238
264
 
239
265
  await UserCreateSchema.parseAsync(userData)
266
+ await this.passwordPolicyService.validatePassword(userData.password)
240
267
 
241
- userData.password = AuthUtils.hashPassword(userData.password.trim())
268
+ const passwordHash = AuthUtils.hashPassword(userData.password.trim())
269
+ userData.password = passwordHash
242
270
 
243
271
  const user: IUser = await this._repository.create(userData)
272
+ await this.userPasswordHistoryService.create(user._id.toString(), passwordHash)
244
273
  return user
245
274
  } catch (e) {
246
275
  console.error("Error creating user", 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
 
4
5
  function LoadIdentityConfigFromEnv() {
5
6
 
@@ -10,6 +11,16 @@ function LoadIdentityConfigFromEnv() {
10
11
 
11
12
  DraxConfig.set(IdentityConfig.RbacCacheTTL, process.env[IdentityConfig.RbacCacheTTL])
12
13
  DraxConfig.set(IdentityConfig.AvatarDir, process.env[IdentityConfig.AvatarDir])
14
+
15
+ DraxConfig.set(PasswordPolicyConfig.MinLength, process.env[PasswordPolicyConfig.MinLength])
16
+ DraxConfig.set(PasswordPolicyConfig.MaxLength, process.env[PasswordPolicyConfig.MaxLength])
17
+ DraxConfig.set(PasswordPolicyConfig.RequireUppercase, process.env[PasswordPolicyConfig.RequireUppercase])
18
+ DraxConfig.set(PasswordPolicyConfig.RequireLowercase, process.env[PasswordPolicyConfig.RequireLowercase])
19
+ DraxConfig.set(PasswordPolicyConfig.RequireNumber, process.env[PasswordPolicyConfig.RequireNumber])
20
+ DraxConfig.set(PasswordPolicyConfig.RequireSpecialChar, process.env[PasswordPolicyConfig.RequireSpecialChar])
21
+ DraxConfig.set(PasswordPolicyConfig.DisallowSpaces, process.env[PasswordPolicyConfig.DisallowSpaces])
22
+ DraxConfig.set(PasswordPolicyConfig.PreventReuse, process.env[PasswordPolicyConfig.PreventReuse])
23
+ DraxConfig.set(PasswordPolicyConfig.ExpirationDays, process.env[PasswordPolicyConfig.ExpirationDays])
13
24
  }
14
25
 
15
26
  export default LoadIdentityConfigFromEnv
@@ -0,0 +1,12 @@
1
+ import type {IPasswordPolicyProject} from "@drax/identity-share";
2
+ import PasswordPolicyResolverFactory from "../factory/PasswordPolicyResolverFactory.js";
3
+
4
+
5
+ function SetProjectPasswordPolicy(projectPasswordPolicy: IPasswordPolicyProject){
6
+
7
+ const passwordPolicyResolver = PasswordPolicyResolverFactory()
8
+ passwordPolicyResolver.setProjectPolicy(projectPasswordPolicy)
9
+ }
10
+
11
+ export default SetProjectPasswordPolicy
12
+ export {SetProjectPasswordPolicy}
@@ -0,0 +1,47 @@
1
+ import z from "zod";
2
+ import type {IPasswordPolicy} from "@drax/identity-share";
3
+
4
+ class PasswordPolicySchemaFactory {
5
+ private static cache = new Map<string, z.ZodType<string>>()
6
+
7
+ static create(policy: IPasswordPolicy): z.ZodType<string> {
8
+ const cacheKey = JSON.stringify(policy)
9
+ const cached = this.cache.get(cacheKey)
10
+ if (cached) {
11
+ return cached
12
+ }
13
+
14
+ let schema = z.string({error: "validation.required"})
15
+ .min(1, "validation.required")
16
+ .min(policy.minLength, "validation.password.minLength")
17
+ .max(policy.maxLength, "validation.password.maxLength")
18
+
19
+ if (policy.requireUppercase) {
20
+ schema = schema.regex(/[A-Z]/, "validation.password.requireUppercase")
21
+ }
22
+
23
+ if (policy.requireLowercase) {
24
+ schema = schema.regex(/[a-z]/, "validation.password.requireLowercase")
25
+ }
26
+
27
+ if (policy.requireNumber) {
28
+ schema = schema.regex(/[0-9]/, "validation.password.requireNumber")
29
+ }
30
+
31
+ if (policy.requireSpecialChar) {
32
+ schema = schema.regex(/[^A-Za-z0-9]/, "validation.password.requireSpecialChar")
33
+ }
34
+
35
+ if (policy.disallowSpaces) {
36
+ schema = schema.refine((value) => !/\s/.test(value), {
37
+ message: "validation.password.disallowSpaces"
38
+ })
39
+ }
40
+
41
+ this.cache.set(cacheKey, schema)
42
+ return schema
43
+ }
44
+ }
45
+
46
+ export default PasswordPolicySchemaFactory
47
+ export {PasswordPolicySchemaFactory}
@@ -0,0 +1,25 @@
1
+ import type {IPasswordPolicy} from "@drax/identity-share";
2
+ import {DraxConfig} from "@drax/common-back";
3
+ import PasswordPolicyConfig from "../config/PasswordPolicyConfig.js";
4
+ import {PartialPasswordPolicySchema} from "../schemas/PasswordPolicySchema.js";
5
+
6
+ function getPasswordEnvPolicy(): Partial<IPasswordPolicy> {
7
+ const envPolicy = {
8
+ minLength: DraxConfig.getOrLoad(PasswordPolicyConfig.MinLength, "number"),
9
+ maxLength: DraxConfig.getOrLoad(PasswordPolicyConfig.MaxLength, "number"),
10
+ requireUppercase: DraxConfig.getOrLoad(PasswordPolicyConfig.RequireUppercase, "boolean"),
11
+ requireLowercase: DraxConfig.getOrLoad(PasswordPolicyConfig.RequireLowercase, "boolean"),
12
+ requireNumber: DraxConfig.getOrLoad(PasswordPolicyConfig.RequireNumber, "boolean"),
13
+ requireSpecialChar: DraxConfig.getOrLoad(PasswordPolicyConfig.RequireSpecialChar, "boolean"),
14
+ disallowSpaces: DraxConfig.getOrLoad(PasswordPolicyConfig.DisallowSpaces, "boolean"),
15
+ preventReuse: DraxConfig.getOrLoad(PasswordPolicyConfig.PreventReuse, "number"),
16
+ expirationDays: DraxConfig.getOrLoad(PasswordPolicyConfig.ExpirationDays, "number")
17
+ }
18
+
19
+ return PartialPasswordPolicySchema.parse(
20
+ Object.fromEntries(Object.entries(envPolicy).filter(([, value]) => value !== undefined))
21
+ )
22
+ }
23
+
24
+ export default getPasswordEnvPolicy
25
+ export {getPasswordEnvPolicy}
@@ -6,7 +6,7 @@ const user = {
6
6
  groups: [],
7
7
  username: "root",
8
8
  email: "root@example.com",
9
- password: "12345678",
9
+ password: "Root1234",
10
10
  name: "root",
11
11
  phone: "123456789",
12
12
  role: "646a661e44c93567c23d8d62",
@@ -6,7 +6,7 @@ const user = {
6
6
  groups: [],
7
7
  username: "root",
8
8
  email: "root@example.com",
9
- password: "123",
9
+ password: "Root1234",
10
10
  name: "root",
11
11
  phone: "123456789",
12
12
  avatar: "asd",
@@ -2,7 +2,7 @@ import {IUserCreate} from "@drax/identity-share";
2
2
 
3
3
  const USER1: IUserCreate = {
4
4
  active: true,
5
- password: "12345678",
5
+ password: "User1234",
6
6
  phone: "",
7
7
  role: "",
8
8
  name: "John Wick",
@@ -11,7 +11,7 @@ const USER1: IUserCreate = {
11
11
  }
12
12
  const USER2: IUserCreate = {
13
13
  active: true,
14
- password: "12345678",
14
+ password: "User1234",
15
15
  phone: "",
16
16
  role: "",
17
17
  name: "John Rambo",
@@ -20,7 +20,7 @@ const USER2: IUserCreate = {
20
20
  }
21
21
  const USER3: IUserCreate = {
22
22
  active: true,
23
- password: "12345678",
23
+ password: "User1234",
24
24
  phone: "",
25
25
  role: "",
26
26
  name: "John Depp",
@@ -0,0 +1,33 @@
1
+ import {afterAll, beforeAll, describe, expect, it} from "vitest";
2
+ import {LoadIdentityConfigFromEnv} from "../../src/setup/LoadIdentityConfigFromEnv.js";
3
+ import {TestSetup} from "../setup/TestSetup";
4
+
5
+ describe("Password Policy Route Test", () => {
6
+ const testSetup = new TestSetup("sqlite")
7
+
8
+ beforeAll(async () => {
9
+ await testSetup.setup()
10
+ process.env.PASSWORD_POLICY_REQUIRE_SPECIAL_CHAR = "true"
11
+ process.env.PASSWORD_POLICY_MIN_LENGTH = "18"
12
+ LoadIdentityConfigFromEnv()
13
+ })
14
+
15
+ afterAll(async () => {
16
+ delete process.env.PASSWORD_POLICY_MIN_LENGTH
17
+ delete process.env.PASSWORD_POLICY_REQUIRE_SPECIAL_CHAR
18
+ await testSetup.dropAndClose()
19
+ })
20
+
21
+ it("returns the final effective policy", async () => {
22
+ const response = await testSetup.fastifyInstance.inject({
23
+ method: "GET",
24
+ url: "/api/auth/password-policy"
25
+ })
26
+
27
+ expect(response.statusCode).toBe(200)
28
+ const body = response.json()
29
+ expect(body.minLength).toBe(18)
30
+ expect(body.requireSpecialChar).toBe(true)
31
+ expect(body.requireUppercase).toBe(true)
32
+ })
33
+ })