@nauth-toolkit/core 0.1.0 → 0.1.5
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/LICENSE +90 -0
- package/README.md +9 -0
- package/package.json +8 -3
- package/jest.config.js +0 -15
- package/jest.setup.ts +0 -6
- package/src/adapters/database-columns.ts +0 -165
- package/src/adapters/express.adapter.ts +0 -385
- package/src/adapters/fastify.adapter.ts +0 -416
- package/src/adapters/index.ts +0 -16
- package/src/adapters/storage.factory.ts +0 -143
- package/src/bootstrap.ts +0 -374
- package/src/dto/auth-challenge.dto.ts +0 -231
- package/src/dto/auth-response.dto.ts +0 -253
- package/src/dto/challenge-response.dto.ts +0 -234
- package/src/dto/change-password-request.dto.ts +0 -50
- package/src/dto/change-password-response.dto.ts +0 -29
- package/src/dto/change-password.dto.ts +0 -57
- package/src/dto/error-response.dto.ts +0 -136
- package/src/dto/get-available-methods.dto.ts +0 -55
- package/src/dto/get-challenge-data-response.dto.ts +0 -28
- package/src/dto/get-challenge-data.dto.ts +0 -69
- package/src/dto/get-client-info.dto.ts +0 -104
- package/src/dto/get-device-token-response.dto.ts +0 -25
- package/src/dto/get-events-by-type.dto.ts +0 -76
- package/src/dto/get-ip-address-response.dto.ts +0 -24
- package/src/dto/get-mfa-status.dto.ts +0 -94
- package/src/dto/get-risk-assessment-history.dto.ts +0 -39
- package/src/dto/get-session-id-response.dto.ts +0 -25
- package/src/dto/get-setup-data-response.dto.ts +0 -31
- package/src/dto/get-setup-data.dto.ts +0 -75
- package/src/dto/get-suspicious-activity.dto.ts +0 -42
- package/src/dto/get-user-agent-response.dto.ts +0 -23
- package/src/dto/get-user-auth-history.dto.ts +0 -95
- package/src/dto/get-user-by-email.dto.ts +0 -61
- package/src/dto/get-user-by-id.dto.ts +0 -46
- package/src/dto/get-user-devices.dto.ts +0 -53
- package/src/dto/get-user-response.dto.ts +0 -17
- package/src/dto/has-provider.dto.ts +0 -56
- package/src/dto/index.ts +0 -57
- package/src/dto/is-trusted-device-response.dto.ts +0 -34
- package/src/dto/list-providers-response.dto.ts +0 -23
- package/src/dto/login.dto.ts +0 -95
- package/src/dto/logout-all-response.dto.ts +0 -24
- package/src/dto/logout-all.dto.ts +0 -65
- package/src/dto/logout-response.dto.ts +0 -25
- package/src/dto/logout.dto.ts +0 -64
- package/src/dto/refresh-token.dto.ts +0 -36
- package/src/dto/remove-devices.dto.ts +0 -85
- package/src/dto/resend-code-response.dto.ts +0 -32
- package/src/dto/resend-code.dto.ts +0 -51
- package/src/dto/reset-password.dto.ts +0 -115
- package/src/dto/respond-challenge.dto.ts +0 -272
- package/src/dto/set-mfa-exemption.dto.ts +0 -112
- package/src/dto/set-must-change-password-response.dto.ts +0 -27
- package/src/dto/set-must-change-password.dto.ts +0 -46
- package/src/dto/set-preferred-method.dto.ts +0 -80
- package/src/dto/setup-mfa.dto.ts +0 -98
- package/src/dto/signup.dto.ts +0 -174
- package/src/dto/social-auth.dto.ts +0 -422
- package/src/dto/trust-device-response.dto.ts +0 -30
- package/src/dto/trust-device.dto.ts +0 -9
- package/src/dto/update-user-attributes-request.dto.ts +0 -51
- package/src/dto/user-response.dto.ts +0 -138
- package/src/dto/user-update.dto.ts +0 -222
- package/src/dto/verify-email.dto.ts +0 -313
- package/src/dto/verify-mfa-code.dto.ts +0 -103
- package/src/dto/verify-phone-by-sub.dto.ts +0 -78
- package/src/dto/verify-phone.dto.ts +0 -245
- package/src/entities/auth-audit.entity.ts +0 -232
- package/src/entities/challenge-session.entity.ts +0 -116
- package/src/entities/index.ts +0 -29
- package/src/entities/login-attempt.entity.ts +0 -64
- package/src/entities/mfa-device.entity.ts +0 -151
- package/src/entities/rate-limit.entity.ts +0 -44
- package/src/entities/session.entity.ts +0 -180
- package/src/entities/social-account.entity.ts +0 -96
- package/src/entities/storage-lock.entity.ts +0 -39
- package/src/entities/trusted-device.entity.ts +0 -112
- package/src/entities/user.entity.ts +0 -243
- package/src/entities/verification-token.entity.ts +0 -141
- package/src/enums/auth-audit-event-type.enum.ts +0 -360
- package/src/enums/error-codes.enum.ts +0 -420
- package/src/enums/mfa-method.enum.ts +0 -97
- package/src/enums/risk-factor.enum.ts +0 -111
- package/src/exceptions/nauth.exception.ts +0 -231
- package/src/handlers/auth.handler.ts +0 -260
- package/src/handlers/client-info.handler.ts +0 -101
- package/src/handlers/csrf.handler.ts +0 -156
- package/src/handlers/token-delivery.handler.ts +0 -118
- package/src/index.ts +0 -118
- package/src/interfaces/client-info.interface.ts +0 -85
- package/src/interfaces/config.interface.ts +0 -2135
- package/src/interfaces/entities.interface.ts +0 -226
- package/src/interfaces/index.ts +0 -15
- package/src/interfaces/logger.interface.ts +0 -283
- package/src/interfaces/mfa-provider.interface.ts +0 -154
- package/src/interfaces/oauth.interface.ts +0 -148
- package/src/interfaces/provider.interface.ts +0 -47
- package/src/interfaces/social-auth-provider.interface.ts +0 -131
- package/src/interfaces/storage-adapter.interface.ts +0 -82
- package/src/interfaces/template.interface.ts +0 -510
- package/src/interfaces/token-verifier.interface.ts +0 -110
- package/src/internal.ts +0 -178
- package/src/platform/interfaces.ts +0 -299
- package/src/schemas/auth-config.schema.ts +0 -646
- package/src/services/adaptive-mfa-decision.service.spec.ts +0 -1058
- package/src/services/adaptive-mfa-decision.service.ts +0 -457
- package/src/services/auth-audit.service.spec.ts +0 -675
- package/src/services/auth-audit.service.ts +0 -558
- package/src/services/auth-challenge-helper.service.spec.ts +0 -3227
- package/src/services/auth-challenge-helper.service.ts +0 -825
- package/src/services/auth-flow-context-builder.service.ts +0 -520
- package/src/services/auth-flow-rules.ts +0 -202
- package/src/services/auth-flow-state-definitions.ts +0 -190
- package/src/services/auth-flow-state-machine.service.ts +0 -207
- package/src/services/auth-flow-state-machine.types.ts +0 -316
- package/src/services/auth.service.spec.ts +0 -4195
- package/src/services/auth.service.ts +0 -3727
- package/src/services/challenge.service.spec.ts +0 -1363
- package/src/services/challenge.service.ts +0 -696
- package/src/services/client-info.service.spec.ts +0 -572
- package/src/services/client-info.service.ts +0 -374
- package/src/services/csrf.service.ts +0 -54
- package/src/services/email-verification.service.spec.ts +0 -1229
- package/src/services/email-verification.service.ts +0 -578
- package/src/services/geo-location.service.spec.ts +0 -603
- package/src/services/geo-location.service.ts +0 -599
- package/src/services/index.ts +0 -13
- package/src/services/jwt.service.spec.ts +0 -882
- package/src/services/jwt.service.ts +0 -621
- package/src/services/mfa-base.service.spec.ts +0 -246
- package/src/services/mfa-base.service.ts +0 -611
- package/src/services/mfa.service.spec.ts +0 -693
- package/src/services/mfa.service.ts +0 -960
- package/src/services/password.service.spec.ts +0 -166
- package/src/services/password.service.ts +0 -309
- package/src/services/phone-verification.service.spec.ts +0 -1120
- package/src/services/phone-verification.service.ts +0 -751
- package/src/services/risk-detection.service.spec.ts +0 -1292
- package/src/services/risk-detection.service.ts +0 -1012
- package/src/services/risk-scoring.service.spec.ts +0 -204
- package/src/services/risk-scoring.service.ts +0 -131
- package/src/services/session.service.spec.ts +0 -1293
- package/src/services/session.service.ts +0 -803
- package/src/services/social-account.service.spec.ts +0 -725
- package/src/services/social-auth-base.service.spec.ts +0 -418
- package/src/services/social-auth-base.service.ts +0 -581
- package/src/services/social-auth.service.spec.ts +0 -238
- package/src/services/social-auth.service.ts +0 -436
- package/src/services/social-provider-registry.service.spec.ts +0 -238
- package/src/services/social-provider-registry.service.ts +0 -122
- package/src/services/trusted-device.service.spec.ts +0 -505
- package/src/services/trusted-device.service.ts +0 -339
- package/src/storage/account-lockout-storage.service.spec.ts +0 -310
- package/src/storage/account-lockout-storage.service.ts +0 -89
- package/src/storage/index.ts +0 -3
- package/src/storage/memory-storage.adapter.ts +0 -443
- package/src/storage/rate-limit-storage.service.spec.ts +0 -247
- package/src/storage/rate-limit-storage.service.ts +0 -38
- package/src/templates/html-template.engine.spec.ts +0 -161
- package/src/templates/html-template.engine.ts +0 -688
- package/src/templates/index.ts +0 -7
- package/src/utils/common-passwords.spec.ts +0 -230
- package/src/utils/common-passwords.ts +0 -170
- package/src/utils/context-storage.ts +0 -188
- package/src/utils/cookie-names.util.ts +0 -67
- package/src/utils/cookies.util.ts +0 -94
- package/src/utils/index.ts +0 -12
- package/src/utils/ip-extractor.spec.ts +0 -330
- package/src/utils/ip-extractor.ts +0 -220
- package/src/utils/nauth-logger.spec.ts +0 -388
- package/src/utils/nauth-logger.ts +0 -215
- package/src/utils/pii-redactor.spec.ts +0 -130
- package/src/utils/pii-redactor.ts +0 -288
- package/src/utils/setup/get-repositories.ts +0 -140
- package/src/utils/setup/init-services.ts +0 -422
- package/src/utils/setup/init-social.ts +0 -189
- package/src/utils/setup/init-storage.ts +0 -94
- package/src/utils/setup/register-mfa.ts +0 -165
- package/src/utils/setup/run-nauth-migrations.ts +0 -61
- package/src/utils/token-delivery-policy.ts +0 -38
- package/src/validators/template.validator.ts +0 -219
- package/tsconfig.json +0 -37
- package/tsconfig.lint.json +0 -6
|
@@ -1,339 +0,0 @@
|
|
|
1
|
-
import { Repository } from 'typeorm';
|
|
2
|
-
import { NAuthConfig } from '../interfaces/config.interface';
|
|
3
|
-
import { NAuthLogger } from '../utils/nauth-logger';
|
|
4
|
-
import { createHash } from 'crypto';
|
|
5
|
-
import { BaseTrustedDevice } from '../entities/trusted-device.entity';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Trusted Device Service
|
|
9
|
-
*
|
|
10
|
-
* Manages device trust for "remember device" feature.
|
|
11
|
-
* Devices can be trusted after successful MFA verification, allowing
|
|
12
|
-
* users to skip MFA for a configured period (rememberDeviceDays).
|
|
13
|
-
*
|
|
14
|
-
* Security:
|
|
15
|
-
* - Device tokens are server-generated UUIDs
|
|
16
|
-
* - Only hash stored in database (SHA-256)
|
|
17
|
-
* - Tokens persist across logouts and session expiry
|
|
18
|
-
* - Independent of refresh token lifecycle
|
|
19
|
-
*
|
|
20
|
-
* @example
|
|
21
|
-
* ```typescript
|
|
22
|
-
* // Mark device as trusted after MFA
|
|
23
|
-
* const deviceToken = await trustedDeviceService.createTrustedDevice(
|
|
24
|
-
* userId,
|
|
25
|
-
* deviceName,
|
|
26
|
-
* deviceType,
|
|
27
|
-
* ipAddress,
|
|
28
|
-
* userAgent,
|
|
29
|
-
* platform,
|
|
30
|
-
* browser
|
|
31
|
-
* );
|
|
32
|
-
*
|
|
33
|
-
* // Check if device is trusted
|
|
34
|
-
* const isTrusted = await trustedDeviceService.isDeviceTrusted(
|
|
35
|
-
* deviceToken,
|
|
36
|
-
* userId
|
|
37
|
-
* );
|
|
38
|
-
* ```
|
|
39
|
-
*/
|
|
40
|
-
export class TrustedDeviceService {
|
|
41
|
-
constructor(
|
|
42
|
-
private readonly config: NAuthConfig,
|
|
43
|
-
private readonly logger: NAuthLogger,
|
|
44
|
-
private readonly trustedDeviceRepository?: Repository<BaseTrustedDevice>,
|
|
45
|
-
) {}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Create trusted device record
|
|
49
|
-
*
|
|
50
|
-
* Generates a secure device token, stores its hash in database,
|
|
51
|
-
* and returns the plain token for client storage.
|
|
52
|
-
*
|
|
53
|
-
* @param userId - Internal user ID
|
|
54
|
-
* @param deviceName - Optional device name
|
|
55
|
-
* @param deviceType - Optional device type (mobile/desktop/tablet)
|
|
56
|
-
* @param ipAddress - IP address when device was trusted
|
|
57
|
-
* @param userAgent - User agent string
|
|
58
|
-
* @param platform - Platform from user agent
|
|
59
|
-
* @param browser - Browser from user agent
|
|
60
|
-
* @returns Device token (UUID) to be stored by client
|
|
61
|
-
*
|
|
62
|
-
* @throws {Error} If rememberDevice is not enabled or repository not available
|
|
63
|
-
*/
|
|
64
|
-
async createTrustedDevice(
|
|
65
|
-
userId: number,
|
|
66
|
-
deviceName?: string | null,
|
|
67
|
-
deviceType?: string | null,
|
|
68
|
-
ipAddress?: string | null,
|
|
69
|
-
userAgent?: string | null,
|
|
70
|
-
platform?: string | null,
|
|
71
|
-
browser?: string | null,
|
|
72
|
-
): Promise<string> {
|
|
73
|
-
if (!this.config.mfa?.rememberDevices || this.config.mfa.rememberDevices === 'never') {
|
|
74
|
-
throw new Error('rememberDevices is not enabled in configuration');
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (!this.trustedDeviceRepository) {
|
|
78
|
-
this.logger?.warn?.('TrustedDeviceRepository not available - trusted device feature disabled');
|
|
79
|
-
throw new Error('TrustedDeviceRepository not available');
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Generate secure device token (UUID v4)
|
|
83
|
-
const crypto = await import('crypto');
|
|
84
|
-
const deviceToken = crypto.randomUUID();
|
|
85
|
-
|
|
86
|
-
// Hash token for storage (SHA-256)
|
|
87
|
-
const deviceTokenHash = this.hashDeviceToken(deviceToken);
|
|
88
|
-
|
|
89
|
-
// Calculate expiry (now + rememberDeviceDays)
|
|
90
|
-
// Only applicable if rememberDevices is not 'never'
|
|
91
|
-
const rememberDeviceDays = this.config.mfa.rememberDeviceDays || 30;
|
|
92
|
-
const trustedUntil = new Date();
|
|
93
|
-
trustedUntil.setDate(trustedUntil.getDate() + rememberDeviceDays);
|
|
94
|
-
|
|
95
|
-
// Check if device already trusted (by hash)
|
|
96
|
-
const existing = await this.trustedDeviceRepository.findOne({
|
|
97
|
-
where: {
|
|
98
|
-
userId,
|
|
99
|
-
deviceTokenHash,
|
|
100
|
-
},
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
if (existing) {
|
|
104
|
-
// Update existing record
|
|
105
|
-
await this.trustedDeviceRepository.update(
|
|
106
|
-
{ userId, deviceTokenHash },
|
|
107
|
-
{
|
|
108
|
-
trustedUntil,
|
|
109
|
-
lastUsedAt: new Date(),
|
|
110
|
-
deviceName: deviceName || null,
|
|
111
|
-
deviceType: deviceType || null,
|
|
112
|
-
ipAddress: ipAddress || null,
|
|
113
|
-
userAgent: userAgent || null,
|
|
114
|
-
platform: platform || null,
|
|
115
|
-
browser: browser || null,
|
|
116
|
-
},
|
|
117
|
-
);
|
|
118
|
-
this.logger?.debug?.(`Updated trusted device for user ${userId}`);
|
|
119
|
-
return deviceToken;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Create new trusted device record
|
|
123
|
-
const trustedDevice = this.trustedDeviceRepository.create({
|
|
124
|
-
userId,
|
|
125
|
-
deviceTokenHash,
|
|
126
|
-
deviceId: null, // Not used, kept for backward compatibility
|
|
127
|
-
deviceName: deviceName || null,
|
|
128
|
-
deviceType: deviceType || null,
|
|
129
|
-
ipAddress: ipAddress || null,
|
|
130
|
-
userAgent: userAgent || null,
|
|
131
|
-
platform: platform || null,
|
|
132
|
-
browser: browser || null,
|
|
133
|
-
trustedUntil,
|
|
134
|
-
lastUsedAt: new Date(),
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
await this.trustedDeviceRepository.save(trustedDevice);
|
|
138
|
-
this.logger?.debug?.(`Created trusted device for user ${userId}, expires ${trustedUntil.toISOString()}`);
|
|
139
|
-
|
|
140
|
-
return deviceToken;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Check if device is trusted
|
|
145
|
-
*
|
|
146
|
-
* Validates device token against trusted devices table.
|
|
147
|
-
* Updates lastUsedAt if device is found and valid.
|
|
148
|
-
*
|
|
149
|
-
* Security:
|
|
150
|
-
* - Returns false for invalid/tampered tokens (silent - MFA required)
|
|
151
|
-
* - Detection of tampered tokens should be handled by caller for audit logging
|
|
152
|
-
*
|
|
153
|
-
* @param deviceToken - Device token from client (plain UUID)
|
|
154
|
-
* @param userId - Internal user ID
|
|
155
|
-
* @returns True if device is trusted and not expired
|
|
156
|
-
*/
|
|
157
|
-
async isDeviceTrusted(deviceToken: string | null | undefined, userId: number): Promise<boolean> {
|
|
158
|
-
if (!deviceToken || !this.trustedDeviceRepository) {
|
|
159
|
-
return false;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (!this.config.mfa?.rememberDevices || this.config.mfa.rememberDevices === 'never') {
|
|
163
|
-
return false;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Hash token for lookup
|
|
167
|
-
const deviceTokenHash = this.hashDeviceToken(deviceToken);
|
|
168
|
-
|
|
169
|
-
// Find trusted device
|
|
170
|
-
const trustedDevice = await this.trustedDeviceRepository.findOne({
|
|
171
|
-
where: {
|
|
172
|
-
userId,
|
|
173
|
-
deviceTokenHash,
|
|
174
|
-
},
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
if (!trustedDevice) {
|
|
178
|
-
// Device token not found - could be tampered/fake
|
|
179
|
-
// Caller should check if token was provided and audit suspicious activity
|
|
180
|
-
return false;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Check if trust has expired
|
|
184
|
-
const trustedUntil = trustedDevice.trustedUntil;
|
|
185
|
-
if (new Date() > new Date(trustedUntil)) {
|
|
186
|
-
// Trust expired - delete record
|
|
187
|
-
await this.trustedDeviceRepository.delete({
|
|
188
|
-
userId,
|
|
189
|
-
deviceTokenHash,
|
|
190
|
-
});
|
|
191
|
-
this.logger?.debug?.(`Trusted device expired for user ${userId}`);
|
|
192
|
-
return false;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Update lastUsedAt with throttling to reduce write load
|
|
196
|
-
const lastUsedAt = trustedDevice.lastUsedAt;
|
|
197
|
-
const now = new Date();
|
|
198
|
-
const fifteenMinutesMs = 15 * 60 * 1000;
|
|
199
|
-
if (!lastUsedAt || now.getTime() - new Date(lastUsedAt).getTime() > fifteenMinutesMs) {
|
|
200
|
-
await this.trustedDeviceRepository.update({ userId, deviceTokenHash }, { lastUsedAt: now });
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
return true;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Validate device token and detect tampering attempts
|
|
208
|
-
*
|
|
209
|
-
* Checks if device token is valid and returns validation result.
|
|
210
|
-
* Used to detect suspicious tampered/fake token attempts for audit logging.
|
|
211
|
-
*
|
|
212
|
-
* @param deviceToken - Device token from client (can be null/undefined)
|
|
213
|
-
* @param userId - Internal user ID
|
|
214
|
-
* @returns Validation result with suspicious flag
|
|
215
|
-
*/
|
|
216
|
-
async validateDeviceToken(
|
|
217
|
-
deviceToken: string | null | undefined,
|
|
218
|
-
userId: number,
|
|
219
|
-
): Promise<{ isValid: boolean; isSuspicious: boolean }> {
|
|
220
|
-
// No token provided - not suspicious (user just doesn't have trusted device)
|
|
221
|
-
if (!deviceToken) {
|
|
222
|
-
return { isValid: false, isSuspicious: false };
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Check if trusted
|
|
226
|
-
const isTrusted = await this.isDeviceTrusted(deviceToken, userId);
|
|
227
|
-
|
|
228
|
-
// If token was provided but not trusted, it's suspicious (tampered/fake)
|
|
229
|
-
const isSuspicious = !isTrusted && deviceToken !== null && deviceToken !== undefined;
|
|
230
|
-
|
|
231
|
-
return { isValid: isTrusted, isSuspicious };
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Revoke trusted device
|
|
236
|
-
*
|
|
237
|
-
* Removes device from trusted devices table.
|
|
238
|
-
* Used when user explicitly untrusts a device.
|
|
239
|
-
*
|
|
240
|
-
* @param deviceToken - Device token to revoke
|
|
241
|
-
* @param userId - Internal user ID
|
|
242
|
-
*/
|
|
243
|
-
async revokeTrustedDevice(deviceToken: string, userId: number): Promise<void> {
|
|
244
|
-
if (!this.trustedDeviceRepository) {
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const deviceTokenHash = this.hashDeviceToken(deviceToken);
|
|
249
|
-
await this.trustedDeviceRepository.delete({
|
|
250
|
-
userId,
|
|
251
|
-
deviceTokenHash,
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
this.logger?.debug?.(`Revoked trusted device for user ${userId}`);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Get user's trusted devices
|
|
259
|
-
*
|
|
260
|
-
* Returns list of trusted devices for management UI.
|
|
261
|
-
*
|
|
262
|
-
* @param userId - Internal user ID
|
|
263
|
-
* @returns Array of trusted device records (without tokens)
|
|
264
|
-
*/
|
|
265
|
-
async getUserTrustedDevices(userId: number): Promise<Omit<BaseTrustedDevice, 'deviceTokenHash'>[]> {
|
|
266
|
-
if (!this.trustedDeviceRepository) {
|
|
267
|
-
return [];
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
const devices = await this.trustedDeviceRepository.find({
|
|
271
|
-
where: { userId },
|
|
272
|
-
order: { lastUsedAt: 'DESC' },
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
// Filter expired devices
|
|
276
|
-
const now = new Date();
|
|
277
|
-
const validDevices = devices.filter((d) => new Date(d.trustedUntil) > now);
|
|
278
|
-
|
|
279
|
-
// Return without sensitive data
|
|
280
|
-
return validDevices.map((d) => {
|
|
281
|
-
const { deviceTokenHash, ...rest } = d;
|
|
282
|
-
return rest;
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* Revoke all trusted devices for a user
|
|
288
|
-
*
|
|
289
|
-
* Removes all trusted devices for the user.
|
|
290
|
-
* Used when user performs global logout with forgetDevices flag.
|
|
291
|
-
*
|
|
292
|
-
* @param userId - Internal user ID
|
|
293
|
-
* @returns Object containing count and device information before deletion
|
|
294
|
-
*/
|
|
295
|
-
async revokeAllTrustedDevices(userId: number): Promise<{
|
|
296
|
-
revokedCount: number;
|
|
297
|
-
devices: Array<{
|
|
298
|
-
id: number | string;
|
|
299
|
-
deviceName: string | null;
|
|
300
|
-
lastUsedAt: Date | null;
|
|
301
|
-
trustedUntil: Date | null;
|
|
302
|
-
}>;
|
|
303
|
-
}> {
|
|
304
|
-
if (!this.trustedDeviceRepository) {
|
|
305
|
-
return { revokedCount: 0, devices: [] };
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// Get devices before deletion for audit logging
|
|
309
|
-
const devices = await this.trustedDeviceRepository.find({
|
|
310
|
-
where: { userId },
|
|
311
|
-
order: { lastUsedAt: 'DESC' },
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
// Extract device information (without sensitive token hash)
|
|
315
|
-
// Note: ipAddress, browser, platform, deviceType are automatically captured by audit service via client info
|
|
316
|
-
// Only include unique identifiers and historical timestamps
|
|
317
|
-
const deviceInfo = devices.map((d) => ({
|
|
318
|
-
id: d.id,
|
|
319
|
-
deviceName: d.deviceName ?? null, // User-given name (may differ from current device name)
|
|
320
|
-
lastUsedAt: d.lastUsedAt ?? null, // Historical timestamp
|
|
321
|
-
trustedUntil: d.trustedUntil ?? null, // Expiry date
|
|
322
|
-
}));
|
|
323
|
-
|
|
324
|
-
// Delete all devices
|
|
325
|
-
const result = await this.trustedDeviceRepository.delete({ userId });
|
|
326
|
-
const deletedCount = typeof result.affected === 'number' ? result.affected : 0;
|
|
327
|
-
this.logger?.debug?.(`Revoked ${deletedCount} trusted device(s) for user ${userId}`);
|
|
328
|
-
return { revokedCount: deletedCount, devices: deviceInfo };
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Hash device token (SHA-256)
|
|
333
|
-
*
|
|
334
|
-
* @private
|
|
335
|
-
*/
|
|
336
|
-
private hashDeviceToken(token: string): string {
|
|
337
|
-
return createHash('sha256').update(token).digest('hex');
|
|
338
|
-
}
|
|
339
|
-
}
|
|
@@ -1,310 +0,0 @@
|
|
|
1
|
-
import { AccountLockoutStorageService } from './account-lockout-storage.service';
|
|
2
|
-
import { StorageAdapter } from '../interfaces/storage-adapter.interface';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Account Lockout Storage Service Unit Tests
|
|
6
|
-
*
|
|
7
|
-
* Tests IP-based account lockout storage operations.
|
|
8
|
-
* Covers failed attempt tracking, lock/unlock operations, and expiration handling.
|
|
9
|
-
*
|
|
10
|
-
* Platform-agnostic: Uses direct instantiation, no NestJS dependencies.
|
|
11
|
-
*/
|
|
12
|
-
describe('AccountLockoutStorageService', () => {
|
|
13
|
-
let service: AccountLockoutStorageService;
|
|
14
|
-
let mockStorageAdapter: jest.Mocked<StorageAdapter>;
|
|
15
|
-
|
|
16
|
-
beforeEach(() => {
|
|
17
|
-
// Create mock storage adapter
|
|
18
|
-
mockStorageAdapter = {
|
|
19
|
-
initialize: jest.fn(),
|
|
20
|
-
isHealthy: jest.fn(),
|
|
21
|
-
get: jest.fn(),
|
|
22
|
-
set: jest.fn(),
|
|
23
|
-
del: jest.fn(),
|
|
24
|
-
exists: jest.fn(),
|
|
25
|
-
incr: jest.fn(),
|
|
26
|
-
decr: jest.fn(),
|
|
27
|
-
expire: jest.fn(),
|
|
28
|
-
ttl: jest.fn(),
|
|
29
|
-
hget: jest.fn(),
|
|
30
|
-
hset: jest.fn(),
|
|
31
|
-
hgetall: jest.fn(),
|
|
32
|
-
hdel: jest.fn(),
|
|
33
|
-
lpush: jest.fn(),
|
|
34
|
-
lrange: jest.fn(),
|
|
35
|
-
llen: jest.fn(),
|
|
36
|
-
keys: jest.fn(),
|
|
37
|
-
scan: jest.fn(),
|
|
38
|
-
cleanup: jest.fn(),
|
|
39
|
-
disconnect: jest.fn(),
|
|
40
|
-
} as any;
|
|
41
|
-
|
|
42
|
-
// Instantiate service directly
|
|
43
|
-
service = new AccountLockoutStorageService(mockStorageAdapter);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
afterEach(() => {
|
|
47
|
-
jest.clearAllMocks();
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
// ============================================================================
|
|
51
|
-
// Service Initialization
|
|
52
|
-
// ============================================================================
|
|
53
|
-
|
|
54
|
-
it('should be defined', () => {
|
|
55
|
-
expect(service).toBeDefined();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
// ============================================================================
|
|
59
|
-
// recordFailedAttempt() Method
|
|
60
|
-
// ============================================================================
|
|
61
|
-
|
|
62
|
-
describe('recordFailedAttempt', () => {
|
|
63
|
-
it('should increment failed attempts counter', async () => {
|
|
64
|
-
mockStorageAdapter.incr.mockResolvedValue(1);
|
|
65
|
-
|
|
66
|
-
const result = await service.recordFailedAttempt('192.168.1.1');
|
|
67
|
-
|
|
68
|
-
expect(result).toBe(1);
|
|
69
|
-
expect(mockStorageAdapter.incr).toHaveBeenCalledWith('nauth:lockout:ip:192.168.1.1');
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('should return incremented count', async () => {
|
|
73
|
-
mockStorageAdapter.incr.mockResolvedValue(5);
|
|
74
|
-
|
|
75
|
-
const result = await service.recordFailedAttempt('192.168.1.1');
|
|
76
|
-
|
|
77
|
-
expect(result).toBe(5);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('should use correct key prefix', async () => {
|
|
81
|
-
mockStorageAdapter.incr.mockResolvedValue(1);
|
|
82
|
-
|
|
83
|
-
await service.recordFailedAttempt('10.0.0.1');
|
|
84
|
-
|
|
85
|
-
expect(mockStorageAdapter.incr).toHaveBeenCalledWith('nauth:lockout:ip:10.0.0.1');
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
// ============================================================================
|
|
90
|
-
// getFailedAttempts() Method
|
|
91
|
-
// ============================================================================
|
|
92
|
-
|
|
93
|
-
describe('getFailedAttempts', () => {
|
|
94
|
-
it('should return failed attempts count', async () => {
|
|
95
|
-
mockStorageAdapter.get.mockResolvedValue('3');
|
|
96
|
-
|
|
97
|
-
const result = await service.getFailedAttempts('192.168.1.1');
|
|
98
|
-
|
|
99
|
-
expect(result).toBe(3);
|
|
100
|
-
expect(mockStorageAdapter.get).toHaveBeenCalledWith('nauth:lockout:ip:192.168.1.1');
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('should return 0 when no attempts recorded', async () => {
|
|
104
|
-
mockStorageAdapter.get.mockResolvedValue(null);
|
|
105
|
-
|
|
106
|
-
const result = await service.getFailedAttempts('192.168.1.1');
|
|
107
|
-
|
|
108
|
-
expect(result).toBe(0);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('should parse string value to number', async () => {
|
|
112
|
-
mockStorageAdapter.get.mockResolvedValue('10');
|
|
113
|
-
|
|
114
|
-
const result = await service.getFailedAttempts('192.168.1.1');
|
|
115
|
-
|
|
116
|
-
expect(result).toBe(10);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('should handle empty string', async () => {
|
|
120
|
-
mockStorageAdapter.get.mockResolvedValue('');
|
|
121
|
-
|
|
122
|
-
const result = await service.getFailedAttempts('192.168.1.1');
|
|
123
|
-
|
|
124
|
-
expect(result).toBe(0);
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
// ============================================================================
|
|
129
|
-
// isAccountLocked() Method
|
|
130
|
-
// ============================================================================
|
|
131
|
-
|
|
132
|
-
describe('isAccountLocked', () => {
|
|
133
|
-
it('should return true when account is locked', async () => {
|
|
134
|
-
mockStorageAdapter.exists.mockResolvedValue(true);
|
|
135
|
-
|
|
136
|
-
const result = await service.isAccountLocked('192.168.1.1');
|
|
137
|
-
|
|
138
|
-
expect(result).toBe(true);
|
|
139
|
-
expect(mockStorageAdapter.exists).toHaveBeenCalledWith('nauth:locked:ip:192.168.1.1');
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it('should return false when account is not locked', async () => {
|
|
143
|
-
mockStorageAdapter.exists.mockResolvedValue(false);
|
|
144
|
-
|
|
145
|
-
const result = await service.isAccountLocked('192.168.1.1');
|
|
146
|
-
|
|
147
|
-
expect(result).toBe(false);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it('should use correct lock key prefix', async () => {
|
|
151
|
-
mockStorageAdapter.exists.mockResolvedValue(false);
|
|
152
|
-
|
|
153
|
-
await service.isAccountLocked('10.0.0.1');
|
|
154
|
-
|
|
155
|
-
expect(mockStorageAdapter.exists).toHaveBeenCalledWith('nauth:locked:ip:10.0.0.1');
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
// ============================================================================
|
|
160
|
-
// lockAccount() Method
|
|
161
|
-
// ============================================================================
|
|
162
|
-
|
|
163
|
-
describe('lockAccount', () => {
|
|
164
|
-
it('should lock account with correct data', async () => {
|
|
165
|
-
mockStorageAdapter.set.mockResolvedValue();
|
|
166
|
-
|
|
167
|
-
await service.lockAccount('192.168.1.1', 300, 'too_many_failed_attempts');
|
|
168
|
-
|
|
169
|
-
expect(mockStorageAdapter.set).toHaveBeenCalledWith(
|
|
170
|
-
'nauth:locked:ip:192.168.1.1',
|
|
171
|
-
(expect as any).stringContaining('too_many_failed_attempts'),
|
|
172
|
-
300,
|
|
173
|
-
);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it('should include lockedAt timestamp', async () => {
|
|
177
|
-
const beforeLock = new Date();
|
|
178
|
-
mockStorageAdapter.set.mockResolvedValue();
|
|
179
|
-
|
|
180
|
-
await service.lockAccount('192.168.1.1', 300, 'test_reason');
|
|
181
|
-
|
|
182
|
-
const afterLock = new Date();
|
|
183
|
-
const callArgs = mockStorageAdapter.set.mock.calls[0];
|
|
184
|
-
const lockData = JSON.parse(callArgs[1] as string);
|
|
185
|
-
|
|
186
|
-
expect(lockData.reason).toBe('test_reason');
|
|
187
|
-
expect(new Date(lockData.lockedAt).getTime()).toBeGreaterThanOrEqual(beforeLock.getTime());
|
|
188
|
-
expect(new Date(lockData.lockedAt).getTime()).toBeLessThanOrEqual(afterLock.getTime());
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it('should calculate lockedUntil correctly', async () => {
|
|
192
|
-
const duration = 600; // 10 minutes
|
|
193
|
-
mockStorageAdapter.set.mockResolvedValue();
|
|
194
|
-
|
|
195
|
-
await service.lockAccount('192.168.1.1', duration, 'test_reason');
|
|
196
|
-
|
|
197
|
-
const callArgs = mockStorageAdapter.set.mock.calls[0];
|
|
198
|
-
const lockData = JSON.parse(callArgs[1] as string);
|
|
199
|
-
const lockedUntil = new Date(lockData.lockedUntil);
|
|
200
|
-
const expectedTime = Date.now() + duration * 1000;
|
|
201
|
-
|
|
202
|
-
expect(lockedUntil.getTime()).toBeCloseTo(expectedTime, -2); // Within 1 second
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it('should set TTL equal to duration', async () => {
|
|
206
|
-
const duration = 300;
|
|
207
|
-
mockStorageAdapter.set.mockResolvedValue();
|
|
208
|
-
|
|
209
|
-
await service.lockAccount('192.168.1.1', duration, 'test_reason');
|
|
210
|
-
|
|
211
|
-
expect(mockStorageAdapter.set).toHaveBeenCalledWith(
|
|
212
|
-
'nauth:locked:ip:192.168.1.1',
|
|
213
|
-
(expect as any).anything(),
|
|
214
|
-
duration,
|
|
215
|
-
);
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
// ============================================================================
|
|
220
|
-
// unlockAccount() Method
|
|
221
|
-
// ============================================================================
|
|
222
|
-
|
|
223
|
-
describe('unlockAccount', () => {
|
|
224
|
-
it('should delete lock key and reset failed attempts', async () => {
|
|
225
|
-
mockStorageAdapter.del.mockResolvedValue();
|
|
226
|
-
|
|
227
|
-
await service.unlockAccount('192.168.1.1');
|
|
228
|
-
|
|
229
|
-
expect(mockStorageAdapter.del).toHaveBeenCalledWith('nauth:locked:ip:192.168.1.1');
|
|
230
|
-
expect(mockStorageAdapter.del).toHaveBeenCalledWith('nauth:lockout:ip:192.168.1.1');
|
|
231
|
-
expect(mockStorageAdapter.del).toHaveBeenCalledTimes(2);
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it('should unlock account and reset counter', async () => {
|
|
235
|
-
mockStorageAdapter.del.mockResolvedValue();
|
|
236
|
-
|
|
237
|
-
await service.unlockAccount('10.0.0.1');
|
|
238
|
-
|
|
239
|
-
// Should delete both lock key and attempt counter
|
|
240
|
-
expect(mockStorageAdapter.del).toHaveBeenCalledWith('nauth:locked:ip:10.0.0.1');
|
|
241
|
-
expect(mockStorageAdapter.del).toHaveBeenCalledWith('nauth:lockout:ip:10.0.0.1');
|
|
242
|
-
expect(mockStorageAdapter.del).toHaveBeenCalledTimes(2);
|
|
243
|
-
});
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
// ============================================================================
|
|
247
|
-
// resetFailedAttempts() Method
|
|
248
|
-
// ============================================================================
|
|
249
|
-
|
|
250
|
-
describe('resetFailedAttempts', () => {
|
|
251
|
-
it('should delete failed attempts counter', async () => {
|
|
252
|
-
mockStorageAdapter.del.mockResolvedValue();
|
|
253
|
-
|
|
254
|
-
await service.resetFailedAttempts('192.168.1.1');
|
|
255
|
-
|
|
256
|
-
expect(mockStorageAdapter.del).toHaveBeenCalledWith('nauth:lockout:ip:192.168.1.1');
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it('should reset counter for correct IP', async () => {
|
|
260
|
-
mockStorageAdapter.del.mockResolvedValue();
|
|
261
|
-
|
|
262
|
-
await service.resetFailedAttempts('10.0.0.1');
|
|
263
|
-
|
|
264
|
-
expect(mockStorageAdapter.del).toHaveBeenCalledWith('nauth:lockout:ip:10.0.0.1');
|
|
265
|
-
});
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
// ============================================================================
|
|
269
|
-
// Integration Tests
|
|
270
|
-
// ============================================================================
|
|
271
|
-
|
|
272
|
-
describe('Integration', () => {
|
|
273
|
-
it('should track failed attempts and lock account', async () => {
|
|
274
|
-
mockStorageAdapter.incr
|
|
275
|
-
.mockResolvedValueOnce(1)
|
|
276
|
-
.mockResolvedValueOnce(2)
|
|
277
|
-
.mockResolvedValueOnce(3)
|
|
278
|
-
.mockResolvedValueOnce(4)
|
|
279
|
-
.mockResolvedValueOnce(5);
|
|
280
|
-
mockStorageAdapter.set.mockResolvedValue();
|
|
281
|
-
mockStorageAdapter.exists.mockResolvedValue(false).mockResolvedValue(true);
|
|
282
|
-
|
|
283
|
-
// Record 5 failed attempts
|
|
284
|
-
for (let i = 0; i < 5; i++) {
|
|
285
|
-
await service.recordFailedAttempt('192.168.1.1');
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Lock account
|
|
289
|
-
await service.lockAccount('192.168.1.1', 300, 'max_attempts_exceeded');
|
|
290
|
-
|
|
291
|
-
// Verify locked
|
|
292
|
-
const isLocked = await service.isAccountLocked('192.168.1.1');
|
|
293
|
-
expect(isLocked).toBe(true);
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
it('should unlock and reset attempts', async () => {
|
|
297
|
-
mockStorageAdapter.del.mockResolvedValue();
|
|
298
|
-
mockStorageAdapter.exists.mockResolvedValue(false);
|
|
299
|
-
mockStorageAdapter.get.mockResolvedValue(null);
|
|
300
|
-
|
|
301
|
-
await service.unlockAccount('192.168.1.1');
|
|
302
|
-
|
|
303
|
-
const isLocked = await service.isAccountLocked('192.168.1.1');
|
|
304
|
-
const attempts = await service.getFailedAttempts('192.168.1.1');
|
|
305
|
-
|
|
306
|
-
expect(isLocked).toBe(false);
|
|
307
|
-
expect(attempts).toBe(0);
|
|
308
|
-
});
|
|
309
|
-
});
|
|
310
|
-
});
|