@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.
- package/CHANGELOG.md +5 -0
- package/LICENSE +339 -0
- package/README.md +4 -0
- package/dist/crypto-password-hasher.d.ts +14 -0
- package/dist/crypto-password-hasher.d.ts.map +1 -0
- package/dist/crypto-password-hasher.js +39 -0
- package/dist/crypto-password-hasher.js.map +1 -0
- package/dist/errors/index.d.ts +4 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +7 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/errors/password-complexity-error.d.ts +15 -0
- package/dist/errors/password-complexity-error.d.ts.map +1 -0
- package/dist/errors/password-complexity-error.js +15 -0
- package/dist/errors/password-complexity-error.js.map +1 -0
- package/dist/errors/unauthenticated-error.d.ts +6 -0
- package/dist/errors/unauthenticated-error.d.ts.map +1 -0
- package/dist/errors/unauthenticated-error.js +10 -0
- package/dist/errors/unauthenticated-error.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/injector-extensions.d.ts +13 -0
- package/dist/injector-extensions.d.ts.map +1 -0
- package/dist/injector-extensions.js +11 -0
- package/dist/injector-extensions.js.map +1 -0
- package/dist/models/index.d.ts +6 -0
- package/dist/models/index.d.ts.map +1 -0
- package/dist/models/index.js +9 -0
- package/dist/models/index.js.map +1 -0
- package/dist/models/password-check-result.d.ts +10 -0
- package/dist/models/password-check-result.d.ts.map +1 -0
- package/dist/models/password-check-result.js +3 -0
- package/dist/models/password-check-result.js.map +1 -0
- package/dist/models/password-complexity-result.d.ts +10 -0
- package/dist/models/password-complexity-result.d.ts.map +1 -0
- package/dist/models/password-complexity-result.js +3 -0
- package/dist/models/password-complexity-result.js.map +1 -0
- package/dist/models/password-complexity-rule.d.ts +6 -0
- package/dist/models/password-complexity-rule.d.ts.map +1 -0
- package/dist/models/password-complexity-rule.js +3 -0
- package/dist/models/password-complexity-rule.js.map +1 -0
- package/dist/models/password-credential.d.ts +22 -0
- package/dist/models/password-credential.d.ts.map +1 -0
- package/dist/models/password-credential.js +10 -0
- package/dist/models/password-credential.js.map +1 -0
- package/dist/models/password-reset-token.d.ts +15 -0
- package/dist/models/password-reset-token.d.ts.map +1 -0
- package/dist/models/password-reset-token.js +7 -0
- package/dist/models/password-reset-token.js.map +1 -0
- package/dist/password-authenticator.d.ts +38 -0
- package/dist/password-authenticator.d.ts.map +1 -0
- package/dist/password-authenticator.js +99 -0
- package/dist/password-authenticator.js.map +1 -0
- package/dist/password-authenticator.spec.d.ts +2 -0
- package/dist/password-authenticator.spec.d.ts.map +1 -0
- package/dist/password-authenticator.spec.js +309 -0
- package/dist/password-authenticator.spec.js.map +1 -0
- package/dist/password-complexity-rules/contains-lowercase.d.ts +7 -0
- package/dist/password-complexity-rules/contains-lowercase.d.ts.map +1 -0
- package/dist/password-complexity-rules/contains-lowercase.js +29 -0
- package/dist/password-complexity-rules/contains-lowercase.js.map +1 -0
- package/dist/password-complexity-rules/contains-lowercase.spec.d.ts +2 -0
- package/dist/password-complexity-rules/contains-lowercase.spec.d.ts.map +1 -0
- package/dist/password-complexity-rules/contains-lowercase.spec.js +28 -0
- package/dist/password-complexity-rules/contains-lowercase.spec.js.map +1 -0
- package/dist/password-complexity-rules/contains-uppercase.d.ts +7 -0
- package/dist/password-complexity-rules/contains-uppercase.d.ts.map +1 -0
- package/dist/password-complexity-rules/contains-uppercase.js +27 -0
- package/dist/password-complexity-rules/contains-uppercase.js.map +1 -0
- package/dist/password-complexity-rules/contains-uppercase.spec.d.ts +2 -0
- package/dist/password-complexity-rules/contains-uppercase.spec.d.ts.map +1 -0
- package/dist/password-complexity-rules/contains-uppercase.spec.js +28 -0
- package/dist/password-complexity-rules/contains-uppercase.spec.js.map +1 -0
- package/dist/password-complexity-rules/index.d.ts +5 -0
- package/dist/password-complexity-rules/index.d.ts.map +1 -0
- package/dist/password-complexity-rules/index.js +8 -0
- package/dist/password-complexity-rules/index.js.map +1 -0
- package/dist/password-complexity-rules/max-length.d.ts +7 -0
- package/dist/password-complexity-rules/max-length.d.ts.map +1 -0
- package/dist/password-complexity-rules/max-length.js +19 -0
- package/dist/password-complexity-rules/max-length.js.map +1 -0
- package/dist/password-complexity-rules/max-length.spec.d.ts +2 -0
- package/dist/password-complexity-rules/max-length.spec.d.ts.map +1 -0
- package/dist/password-complexity-rules/max-length.spec.js +17 -0
- package/dist/password-complexity-rules/max-length.spec.js.map +1 -0
- package/dist/password-complexity-rules/min-length.d.ts +7 -0
- package/dist/password-complexity-rules/min-length.d.ts.map +1 -0
- package/dist/password-complexity-rules/min-length.js +19 -0
- package/dist/password-complexity-rules/min-length.js.map +1 -0
- package/dist/password-complexity-rules/min-length.spec.d.ts +2 -0
- package/dist/password-complexity-rules/min-length.spec.d.ts.map +1 -0
- package/dist/password-complexity-rules/min-length.spec.js +17 -0
- package/dist/password-complexity-rules/min-length.spec.js.map +1 -0
- package/dist/password-hasher.d.ts +6 -0
- package/dist/password-hasher.d.ts.map +1 -0
- package/dist/password-hasher.js +18 -0
- package/dist/password-hasher.js.map +1 -0
- package/dist/security-policy-manager.d.ts +26 -0
- package/dist/security-policy-manager.d.ts.map +1 -0
- package/dist/security-policy-manager.js +55 -0
- package/dist/security-policy-manager.js.map +1 -0
- package/dist/security-policy-manager.spec.d.ts +2 -0
- package/dist/security-policy-manager.spec.d.ts.map +1 -0
- package/dist/security-policy-manager.spec.js +19 -0
- package/dist/security-policy-manager.spec.js.map +1 -0
- package/dist/security-policy.d.ts +26 -0
- package/dist/security-policy.d.ts.map +1 -0
- package/dist/security-policy.js +35 -0
- package/dist/security-policy.js.map +1 -0
- package/package.json +45 -0
- package/src/crypto-password-hasher.ts +44 -0
- package/src/errors/index.ts +3 -0
- package/src/errors/password-complexity-error.ts +9 -0
- package/src/errors/unauthenticated-error.ts +4 -0
- package/src/index.ts +9 -0
- package/src/injector-extensions.ts +21 -0
- package/src/models/index.ts +5 -0
- package/src/models/password-check-result.ts +11 -0
- package/src/models/password-complexity-result.ts +11 -0
- package/src/models/password-complexity-rule.ts +6 -0
- package/src/models/password-credential.ts +23 -0
- package/src/models/password-reset-token.ts +14 -0
- package/src/password-authenticator.spec.ts +393 -0
- package/src/password-authenticator.ts +101 -0
- package/src/password-complexity-rules/contains-lowercase.spec.ts +34 -0
- package/src/password-complexity-rules/contains-lowercase.ts +27 -0
- package/src/password-complexity-rules/contains-uppercase.spec.ts +34 -0
- package/src/password-complexity-rules/contains-uppercase.ts +24 -0
- package/src/password-complexity-rules/index.ts +4 -0
- package/src/password-complexity-rules/max-length.spec.ts +17 -0
- package/src/password-complexity-rules/max-length.ts +16 -0
- package/src/password-complexity-rules/min-length.spec.ts +19 -0
- package/src/password-complexity-rules/min-length.ts +16 -0
- package/src/password-hasher.ts +13 -0
- package/src/security-policy-manager.spec.ts +16 -0
- package/src/security-policy-manager.ts +53 -0
- 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,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
|
+
}
|
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,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,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
|
+
}
|