@furystack/security 1.0.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 (139) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +339 -0
  3. package/README.md +4 -0
  4. package/dist/crypto-password-hasher.d.ts +14 -0
  5. package/dist/crypto-password-hasher.d.ts.map +1 -0
  6. package/dist/crypto-password-hasher.js +39 -0
  7. package/dist/crypto-password-hasher.js.map +1 -0
  8. package/dist/errors/index.d.ts +4 -0
  9. package/dist/errors/index.d.ts.map +1 -0
  10. package/dist/errors/index.js +7 -0
  11. package/dist/errors/index.js.map +1 -0
  12. package/dist/errors/password-complexity-error.d.ts +15 -0
  13. package/dist/errors/password-complexity-error.d.ts.map +1 -0
  14. package/dist/errors/password-complexity-error.js +15 -0
  15. package/dist/errors/password-complexity-error.js.map +1 -0
  16. package/dist/errors/unauthenticated-error.d.ts +6 -0
  17. package/dist/errors/unauthenticated-error.d.ts.map +1 -0
  18. package/dist/errors/unauthenticated-error.js +10 -0
  19. package/dist/errors/unauthenticated-error.js.map +1 -0
  20. package/dist/index.d.ts +9 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +12 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/injector-extensions.d.ts +13 -0
  25. package/dist/injector-extensions.d.ts.map +1 -0
  26. package/dist/injector-extensions.js +11 -0
  27. package/dist/injector-extensions.js.map +1 -0
  28. package/dist/models/index.d.ts +6 -0
  29. package/dist/models/index.d.ts.map +1 -0
  30. package/dist/models/index.js +9 -0
  31. package/dist/models/index.js.map +1 -0
  32. package/dist/models/password-check-result.d.ts +10 -0
  33. package/dist/models/password-check-result.d.ts.map +1 -0
  34. package/dist/models/password-check-result.js +3 -0
  35. package/dist/models/password-check-result.js.map +1 -0
  36. package/dist/models/password-complexity-result.d.ts +10 -0
  37. package/dist/models/password-complexity-result.d.ts.map +1 -0
  38. package/dist/models/password-complexity-result.js +3 -0
  39. package/dist/models/password-complexity-result.js.map +1 -0
  40. package/dist/models/password-complexity-rule.d.ts +6 -0
  41. package/dist/models/password-complexity-rule.d.ts.map +1 -0
  42. package/dist/models/password-complexity-rule.js +3 -0
  43. package/dist/models/password-complexity-rule.js.map +1 -0
  44. package/dist/models/password-credential.d.ts +22 -0
  45. package/dist/models/password-credential.d.ts.map +1 -0
  46. package/dist/models/password-credential.js +10 -0
  47. package/dist/models/password-credential.js.map +1 -0
  48. package/dist/models/password-reset-token.d.ts +15 -0
  49. package/dist/models/password-reset-token.d.ts.map +1 -0
  50. package/dist/models/password-reset-token.js +7 -0
  51. package/dist/models/password-reset-token.js.map +1 -0
  52. package/dist/password-authenticator.d.ts +38 -0
  53. package/dist/password-authenticator.d.ts.map +1 -0
  54. package/dist/password-authenticator.js +99 -0
  55. package/dist/password-authenticator.js.map +1 -0
  56. package/dist/password-authenticator.spec.d.ts +2 -0
  57. package/dist/password-authenticator.spec.d.ts.map +1 -0
  58. package/dist/password-authenticator.spec.js +309 -0
  59. package/dist/password-authenticator.spec.js.map +1 -0
  60. package/dist/password-complexity-rules/contains-lowercase.d.ts +7 -0
  61. package/dist/password-complexity-rules/contains-lowercase.d.ts.map +1 -0
  62. package/dist/password-complexity-rules/contains-lowercase.js +29 -0
  63. package/dist/password-complexity-rules/contains-lowercase.js.map +1 -0
  64. package/dist/password-complexity-rules/contains-lowercase.spec.d.ts +2 -0
  65. package/dist/password-complexity-rules/contains-lowercase.spec.d.ts.map +1 -0
  66. package/dist/password-complexity-rules/contains-lowercase.spec.js +28 -0
  67. package/dist/password-complexity-rules/contains-lowercase.spec.js.map +1 -0
  68. package/dist/password-complexity-rules/contains-uppercase.d.ts +7 -0
  69. package/dist/password-complexity-rules/contains-uppercase.d.ts.map +1 -0
  70. package/dist/password-complexity-rules/contains-uppercase.js +27 -0
  71. package/dist/password-complexity-rules/contains-uppercase.js.map +1 -0
  72. package/dist/password-complexity-rules/contains-uppercase.spec.d.ts +2 -0
  73. package/dist/password-complexity-rules/contains-uppercase.spec.d.ts.map +1 -0
  74. package/dist/password-complexity-rules/contains-uppercase.spec.js +28 -0
  75. package/dist/password-complexity-rules/contains-uppercase.spec.js.map +1 -0
  76. package/dist/password-complexity-rules/index.d.ts +5 -0
  77. package/dist/password-complexity-rules/index.d.ts.map +1 -0
  78. package/dist/password-complexity-rules/index.js +8 -0
  79. package/dist/password-complexity-rules/index.js.map +1 -0
  80. package/dist/password-complexity-rules/max-length.d.ts +7 -0
  81. package/dist/password-complexity-rules/max-length.d.ts.map +1 -0
  82. package/dist/password-complexity-rules/max-length.js +19 -0
  83. package/dist/password-complexity-rules/max-length.js.map +1 -0
  84. package/dist/password-complexity-rules/max-length.spec.d.ts +2 -0
  85. package/dist/password-complexity-rules/max-length.spec.d.ts.map +1 -0
  86. package/dist/password-complexity-rules/max-length.spec.js +17 -0
  87. package/dist/password-complexity-rules/max-length.spec.js.map +1 -0
  88. package/dist/password-complexity-rules/min-length.d.ts +7 -0
  89. package/dist/password-complexity-rules/min-length.d.ts.map +1 -0
  90. package/dist/password-complexity-rules/min-length.js +19 -0
  91. package/dist/password-complexity-rules/min-length.js.map +1 -0
  92. package/dist/password-complexity-rules/min-length.spec.d.ts +2 -0
  93. package/dist/password-complexity-rules/min-length.spec.d.ts.map +1 -0
  94. package/dist/password-complexity-rules/min-length.spec.js +17 -0
  95. package/dist/password-complexity-rules/min-length.spec.js.map +1 -0
  96. package/dist/password-hasher.d.ts +6 -0
  97. package/dist/password-hasher.d.ts.map +1 -0
  98. package/dist/password-hasher.js +18 -0
  99. package/dist/password-hasher.js.map +1 -0
  100. package/dist/security-policy-manager.d.ts +26 -0
  101. package/dist/security-policy-manager.d.ts.map +1 -0
  102. package/dist/security-policy-manager.js +55 -0
  103. package/dist/security-policy-manager.js.map +1 -0
  104. package/dist/security-policy-manager.spec.d.ts +2 -0
  105. package/dist/security-policy-manager.spec.d.ts.map +1 -0
  106. package/dist/security-policy-manager.spec.js +19 -0
  107. package/dist/security-policy-manager.spec.js.map +1 -0
  108. package/dist/security-policy.d.ts +26 -0
  109. package/dist/security-policy.d.ts.map +1 -0
  110. package/dist/security-policy.js +35 -0
  111. package/dist/security-policy.js.map +1 -0
  112. package/package.json +45 -0
  113. package/src/crypto-password-hasher.ts +44 -0
  114. package/src/errors/index.ts +3 -0
  115. package/src/errors/password-complexity-error.ts +9 -0
  116. package/src/errors/unauthenticated-error.ts +4 -0
  117. package/src/index.ts +9 -0
  118. package/src/injector-extensions.ts +21 -0
  119. package/src/models/index.ts +5 -0
  120. package/src/models/password-check-result.ts +11 -0
  121. package/src/models/password-complexity-result.ts +11 -0
  122. package/src/models/password-complexity-rule.ts +6 -0
  123. package/src/models/password-credential.ts +23 -0
  124. package/src/models/password-reset-token.ts +14 -0
  125. package/src/password-authenticator.spec.ts +393 -0
  126. package/src/password-authenticator.ts +101 -0
  127. package/src/password-complexity-rules/contains-lowercase.spec.ts +34 -0
  128. package/src/password-complexity-rules/contains-lowercase.ts +27 -0
  129. package/src/password-complexity-rules/contains-uppercase.spec.ts +34 -0
  130. package/src/password-complexity-rules/contains-uppercase.ts +24 -0
  131. package/src/password-complexity-rules/index.ts +4 -0
  132. package/src/password-complexity-rules/max-length.spec.ts +17 -0
  133. package/src/password-complexity-rules/max-length.ts +16 -0
  134. package/src/password-complexity-rules/min-length.spec.ts +19 -0
  135. package/src/password-complexity-rules/min-length.ts +16 -0
  136. package/src/password-hasher.ts +13 -0
  137. package/src/security-policy-manager.spec.ts +16 -0
  138. package/src/security-policy-manager.ts +53 -0
  139. package/src/security-policy.ts +32 -0
@@ -0,0 +1,44 @@
1
+ import { Injectable } from '@furystack/inject'
2
+ import { PasswordHasher } from './password-hasher'
3
+ import { randomBytes, pbkdf2 } from 'crypto'
4
+ import { PasswordCheckResult, PasswordCredential } from './models'
5
+
6
+ @Injectable({ lifetime: 'singleton' })
7
+ export class CryptoPasswordHasher implements PasswordHasher {
8
+ private readonly iterations = 1000
9
+ private readonly keylen = 64
10
+
11
+ public async verifyCredential(
12
+ password: string,
13
+ { passwordHash, salt }: PasswordCredential,
14
+ ): Promise<PasswordCheckResult> {
15
+ const newHash = await new Promise((resolve, reject) =>
16
+ pbkdf2(password, salt, this.iterations, this.keylen, 'sha512', (err, key) =>
17
+ err ? reject(err) : resolve(key.toString('hex')),
18
+ ),
19
+ )
20
+ if (newHash === passwordHash) {
21
+ return {
22
+ isValid: true,
23
+ }
24
+ }
25
+ return {
26
+ isValid: false,
27
+ reason: 'badUsernameOrPassword',
28
+ }
29
+ }
30
+ public async createCredential(userName: string, password: string) {
31
+ const salt = randomBytes(16).toString('hex')
32
+ const passwordHash = await new Promise<string>((resolve, reject) =>
33
+ pbkdf2(password, salt, this.iterations, this.keylen, 'sha512', (err, key) =>
34
+ err ? reject(err) : resolve(key.toString('hex')),
35
+ ),
36
+ )
37
+ return {
38
+ userName,
39
+ salt,
40
+ passwordHash,
41
+ creationDate: new Date().toISOString(),
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,3 @@
1
+ export * from './password-complexity-error'
2
+ export * from './unauthenticated-error'
3
+ export * from './password-complexity-error'
@@ -0,0 +1,9 @@
1
+ export class PasswordComplexityError extends Error {
2
+ /**
3
+ * @param result A detailed error result array
4
+ * @param message The Error message
5
+ */
6
+ constructor(public readonly result: Array<{ message: string; rule: string }>, message?: string) {
7
+ super(message)
8
+ }
9
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Error that can be thrown if an authentication is required but not provided
3
+ */
4
+ export class UnauthenticatedError extends Error {}
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export * from './crypto-password-hasher'
2
+ export * from './errors'
3
+ export * from './models'
4
+ export * from './password-authenticator'
5
+ export * from './password-complexity-rules'
6
+ export * from './password-hasher'
7
+ export * from './security-policy-manager'
8
+
9
+ import './injector-extensions'
@@ -0,0 +1,21 @@
1
+ import { Injector } from '@furystack/inject/dist/injector'
2
+ import { SecurityPolicy } from './security-policy'
3
+
4
+ declare module '@furystack/inject/dist/injector' {
5
+ /**
6
+ * Extended Injector with Password Policy related methods
7
+ */
8
+ export interface Injector {
9
+ /**
10
+ * Sets up the @furystack/security with the provided settings
11
+ */
12
+ usePasswordPolicy: (policy?: Partial<SecurityPolicy>) => this
13
+ }
14
+ }
15
+
16
+ Injector.prototype.usePasswordPolicy = function (policy) {
17
+ const plainPolicy = new SecurityPolicy()
18
+ policy && Object.assign(plainPolicy, policy)
19
+ this.setExplicitInstance(plainPolicy, SecurityPolicy)
20
+ return this
21
+ }
@@ -0,0 +1,5 @@
1
+ export * from './password-check-result'
2
+ export * from './password-complexity-result'
3
+ export * from './password-complexity-rule'
4
+ export * from './password-credential'
5
+ export * from './password-reset-token'
@@ -0,0 +1,11 @@
1
+ export interface PasswordCheckSuccessResult {
2
+ isValid: true
3
+ willExpire?: string
4
+ }
5
+
6
+ export interface PasswordCheckFailedResult {
7
+ isValid: false
8
+ reason: 'badUsernameOrPassword' | 'passwordExpired' // TODO | 'challengeNeeded'
9
+ }
10
+
11
+ export type PasswordCheckResult = PasswordCheckFailedResult | PasswordCheckSuccessResult
@@ -0,0 +1,11 @@
1
+ export interface PasswordComplexitySuccessResult {
2
+ success: true
3
+ }
4
+
5
+ export interface PasswordComplexityFailedResult {
6
+ success: false
7
+ message: string
8
+ rule: string
9
+ }
10
+
11
+ export type PasswordComplexityResult = PasswordComplexitySuccessResult | PasswordComplexityFailedResult
@@ -0,0 +1,6 @@
1
+ import type { PasswordComplexityResult } from './password-complexity-result'
2
+
3
+ export interface PasswordComplexityRule {
4
+ name: string
5
+ check: (passwordString: string) => Promise<PasswordComplexityResult>
6
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Entry that stores credential info for password login
3
+ */
4
+ export class PasswordCredential {
5
+ /**
6
+ * The unique name of the user
7
+ */
8
+ userName!: string
9
+ /**
10
+ * The hashed password value
11
+ */
12
+ passwordHash!: string
13
+
14
+ /**
15
+ * Salt value for password hashing // TODO
16
+ */
17
+ salt!: string
18
+
19
+ /**
20
+ * The Creation date in ISO String format
21
+ */
22
+ creationDate!: string
23
+ }
@@ -0,0 +1,14 @@
1
+ export class PasswordResetToken {
2
+ /**
3
+ * The related user name
4
+ */
5
+ userName!: string
6
+ /**
7
+ * The Token value - should be some kind of generated UUID that can be included e.g. in URLs
8
+ */
9
+ token!: string
10
+ /**
11
+ * The creation date in an ISO datetime format
12
+ */
13
+ createdAt!: string
14
+ }
@@ -0,0 +1,393 @@
1
+ import { InMemoryStore, StoreManager, User } from '@furystack/core'
2
+ import { Injector } from '@furystack/inject'
3
+ import { using, usingAsync } from '@furystack/utils'
4
+ import './injector-extensions'
5
+ import { v4 } from 'uuid'
6
+ import { PasswordCheckFailedResult, PasswordCredential, PasswordResetToken } from './models'
7
+ import { PasswordAuthenticator } from './password-authenticator'
8
+ import { createMinLengthComplexityRule } from './password-complexity-rules'
9
+ import { SecurityPolicyManager } from './security-policy-manager'
10
+ import { PasswordComplexityError, UnauthenticatedError } from './errors'
11
+
12
+ describe('PasswordAuthenticator', () => {
13
+ it('Should be instantiated', () => {
14
+ using(new Injector(), (i) => {
15
+ const auth = i.getInstance(PasswordAuthenticator)
16
+ expect(auth).toBeInstanceOf(PasswordAuthenticator)
17
+ })
18
+ })
19
+
20
+ it('Should inherit policy from the extension method', () => {
21
+ using(new Injector(), (i) => {
22
+ i.usePasswordPolicy({
23
+ passwordComplexityRules: [createMinLengthComplexityRule(3)],
24
+ })
25
+ const auth = i.getInstance(PasswordAuthenticator)
26
+ expect(auth).toBeInstanceOf(PasswordAuthenticator)
27
+ expect(auth.policyManager.policy.passwordComplexityRules[0].name).toBe('minLength')
28
+ })
29
+ })
30
+
31
+ describe('checkPasswordForUser', () => {
32
+ it('Should return a truthy value for valid username and password', async () => {
33
+ await usingAsync(new Injector(), async (i) => {
34
+ const userName = v4()
35
+ const password = v4()
36
+ i.setupStores((sm) =>
37
+ sm
38
+ .addStore(new InMemoryStore({ model: PasswordCredential, primaryKey: 'userName' }))
39
+ .addStore(new InMemoryStore({ model: User, primaryKey: 'username' })),
40
+ )
41
+ i.usePasswordPolicy({})
42
+ const authenticator = i.getInstance(PasswordAuthenticator)
43
+ const hasher = authenticator.getHasher()
44
+ const passwordStore = i.getInstance(StoreManager).getStoreFor(PasswordCredential, 'userName')
45
+
46
+ const entry = await hasher.createCredential(userName, password)
47
+ await passwordStore.add(entry)
48
+
49
+ const successResult = await authenticator.checkPasswordForUser(userName, password)
50
+ expect(successResult.isValid).toBeTruthy()
51
+ })
52
+ })
53
+
54
+ it('Should return a falsy value for invalid username', async () => {
55
+ await usingAsync(new Injector(), async (i) => {
56
+ const userName = v4()
57
+ const password = v4()
58
+ i.setupStores((sm) =>
59
+ sm
60
+ .addStore(new InMemoryStore({ model: PasswordCredential, primaryKey: 'userName' }))
61
+ .addStore(new InMemoryStore({ model: User, primaryKey: 'username' })),
62
+ )
63
+ i.usePasswordPolicy({})
64
+ const authenticator = i.getInstance(PasswordAuthenticator)
65
+ const hasher = authenticator.getHasher()
66
+ const passwordStore = i.getInstance(StoreManager).getStoreFor(PasswordCredential, 'userName')
67
+
68
+ const entry = await hasher.createCredential(userName, password)
69
+ await passwordStore.add(entry)
70
+
71
+ const failResult = await authenticator.checkPasswordForUser('invalidUsername', password)
72
+ expect(failResult.isValid).toBeFalsy()
73
+ expect((failResult as PasswordCheckFailedResult).reason).toBe('badUsernameOrPassword')
74
+ })
75
+ })
76
+
77
+ it('Should return a falsy value for invalid password', async () => {
78
+ await usingAsync(new Injector(), async (i) => {
79
+ const userName = v4()
80
+ const password = v4()
81
+ i.setupStores((sm) =>
82
+ sm
83
+ .addStore(new InMemoryStore({ model: PasswordCredential, primaryKey: 'userName' }))
84
+ .addStore(new InMemoryStore({ model: User, primaryKey: 'username' })),
85
+ )
86
+ i.usePasswordPolicy({})
87
+ const authenticator = i.getInstance(PasswordAuthenticator)
88
+ const hasher = authenticator.getHasher()
89
+ const passwordStore = i.getInstance(StoreManager).getStoreFor(PasswordCredential, 'userName')
90
+
91
+ const entry = await hasher.createCredential(userName, password)
92
+ await passwordStore.add(entry)
93
+
94
+ const failResult = await authenticator.checkPasswordForUser(userName, 'someInvalidPassword')
95
+ expect(failResult.isValid).toBeFalsy()
96
+ expect((failResult as PasswordCheckFailedResult).reason).toBe('badUsernameOrPassword')
97
+ })
98
+ })
99
+
100
+ it('Should return a falsy value for an expired password', async () => {
101
+ await usingAsync(new Injector(), async (i) => {
102
+ const userName = v4()
103
+ const password = v4()
104
+ i.setupStores((sm) =>
105
+ sm
106
+ .addStore(new InMemoryStore({ model: PasswordCredential, primaryKey: 'userName' }))
107
+ .addStore(new InMemoryStore({ model: User, primaryKey: 'username' })),
108
+ )
109
+ i.usePasswordPolicy({ passwordExpirationDays: 1 })
110
+ const authenticator = i.getInstance(PasswordAuthenticator)
111
+ const hasher = authenticator.getHasher()
112
+ const passwordStore = i.getInstance(StoreManager).getStoreFor(PasswordCredential, 'userName')
113
+
114
+ const entry = await hasher.createCredential(userName, password)
115
+
116
+ const creationDate = new Date()
117
+ creationDate.setDate(creationDate.getDate() - 2)
118
+
119
+ entry.creationDate = creationDate.toISOString()
120
+
121
+ await passwordStore.add(entry)
122
+
123
+ const failResult = await authenticator.checkPasswordForUser(userName, password)
124
+ expect(failResult.isValid).toBeFalsy()
125
+ expect((failResult as PasswordCheckFailedResult).reason).toBe('passwordExpired')
126
+ })
127
+ })
128
+ })
129
+ describe('setPasswordForUser', () => {
130
+ it('Should be able to set the password for the user using the last password', async () => {
131
+ await usingAsync(new Injector(), async (i) => {
132
+ const userName = v4()
133
+ const lastPassword = v4()
134
+ const password = v4()
135
+ i.setupStores((sm) =>
136
+ sm
137
+ .addStore(new InMemoryStore({ model: PasswordCredential, primaryKey: 'userName' }))
138
+ .addStore(new InMemoryStore({ model: User, primaryKey: 'username' })),
139
+ )
140
+ i.usePasswordPolicy({})
141
+ const authenticator = i.getInstance(PasswordAuthenticator)
142
+ const hasher = authenticator.getHasher()
143
+ const passwordStore = i.getInstance(StoreManager).getStoreFor(PasswordCredential, 'userName')
144
+ const policyManager = i.getInstance(SecurityPolicyManager)
145
+ const pmSpy = jest.spyOn(policyManager, 'matchPasswordComplexityRules')
146
+
147
+ const entry = await hasher.createCredential(userName, lastPassword)
148
+
149
+ await passwordStore.add(entry)
150
+ await authenticator.setPasswordForUser(userName, lastPassword, password)
151
+
152
+ expect(pmSpy).toBeCalled()
153
+
154
+ const entryCount = await passwordStore.count({ userName: { $eq: userName } })
155
+
156
+ expect(entryCount).toBe(1) // old entry should be removed
157
+
158
+ const successResult = await authenticator.checkPasswordForUser(userName, password)
159
+ expect(successResult.isValid).toBeTruthy()
160
+
161
+ // Password updated, cannot use the last password
162
+ const failResult = await authenticator.checkPasswordForUser(userName, lastPassword)
163
+ expect(failResult.isValid).toBeFalsy()
164
+ expect((failResult as PasswordCheckFailedResult).reason).toBe('badUsernameOrPassword')
165
+ })
166
+ })
167
+
168
+ it('Should throw PasswordComplexityError if the complexity rules are not respected', async () => {
169
+ await usingAsync(new Injector(), async (i) => {
170
+ const userName = v4()
171
+ const lastPassword = v4()
172
+ const password = ''
173
+ i.setupStores((sm) =>
174
+ sm
175
+ .addStore(new InMemoryStore({ model: PasswordCredential, primaryKey: 'userName' }))
176
+ .addStore(new InMemoryStore({ model: User, primaryKey: 'username' })),
177
+ )
178
+ i.usePasswordPolicy({ passwordComplexityRules: [createMinLengthComplexityRule(3)] })
179
+ const authenticator = i.getInstance(PasswordAuthenticator)
180
+ const hasher = authenticator.getHasher()
181
+ const passwordStore = i.getInstance(StoreManager).getStoreFor(PasswordCredential, 'userName')
182
+ const policyManager = i.getInstance(SecurityPolicyManager)
183
+ const pmSpy = jest.spyOn(policyManager, 'matchPasswordComplexityRules')
184
+
185
+ const entry = await hasher.createCredential(userName, lastPassword)
186
+
187
+ await passwordStore.add(entry)
188
+
189
+ await expect(authenticator.setPasswordForUser(userName, lastPassword, password)).rejects.toThrowError(
190
+ PasswordComplexityError,
191
+ )
192
+
193
+ expect(pmSpy).toBeCalled()
194
+
195
+ const entryCount = await passwordStore.count({ userName: { $eq: userName } })
196
+
197
+ expect(entryCount).toBe(1) // old entry should be removed
198
+
199
+ const failResult = await authenticator.checkPasswordForUser(userName, password)
200
+ // Password NOT updated, can use the last password
201
+ expect(failResult.isValid).toBeFalsy()
202
+ expect((failResult as PasswordCheckFailedResult).reason).toBe('badUsernameOrPassword')
203
+
204
+ // Password NOT updated, cannot use the new password
205
+ const successResult = await authenticator.checkPasswordForUser(userName, lastPassword)
206
+ expect(successResult.isValid).toBeTruthy()
207
+ })
208
+ })
209
+
210
+ it('Should throw UnauthenticatedError if the last password is not valid', async () => {
211
+ await usingAsync(new Injector(), async (i) => {
212
+ const userName = v4()
213
+ const lastPassword = v4()
214
+ const password = v4()
215
+ i.setupStores((sm) =>
216
+ sm
217
+ .addStore(new InMemoryStore({ model: PasswordCredential, primaryKey: 'userName' }))
218
+ .addStore(new InMemoryStore({ model: User, primaryKey: 'username' })),
219
+ )
220
+ i.usePasswordPolicy()
221
+ const authenticator = i.getInstance(PasswordAuthenticator)
222
+ const hasher = authenticator.getHasher()
223
+ const passwordStore = i.getInstance(StoreManager).getStoreFor(PasswordCredential, 'userName')
224
+ const policyManager = i.getInstance(SecurityPolicyManager)
225
+ const pmSpy = jest.spyOn(policyManager, 'matchPasswordComplexityRules')
226
+
227
+ const entry = await hasher.createCredential(userName, lastPassword)
228
+
229
+ await passwordStore.add(entry)
230
+
231
+ await expect(authenticator.setPasswordForUser(userName, 'someBadLastPassword', password)).rejects.toThrowError(
232
+ UnauthenticatedError,
233
+ )
234
+
235
+ expect(pmSpy).toBeCalled()
236
+
237
+ const entryCount = await passwordStore.count({ userName: { $eq: userName } })
238
+
239
+ expect(entryCount).toBe(1) // old entry should be removed
240
+
241
+ const failResult = await authenticator.checkPasswordForUser(userName, password)
242
+ // Password NOT updated, can use the last password
243
+ expect(failResult.isValid).toBeFalsy()
244
+ expect((failResult as PasswordCheckFailedResult).reason).toBe('badUsernameOrPassword')
245
+
246
+ // Password NOT updated, cannot use the new password
247
+ const successResult = await authenticator.checkPasswordForUser(userName, lastPassword)
248
+ expect(successResult.isValid).toBeTruthy()
249
+ })
250
+ })
251
+ })
252
+
253
+ describe('resetPasswordForUser', () => {
254
+ it('Should reset the password with the valid token', async () => {
255
+ await usingAsync(new Injector(), async (i) => {
256
+ const userName = v4()
257
+ const lastPassword = v4()
258
+ const password = v4()
259
+ const token = v4()
260
+ i.setupStores((sm) =>
261
+ sm
262
+ .addStore(new InMemoryStore({ model: PasswordCredential, primaryKey: 'userName' }))
263
+ .addStore(new InMemoryStore({ model: User, primaryKey: 'username' }))
264
+ .addStore(new InMemoryStore({ model: PasswordResetToken, primaryKey: 'token' })),
265
+ )
266
+ i.usePasswordPolicy()
267
+ const authenticator = i.getInstance(PasswordAuthenticator)
268
+ const hasher = authenticator.getHasher()
269
+ const passwordStore = i.getInstance(StoreManager).getStoreFor(PasswordCredential, 'userName')
270
+ const resetTokenStore = i.getInstance(StoreManager).getStoreFor(PasswordResetToken, 'token')
271
+
272
+ const entry = await hasher.createCredential(userName, lastPassword)
273
+ await passwordStore.add(entry)
274
+ await resetTokenStore.add({ userName, createdAt: new Date().toISOString(), token })
275
+
276
+ await authenticator.resetPasswordForUser(token, password)
277
+
278
+ const successResult = await authenticator.checkPasswordForUser(userName, password)
279
+ expect(successResult.isValid).toBeTruthy()
280
+
281
+ // Password updated, cannot use the last password
282
+ const failResult = await authenticator.checkPasswordForUser(userName, lastPassword)
283
+ expect(failResult.isValid).toBeFalsy()
284
+ expect((failResult as PasswordCheckFailedResult).reason).toBe('badUsernameOrPassword')
285
+ })
286
+ })
287
+ it('Should throw PasswordComplexityError if the complexity rules are not respected', async () => {
288
+ await usingAsync(new Injector(), async (i) => {
289
+ const userName = v4()
290
+ const lastPassword = v4()
291
+ const password = ''
292
+ const token = v4()
293
+ i.setupStores((sm) =>
294
+ sm
295
+ .addStore(new InMemoryStore({ model: PasswordCredential, primaryKey: 'userName' }))
296
+ .addStore(new InMemoryStore({ model: User, primaryKey: 'username' }))
297
+ .addStore(new InMemoryStore({ model: PasswordResetToken, primaryKey: 'token' })),
298
+ )
299
+ i.usePasswordPolicy({ passwordComplexityRules: [createMinLengthComplexityRule(3)] })
300
+ const authenticator = i.getInstance(PasswordAuthenticator)
301
+ const hasher = authenticator.getHasher()
302
+ const passwordStore = i.getInstance(StoreManager).getStoreFor(PasswordCredential, 'userName')
303
+ const resetTokenStore = i.getInstance(StoreManager).getStoreFor(PasswordResetToken, 'token')
304
+
305
+ const entry = await hasher.createCredential(userName, lastPassword)
306
+ await passwordStore.add(entry)
307
+ await resetTokenStore.add({ userName, createdAt: new Date().toISOString(), token })
308
+
309
+ await expect(authenticator.resetPasswordForUser(token, password)).rejects.toThrowError(PasswordComplexityError)
310
+ const successResult = await authenticator.checkPasswordForUser(userName, lastPassword)
311
+ expect(successResult.isValid).toBeTruthy()
312
+
313
+ // Password updated, cannot use the last password
314
+ const failResult = await authenticator.checkPasswordForUser(userName, password)
315
+ expect(failResult.isValid).toBeFalsy()
316
+ expect((failResult as PasswordCheckFailedResult).reason).toBe('badUsernameOrPassword')
317
+ })
318
+ })
319
+ it('Should throw UnauthenticatedError if the token is not valid', async () => {
320
+ await usingAsync(new Injector(), async (i) => {
321
+ const userName = v4()
322
+ const lastPassword = v4()
323
+ const password = ''
324
+ const token = v4()
325
+ i.setupStores((sm) =>
326
+ sm
327
+ .addStore(new InMemoryStore({ model: PasswordCredential, primaryKey: 'userName' }))
328
+ .addStore(new InMemoryStore({ model: User, primaryKey: 'username' }))
329
+ .addStore(new InMemoryStore({ model: PasswordResetToken, primaryKey: 'token' })),
330
+ )
331
+ i.usePasswordPolicy()
332
+ const authenticator = i.getInstance(PasswordAuthenticator)
333
+ const hasher = authenticator.getHasher()
334
+ const passwordStore = i.getInstance(StoreManager).getStoreFor(PasswordCredential, 'userName')
335
+ const resetTokenStore = i.getInstance(StoreManager).getStoreFor(PasswordResetToken, 'token')
336
+
337
+ const entry = await hasher.createCredential(userName, lastPassword)
338
+ await passwordStore.add(entry)
339
+ await resetTokenStore.add({ userName, createdAt: new Date().toISOString(), token })
340
+
341
+ await expect(authenticator.resetPasswordForUser('someBadToken', password)).rejects.toThrowError(
342
+ UnauthenticatedError,
343
+ )
344
+ const successResult = await authenticator.checkPasswordForUser(userName, lastPassword)
345
+ expect(successResult.isValid).toBeTruthy()
346
+
347
+ // Password updated, cannot use the last password
348
+ const failResult = await authenticator.checkPasswordForUser(userName, password)
349
+ expect(failResult.isValid).toBeFalsy()
350
+ expect((failResult as PasswordCheckFailedResult).reason).toBe('badUsernameOrPassword')
351
+ })
352
+ })
353
+ it('Should throw UnauthenticatedError and clean up token if the token has been expired', async () => {
354
+ await usingAsync(new Injector(), async (i) => {
355
+ const userName = v4()
356
+ const lastPassword = v4()
357
+ const password = ''
358
+ const token = v4()
359
+ i.setupStores((sm) =>
360
+ sm
361
+ .addStore(new InMemoryStore({ model: PasswordCredential, primaryKey: 'userName' }))
362
+ .addStore(new InMemoryStore({ model: User, primaryKey: 'username' }))
363
+ .addStore(new InMemoryStore({ model: PasswordResetToken, primaryKey: 'token' })),
364
+ )
365
+ i.usePasswordPolicy({ resetTokenExpirationSeconds: 1 })
366
+ const authenticator = i.getInstance(PasswordAuthenticator)
367
+ const hasher = authenticator.getHasher()
368
+ const passwordStore = i.getInstance(StoreManager).getStoreFor(PasswordCredential, 'userName')
369
+ const resetTokenStore = i.getInstance(StoreManager).getStoreFor(PasswordResetToken, 'token')
370
+
371
+ const entry = await hasher.createCredential(userName, lastPassword)
372
+ await passwordStore.add(entry)
373
+
374
+ const creationDate = new Date()
375
+ creationDate.setDate(creationDate.getDate() - 2)
376
+
377
+ await resetTokenStore.add({ userName, createdAt: creationDate.toISOString(), token })
378
+
379
+ await expect(authenticator.resetPasswordForUser(token, password)).rejects.toThrowError(UnauthenticatedError)
380
+ const successResult = await authenticator.checkPasswordForUser(userName, lastPassword)
381
+ expect(successResult.isValid).toBeTruthy()
382
+
383
+ // Password updated, cannot use the last password
384
+ const failResult = await authenticator.checkPasswordForUser(userName, password)
385
+ expect(failResult.isValid).toBeFalsy()
386
+ expect((failResult as PasswordCheckFailedResult).reason).toBe('badUsernameOrPassword')
387
+
388
+ const tokenCount = await resetTokenStore.count()
389
+ expect(tokenCount).toBe(0)
390
+ })
391
+ })
392
+ })
393
+ })
@@ -0,0 +1,101 @@
1
+ import { StoreManager } from '@furystack/core'
2
+ import { Injectable, Injector } from '@furystack/inject'
3
+ import { SecurityPolicyManager } from './security-policy-manager'
4
+ import { UnauthenticatedError } from './errors'
5
+ import { PasswordCheckResult, PasswordCredential, PasswordResetToken } from './models'
6
+ import { PasswordComplexityError } from './errors/password-complexity-error'
7
+
8
+ @Injectable({ lifetime: 'singleton' })
9
+ export class PasswordAuthenticator {
10
+ private readonly getPasswordStore = () =>
11
+ this.injector.getInstance(StoreManager).getStoreFor(PasswordCredential, 'userName')
12
+
13
+ private readonly getTokenStore = () =>
14
+ this.injector.getInstance(StoreManager).getStoreFor(PasswordResetToken, 'token')
15
+
16
+ public readonly getHasher = () => this.injector.getInstance(this.policyManager.policy.hasher)
17
+
18
+ /**
19
+ * @param userName The User's unique name
20
+ * @param plainPassword The plain password to check
21
+ * @returns An object that contains an { isValid } value that indicates if the password is valid
22
+ */
23
+ public async checkPasswordForUser(userName: string, plainPassword: string): Promise<PasswordCheckResult> {
24
+ const entry = await this.getPasswordStore().get(userName)
25
+ if (!entry) {
26
+ return {
27
+ isValid: false,
28
+ reason: 'badUsernameOrPassword',
29
+ }
30
+ }
31
+ const result = await this.getHasher().verifyCredential(plainPassword, entry)
32
+ if (result.isValid && this.policyManager.hasPasswordExpired(entry)) {
33
+ return {
34
+ isValid: false,
35
+ reason: 'passwordExpired',
36
+ }
37
+ }
38
+ return result
39
+ }
40
+
41
+ /**
42
+ *
43
+ * Sets the password for the specific user
44
+ *
45
+ * @param userName The user to set the password for
46
+ * @param lastPassword The last user password
47
+ * @param plainPassword The password as a plain string
48
+ */
49
+ public async setPasswordForUser(userName: string, lastPassword: string, plainPassword: string): Promise<void> {
50
+ const complexityResult = await this.policyManager.matchPasswordComplexityRules(plainPassword)
51
+ if (!complexityResult.match) {
52
+ throw new PasswordComplexityError(complexityResult.errors)
53
+ }
54
+
55
+ const lastResult = await this.checkPasswordForUser(userName, lastPassword)
56
+ if (!lastResult.isValid) {
57
+ throw new UnauthenticatedError()
58
+ }
59
+ const store = this.getPasswordStore()
60
+ const newCredential = await this.getHasher().createCredential(userName, plainPassword)
61
+ const existing = await store.get(userName)
62
+ existing && (await store.remove(existing.userName))
63
+ await store.add(newCredential)
64
+ }
65
+
66
+ /**
67
+ * Resets the password with a Password Reset token entry
68
+ *
69
+ * @param resetToken The Reset Token value
70
+ * @param plainPassword The new password in plain string
71
+ */
72
+ public async resetPasswordForUser(resetToken: string, plainPassword: string): Promise<void> {
73
+ const token = await this.getTokenStore().get(resetToken)
74
+
75
+ if (!token) {
76
+ throw new UnauthenticatedError()
77
+ }
78
+
79
+ if (this.policyManager.hasTokenExpired(token)) {
80
+ await this.getTokenStore().remove(resetToken) // clean up token
81
+ throw new UnauthenticatedError()
82
+ }
83
+
84
+ const complexityResult = await this.policyManager.matchPasswordComplexityRules(plainPassword)
85
+ if (!complexityResult.match) {
86
+ throw new PasswordComplexityError(complexityResult.errors)
87
+ }
88
+
89
+ const newCredential = await this.getHasher().createCredential(token.userName, plainPassword)
90
+ const store = this.getPasswordStore()
91
+ const existing = await store.get(token.userName)
92
+ existing && (await store.remove(existing.userName))
93
+ await store.add(newCredential)
94
+ }
95
+
96
+ /**
97
+ * @param injector The Injector instance
98
+ * @param policyManager The Password Policy Manager instance
99
+ */
100
+ constructor(private readonly injector: Injector, public policyManager: SecurityPolicyManager) {}
101
+ }