@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.
- package/dist/config/PasswordPolicyConfig.js +14 -0
- package/dist/controllers/UserApiKeyController.js +8 -119
- package/dist/controllers/UserController.js +10 -0
- package/dist/factory/PasswordPolicyResolverFactory.js +9 -0
- package/dist/factory/PasswordPolicyServiceFactory.js +27 -0
- package/dist/factory/UserPasswordHistoryServiceFactory.js +25 -0
- package/dist/factory/UserServiceFactory.js +3 -1
- package/dist/graphql/resolvers/user-api-key.resolvers.js +2 -3
- package/dist/index.js +20 -8
- package/dist/interfaces/IUserPasswordHistory.js +1 -0
- package/dist/interfaces/IUserPasswordHistoryRepository.js +1 -0
- package/dist/middleware/apiKeyMiddleware.js +1 -1
- package/dist/middleware/rbacMiddleware.js +1 -1
- package/dist/models/UserPasswordHistoryModel.js +30 -0
- package/dist/permissions/UserApiKeyPermissions.js +1 -2
- package/dist/policies/defaultPasswordPolicy.js +12 -0
- package/dist/repository/mongo/UserPasswordHistoryMongoRepository.js +20 -0
- package/dist/repository/sqlite/UserPasswordHistorySqliteRepository.js +38 -0
- package/dist/resolver/PasswordPolicyResolver.js +27 -0
- package/dist/routes/UserRoutes.js +10 -0
- package/dist/schemas/PasswordPolicySchema.js +18 -0
- package/dist/schemas/RegisterSchema.js +1 -3
- package/dist/schemas/UserSchema.js +1 -3
- package/dist/security/constants/defaultPasswordPolicy.js +12 -0
- package/dist/security/interfaces/IPasswordPolicy.js +1 -0
- package/dist/security/interfaces/IPasswordPolicyProjectContext.js +1 -0
- package/dist/security/schemas/PasswordPolicySchema.js +18 -0
- package/dist/security/services/PasswordPolicyResolver.js +21 -0
- package/dist/security/services/PasswordPolicyService.js +147 -0
- package/dist/security/utils/PasswordPolicySchemaFactory.js +36 -0
- package/dist/security/utils/getPasswordEnvPolicy.js +19 -0
- package/dist/services/PasswordPolicyService.js +147 -0
- package/dist/services/UserPasswordHistoryService.js +18 -0
- package/dist/services/UserService.js +34 -9
- package/dist/setup/LoadIdentityConfigFromEnv.js +10 -0
- package/dist/setup/SetProjectPasswordPolicy.js +7 -0
- package/dist/utils/PasswordPolicySchemaFactory.js +36 -0
- package/dist/utils/getPasswordEnvPolicy.js +19 -0
- package/docs/password-policy.md +33 -0
- package/package.json +6 -6
- package/src/config/PasswordPolicyConfig.ts +14 -0
- package/src/controllers/UserApiKeyController.ts +15 -129
- package/src/controllers/UserController.ts +10 -1
- package/src/factory/PasswordPolicyResolverFactory.ts +14 -0
- package/src/factory/PasswordPolicyServiceFactory.ts +38 -0
- package/src/factory/UserPasswordHistoryServiceFactory.ts +31 -0
- package/src/factory/UserServiceFactory.ts +7 -1
- package/src/graphql/resolvers/user-api-key.resolvers.ts +2 -3
- package/src/index.ts +28 -3
- package/src/interfaces/IUserPasswordHistory.ts +21 -0
- package/src/interfaces/IUserPasswordHistoryRepository.ts +8 -0
- package/src/middleware/apiKeyMiddleware.ts +1 -1
- package/src/middleware/rbacMiddleware.ts +1 -1
- package/src/models/UserPasswordHistoryModel.ts +42 -0
- package/src/permissions/UserApiKeyPermissions.ts +1 -2
- package/src/policies/defaultPasswordPolicy.ts +17 -0
- package/src/repository/mongo/UserPasswordHistoryMongoRepository.ts +25 -0
- package/src/repository/sqlite/UserPasswordHistorySqliteRepository.ts +47 -0
- package/src/resolver/PasswordPolicyResolver.ts +33 -0
- package/src/routes/UserRoutes.ts +11 -0
- package/src/schemas/PasswordPolicySchema.ts +29 -0
- package/src/schemas/RegisterSchema.ts +1 -3
- package/src/schemas/UserSchema.ts +1 -3
- package/src/services/PasswordPolicyService.ts +184 -0
- package/src/services/UserPasswordHistoryService.ts +23 -0
- package/src/services/UserService.ts +38 -9
- package/src/setup/LoadIdentityConfigFromEnv.ts +11 -0
- package/src/setup/SetProjectPasswordPolicy.ts +12 -0
- package/src/utils/PasswordPolicySchemaFactory.ts +47 -0
- package/src/utils/getPasswordEnvPolicy.ts +25 -0
- package/test/data-obj/users/root-mongo-user.ts +1 -1
- package/test/data-obj/users/root-sqlite-user.ts +1 -1
- package/test/endpoints/data/users-data.ts +3 -3
- package/test/endpoints/password-policy-route.test.ts +33 -0
- package/test/endpoints/user-route.test.ts +17 -4
- package/test/security/password-policy-resolver.test.ts +55 -0
- package/test/security/password-policy-schema-factory.test.ts +40 -0
- package/test/services/user-service.test.ts +218 -31
- package/test/setup/TestSetup.ts +22 -4
- package/test/setup/data/basic-user.ts +1 -1
- package/test/setup/data/root-user.ts +1 -1
- package/tsconfig.tsbuildinfo +1 -1
- package/types/config/PasswordPolicyConfig.d.ts +14 -0
- package/types/config/PasswordPolicyConfig.d.ts.map +1 -0
- package/types/controllers/UserApiKeyController.d.ts +10 -6
- package/types/controllers/UserApiKeyController.d.ts.map +1 -1
- package/types/controllers/UserController.d.ts +1 -0
- package/types/controllers/UserController.d.ts.map +1 -1
- package/types/factory/PasswordPolicyResolverFactory.d.ts +4 -0
- package/types/factory/PasswordPolicyResolverFactory.d.ts.map +1 -0
- package/types/factory/PasswordPolicyServiceFactory.d.ts +4 -0
- package/types/factory/PasswordPolicyServiceFactory.d.ts.map +1 -0
- package/types/factory/UserPasswordHistoryServiceFactory.d.ts +4 -0
- package/types/factory/UserPasswordHistoryServiceFactory.d.ts.map +1 -0
- package/types/factory/UserServiceFactory.d.ts.map +1 -1
- package/types/graphql/resolvers/user-api-key.resolvers.d.ts.map +1 -1
- package/types/index.d.ts +15 -2
- package/types/index.d.ts.map +1 -1
- package/types/interfaces/IUserPasswordHistory.d.ts +17 -0
- package/types/interfaces/IUserPasswordHistory.d.ts.map +1 -0
- package/types/interfaces/IUserPasswordHistoryRepository.d.ts +7 -0
- package/types/interfaces/IUserPasswordHistoryRepository.d.ts.map +1 -0
- package/types/models/UserPasswordHistoryModel.d.ts +15 -0
- package/types/models/UserPasswordHistoryModel.d.ts.map +1 -0
- package/types/permissions/UserApiKeyPermissions.d.ts +1 -2
- package/types/permissions/UserApiKeyPermissions.d.ts.map +1 -1
- package/types/permissions/index.d.ts +0 -2
- package/types/permissions/index.d.ts.map +1 -1
- package/types/policies/defaultPasswordPolicy.d.ts +4 -0
- package/types/policies/defaultPasswordPolicy.d.ts.map +1 -0
- package/types/repository/mongo/UserPasswordHistoryMongoRepository.d.ts +10 -0
- package/types/repository/mongo/UserPasswordHistoryMongoRepository.d.ts.map +1 -0
- package/types/repository/sqlite/UserPasswordHistorySqliteRepository.d.ts +25 -0
- package/types/repository/sqlite/UserPasswordHistorySqliteRepository.d.ts.map +1 -0
- package/types/resolver/PasswordPolicyResolver.d.ts +10 -0
- package/types/resolver/PasswordPolicyResolver.d.ts.map +1 -0
- package/types/routes/UserRoutes.d.ts.map +1 -1
- package/types/schemas/PasswordPolicySchema.d.ts +25 -0
- package/types/schemas/PasswordPolicySchema.d.ts.map +1 -0
- package/types/schemas/RegisterSchema.d.ts.map +1 -1
- package/types/schemas/UserSchema.d.ts.map +1 -1
- package/types/security/constants/defaultPasswordPolicy.d.ts +4 -0
- package/types/security/constants/defaultPasswordPolicy.d.ts.map +1 -0
- package/types/security/interfaces/IPasswordPolicy.d.ts +13 -0
- package/types/security/interfaces/IPasswordPolicy.d.ts.map +1 -0
- package/types/security/interfaces/IPasswordPolicyProjectContext.d.ts +6 -0
- package/types/security/interfaces/IPasswordPolicyProjectContext.d.ts.map +1 -0
- package/types/security/schemas/PasswordPolicySchema.d.ts +25 -0
- package/types/security/schemas/PasswordPolicySchema.d.ts.map +1 -0
- package/types/security/services/PasswordPolicyResolver.d.ts +9 -0
- package/types/security/services/PasswordPolicyResolver.d.ts.map +1 -0
- package/types/security/services/PasswordPolicyService.d.ts +35 -0
- package/types/security/services/PasswordPolicyService.d.ts.map +1 -0
- package/types/security/utils/PasswordPolicySchemaFactory.d.ts +9 -0
- package/types/security/utils/PasswordPolicySchemaFactory.d.ts.map +1 -0
- package/types/security/utils/getPasswordEnvPolicy.d.ts +5 -0
- package/types/security/utils/getPasswordEnvPolicy.d.ts.map +1 -0
- package/types/services/PasswordPolicyService.d.ts +34 -0
- package/types/services/PasswordPolicyService.d.ts.map +1 -0
- package/types/services/UserPasswordHistoryService.d.ts +10 -0
- package/types/services/UserPasswordHistoryService.d.ts.map +1 -0
- package/types/services/UserService.d.ts +5 -1
- package/types/services/UserService.d.ts.map +1 -1
- package/types/setup/LoadIdentityConfigFromEnv.d.ts.map +1 -1
- package/types/setup/SetProjectPasswordPolicy.d.ts +5 -0
- package/types/setup/SetProjectPasswordPolicy.d.ts.map +1 -0
- package/types/utils/PasswordPolicySchemaFactory.d.ts +9 -0
- package/types/utils/PasswordPolicySchemaFactory.d.ts.map +1 -0
- package/types/utils/getPasswordEnvPolicy.d.ts +5 -0
- 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}
|
package/src/routes/UserRoutes.ts
CHANGED
|
@@ -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(
|
|
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 :
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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}
|
|
@@ -2,7 +2,7 @@ import {IUserCreate} from "@drax/identity-share";
|
|
|
2
2
|
|
|
3
3
|
const USER1: IUserCreate = {
|
|
4
4
|
active: true,
|
|
5
|
-
password: "
|
|
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: "
|
|
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: "
|
|
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
|
+
})
|