@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,803 +0,0 @@
|
|
|
1
|
-
import { ISession } from '../interfaces/entities.interface';
|
|
2
|
-
import { Repository, LessThan, MoreThan, In } from 'typeorm';
|
|
3
|
-
import { BaseSession } from '../entities';
|
|
4
|
-
import { StorageAdapter } from '../interfaces/storage-adapter.interface';
|
|
5
|
-
import { InternalAuthAuditService as AuthAuditService } from './auth-audit.service';
|
|
6
|
-
import { AuthAuditEventType } from '../enums/auth-audit-event-type.enum';
|
|
7
|
-
import { ClientInfoService } from './client-info.service';
|
|
8
|
-
import { NAuthLogger } from '../utils/nauth-logger';
|
|
9
|
-
import { NAuthConfig } from '../interfaces/config.interface';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Session Service
|
|
13
|
-
*
|
|
14
|
-
* Manages user sessions and device tracking including:
|
|
15
|
-
* - Creating new sessions with device information
|
|
16
|
-
* - Finding sessions by ID or refresh token
|
|
17
|
-
* - Updating session activity and tokens (rotation)
|
|
18
|
-
* - Revoking individual or all user sessions
|
|
19
|
-
* - Token family management for reuse detection
|
|
20
|
-
* - Token reuse detection with storage tracking
|
|
21
|
-
* - Cleanup of expired sessions
|
|
22
|
-
*
|
|
23
|
-
* Security Features:
|
|
24
|
-
* - Token family tracking for reuse detection
|
|
25
|
-
* - Used refresh token tracking (prevents reuse attacks)
|
|
26
|
-
* - Session expiration management
|
|
27
|
-
* - Device fingerprinting support
|
|
28
|
-
* - Revocation with reason tracking
|
|
29
|
-
* - Activity timestamp updates
|
|
30
|
-
*
|
|
31
|
-
* @example
|
|
32
|
-
* ```typescript
|
|
33
|
-
* // Create session
|
|
34
|
-
* const session = await sessionService.createSession({
|
|
35
|
-
* userId: user.id, // Internal ID (integer)
|
|
36
|
-
* accessTokenHash: 'hash1',
|
|
37
|
-
* refreshTokenHash: 'hash2',
|
|
38
|
-
* tokenFamily: 'family-abc',
|
|
39
|
-
* // Client info (ipAddress, userAgent, etc.) automatically extracted from ClientInfoService
|
|
40
|
-
* expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
|
41
|
-
* });
|
|
42
|
-
*
|
|
43
|
-
* // Revoke all user sessions (global signout)
|
|
44
|
-
* const revokedCount = await sessionService.revokeAllUserSessions(
|
|
45
|
-
* user.id, // Internal ID (integer)
|
|
46
|
-
* 'User requested global signout'
|
|
47
|
-
* );
|
|
48
|
-
* ```
|
|
49
|
-
*/
|
|
50
|
-
export class SessionService {
|
|
51
|
-
constructor(
|
|
52
|
-
private readonly sessionRepository: Repository<BaseSession>,
|
|
53
|
-
private readonly storageAdapter: StorageAdapter,
|
|
54
|
-
private readonly clientInfoService: ClientInfoService,
|
|
55
|
-
private readonly config: NAuthConfig,
|
|
56
|
-
private readonly logger: NAuthLogger,
|
|
57
|
-
private readonly auditService?: AuthAuditService, // Optional - audit trail service (enabled via config.auditLogs.enabled)
|
|
58
|
-
) {}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Calculate session expiration date from config
|
|
62
|
-
*
|
|
63
|
-
* Parses session.maxLifetime config (e.g., '30d', '7d', '5h') and returns
|
|
64
|
-
* the expiration Date. Defaults to 30 days if not configured.
|
|
65
|
-
*
|
|
66
|
-
* @returns Session expiration date
|
|
67
|
-
*/
|
|
68
|
-
getSessionExpirationDate(): Date {
|
|
69
|
-
const maxLifetime = this.config.session?.maxLifetime || '30d';
|
|
70
|
-
const expiresInSeconds = this.parseMaxLifetime(maxLifetime);
|
|
71
|
-
return new Date(Date.now() + expiresInSeconds * 1000);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Parse maxLifetime from string or number
|
|
76
|
-
*
|
|
77
|
-
* @param maxLifetime - Max lifetime (e.g., '30d', '7d', '5h', 2592000)
|
|
78
|
-
* @returns Max lifetime in seconds
|
|
79
|
-
* @private
|
|
80
|
-
*/
|
|
81
|
-
private parseMaxLifetime(maxLifetime: string | number): number {
|
|
82
|
-
if (typeof maxLifetime === 'number') {
|
|
83
|
-
return maxLifetime;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Parse time strings (e.g., '15m', '1h', '30d')
|
|
87
|
-
const units: Record<string, number> = {
|
|
88
|
-
s: 1,
|
|
89
|
-
m: 60,
|
|
90
|
-
h: 3600,
|
|
91
|
-
d: 86400,
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
const match = maxLifetime.match(/^(\d+)([smhd])$/);
|
|
95
|
-
if (!match) {
|
|
96
|
-
this.logger?.warn?.(`Invalid session.maxLifetime format: ${maxLifetime}. Using default 30 days.`);
|
|
97
|
-
return 30 * 86400; // Default to 30 days in seconds
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const [, value, unit] = match;
|
|
101
|
-
return parseInt(value, 10) * units[unit];
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Create a new session
|
|
106
|
-
*
|
|
107
|
-
* Creates a session record with token hashes, device information,
|
|
108
|
-
* and expiration time. Used during login and signup.
|
|
109
|
-
*
|
|
110
|
-
* @param data - Session creation data
|
|
111
|
-
* @param data.userId - Internal user ID (integer, not sub)
|
|
112
|
-
* @param data.accessTokenHash - SHA-256 hash of access token
|
|
113
|
-
* @param data.refreshTokenHash - SHA-256 hash of refresh token
|
|
114
|
-
* @param data.tokenFamily - Token family ID for rotation detection
|
|
115
|
-
* @param data.deviceId - Optional device identifier (UUID). Auto-generated if not provided.
|
|
116
|
-
* @param data.deviceName - Optional device name. Falls back to parsed value from ClientInfoService if not provided.
|
|
117
|
-
* @param data.deviceType - Optional device type (mobile, desktop, tablet). Falls back to parsed value from ClientInfoService if not provided.
|
|
118
|
-
* @param data.expiresAt - Session expiration date
|
|
119
|
-
* @remarks Client info (ipAddress, ipCountry, ipCity, userAgent, platform, browser) is automatically extracted from ClientInfoService context
|
|
120
|
-
* @param data.isRemembered - Whether session is from "remember me"
|
|
121
|
-
* @param data.authMethod - Authentication method: 'password', 'google', 'facebook', 'github', etc.
|
|
122
|
-
* @returns Created session
|
|
123
|
-
*
|
|
124
|
-
* @example
|
|
125
|
-
* ```typescript
|
|
126
|
-
* const session = await sessionService.createSession({
|
|
127
|
-
* userId: user.id, // Internal ID (integer)
|
|
128
|
-
* accessTokenHash: jwtService.hashToken(accessToken),
|
|
129
|
-
* refreshTokenHash: jwtService.hashToken(refreshToken),
|
|
130
|
-
* tokenFamily: jwtService.generateTokenFamily(),
|
|
131
|
-
* // Client info (ipAddress, userAgent, etc.) automatically extracted from ClientInfoService
|
|
132
|
-
* expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
|
133
|
-
* });
|
|
134
|
-
* ```
|
|
135
|
-
*/
|
|
136
|
-
async createSession(data: {
|
|
137
|
-
userId: number; // Internal user ID (integer)
|
|
138
|
-
accessTokenHash: string;
|
|
139
|
-
refreshTokenHash: string;
|
|
140
|
-
tokenFamily: string;
|
|
141
|
-
deviceId?: string;
|
|
142
|
-
deviceName?: string; // Optional - falls back to parsed value from ClientInfoService
|
|
143
|
-
deviceType?: string; // Optional - falls back to parsed value from ClientInfoService
|
|
144
|
-
// Client info (ipAddress, ipCountry, ipCity, userAgent) automatically extracted from ClientInfoService
|
|
145
|
-
expiresAt: Date;
|
|
146
|
-
isRemembered?: boolean;
|
|
147
|
-
authMethod?: string; // Authentication method: 'password', 'google', 'facebook', etc.
|
|
148
|
-
}): Promise<ISession> {
|
|
149
|
-
// ============================================================================
|
|
150
|
-
// Session Limit Enforcement (maxConcurrent)
|
|
151
|
-
// ============================================================================
|
|
152
|
-
const maxConcurrent = this.config.session?.maxConcurrent;
|
|
153
|
-
if (maxConcurrent && maxConcurrent > 0) {
|
|
154
|
-
// Count active sessions for this user (not revoked and not expired)
|
|
155
|
-
const now = new Date();
|
|
156
|
-
// Fetch only IDs of active sessions ordered by oldest activity
|
|
157
|
-
const activeIds: Array<{ id: number }> = (await this.sessionRepository.find({
|
|
158
|
-
select: ['id'],
|
|
159
|
-
where: { userId: data.userId, isRevoked: false, expiresAt: MoreThan(now) },
|
|
160
|
-
order: { lastActivityAt: 'ASC' },
|
|
161
|
-
})) as unknown as Array<{ id: number }>;
|
|
162
|
-
|
|
163
|
-
if (activeIds.length >= maxConcurrent) {
|
|
164
|
-
// Revoke oldest sessions to make room for new one (bulk update)
|
|
165
|
-
const toRevokeCount = activeIds.length - maxConcurrent + 1;
|
|
166
|
-
const idsToRevoke = activeIds.slice(0, toRevokeCount).map((s) => s.id);
|
|
167
|
-
|
|
168
|
-
if (idsToRevoke.length > 0) {
|
|
169
|
-
const nowTs = new Date();
|
|
170
|
-
const result = await this.sessionRepository.update(
|
|
171
|
-
{ id: In(idsToRevoke) },
|
|
172
|
-
{ isRevoked: true, revokedAt: nowTs, revokeReason: 'Max concurrent sessions exceeded' },
|
|
173
|
-
);
|
|
174
|
-
|
|
175
|
-
const affected = result.affected || idsToRevoke.length;
|
|
176
|
-
if (affected > 0) {
|
|
177
|
-
// Single summary audit event
|
|
178
|
-
try {
|
|
179
|
-
await this.auditService?.recordEvent({
|
|
180
|
-
userId: data.userId,
|
|
181
|
-
eventType: AuthAuditEventType.SESSION_REVOKED,
|
|
182
|
-
eventStatus: 'INFO',
|
|
183
|
-
reason: 'Max concurrent sessions exceeded',
|
|
184
|
-
description: `Revoked ${affected} session(s) due to max concurrent sessions limit`,
|
|
185
|
-
metadata: {
|
|
186
|
-
revokedCount: affected,
|
|
187
|
-
sessionIds: idsToRevoke,
|
|
188
|
-
},
|
|
189
|
-
});
|
|
190
|
-
} catch (auditError) {
|
|
191
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
192
|
-
this.logger?.error?.(`Failed to record SESSION_REVOKED summary event: ${errorMessage}`, {
|
|
193
|
-
error: auditError,
|
|
194
|
-
userId: data.userId,
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
// ============================================================================
|
|
202
|
-
// Get client info from context (transparent access - no parameters needed)
|
|
203
|
-
// ============================================================================
|
|
204
|
-
// Client info is automatically extracted by ClientInfoInterceptor and stored in context
|
|
205
|
-
// This includes parsed user agent info (platform, browser, deviceType, deviceName)
|
|
206
|
-
// This allows SessionService to access IP, userAgent, etc. without requiring them as parameters
|
|
207
|
-
const clientInfo = this.clientInfoService.get();
|
|
208
|
-
|
|
209
|
-
// ============================================================================
|
|
210
|
-
// Use parsed device information from ClientInfoService (already parsed by interceptor)
|
|
211
|
-
// ============================================================================
|
|
212
|
-
// ClientInfoService already parsed the user agent in the interceptor
|
|
213
|
-
// Use provided values or fall back to parsed values from context
|
|
214
|
-
const deviceType = data.deviceType || clientInfo.deviceType || null;
|
|
215
|
-
const deviceName = data.deviceName || clientInfo.deviceName || null;
|
|
216
|
-
const platform = clientInfo.platform || null;
|
|
217
|
-
const browser = clientInfo.browser || null;
|
|
218
|
-
|
|
219
|
-
// ============================================================================
|
|
220
|
-
// Generate deviceId if not provided
|
|
221
|
-
// ============================================================================
|
|
222
|
-
// DeviceId is a unique UUID that identifies a specific browser/device
|
|
223
|
-
// It's used for trusted device tracking and MFA "remember device" features
|
|
224
|
-
// In browser contexts, this should be stored in localStorage/sessionStorage
|
|
225
|
-
// and sent with each login request to maintain continuity
|
|
226
|
-
let deviceId = data.deviceId;
|
|
227
|
-
if (!deviceId) {
|
|
228
|
-
const crypto = await import('crypto');
|
|
229
|
-
deviceId = crypto.randomUUID();
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Debug: Log what we're about to save
|
|
233
|
-
if (!clientInfo.ipLatitude || !clientInfo.ipLongitude) {
|
|
234
|
-
this.logger?.warn?.(
|
|
235
|
-
`[SessionService] Creating session WITHOUT coordinates: ` +
|
|
236
|
-
`IP=${clientInfo.ipAddress}, country=${clientInfo.ipCountry}, city=${clientInfo.ipCity}, ` +
|
|
237
|
-
`lat=${clientInfo.ipLatitude}, lon=${clientInfo.ipLongitude}`,
|
|
238
|
-
);
|
|
239
|
-
} else {
|
|
240
|
-
this.logger?.debug?.(
|
|
241
|
-
`[SessionService] Creating session WITH coordinates: ` +
|
|
242
|
-
`IP=${clientInfo.ipAddress}, ${clientInfo.ipCity}, ${clientInfo.ipCountry} ` +
|
|
243
|
-
`(${clientInfo.ipLatitude}, ${clientInfo.ipLongitude})`,
|
|
244
|
-
);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
const session = this.sessionRepository.create({
|
|
248
|
-
userId: data.userId,
|
|
249
|
-
accessTokenHash: data.accessTokenHash,
|
|
250
|
-
refreshTokenHash: data.refreshTokenHash,
|
|
251
|
-
tokenFamily: data.tokenFamily,
|
|
252
|
-
deviceId,
|
|
253
|
-
deviceName,
|
|
254
|
-
deviceType,
|
|
255
|
-
// Client info automatically extracted from ClientInfoService (transparent access)
|
|
256
|
-
ipAddress: clientInfo.ipAddress || null,
|
|
257
|
-
ipCountry: clientInfo.ipCountry || null,
|
|
258
|
-
ipCity: clientInfo.ipCity || null,
|
|
259
|
-
ipLatitude: clientInfo.ipLatitude || null,
|
|
260
|
-
ipLongitude: clientInfo.ipLongitude || null,
|
|
261
|
-
userAgent: clientInfo.userAgent || null,
|
|
262
|
-
platform,
|
|
263
|
-
browser,
|
|
264
|
-
authMethod: data.authMethod || null,
|
|
265
|
-
expiresAt: data.expiresAt,
|
|
266
|
-
isRemembered: data.isRemembered || false,
|
|
267
|
-
lastActivityAt: new Date(),
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
const savedSession = (await this.sessionRepository.save(session)) as unknown as ISession;
|
|
271
|
-
|
|
272
|
-
// ============================================================================
|
|
273
|
-
// Audit: Record session creation
|
|
274
|
-
// ============================================================================
|
|
275
|
-
try {
|
|
276
|
-
await this.auditService?.recordEvent({
|
|
277
|
-
userId: data.userId,
|
|
278
|
-
eventType: AuthAuditEventType.SESSION_CREATED,
|
|
279
|
-
eventStatus: 'INFO',
|
|
280
|
-
sessionId: savedSession.id,
|
|
281
|
-
authMethod: data.authMethod || null,
|
|
282
|
-
// Client info automatically included from context
|
|
283
|
-
metadata: {
|
|
284
|
-
deviceId: savedSession.deviceId,
|
|
285
|
-
deviceName: savedSession.deviceName,
|
|
286
|
-
deviceType: savedSession.deviceType,
|
|
287
|
-
isRemembered: savedSession.isRemembered,
|
|
288
|
-
},
|
|
289
|
-
});
|
|
290
|
-
} catch (auditError) {
|
|
291
|
-
// Non-blocking: Log but continue
|
|
292
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
293
|
-
this.logger?.error?.(`Failed to record SESSION_CREATED audit event: ${errorMessage}`, {
|
|
294
|
-
error: auditError,
|
|
295
|
-
userId: data.userId,
|
|
296
|
-
sessionId: savedSession.id,
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
return savedSession;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* Find session by ID
|
|
305
|
-
* @param sessionId - Session ID (can be string from JWT or number)
|
|
306
|
-
*/
|
|
307
|
-
async findById(sessionId: string | number): Promise<ISession | null> {
|
|
308
|
-
return (await this.sessionRepository.findOne({
|
|
309
|
-
where: { id: typeof sessionId === 'string' ? parseInt(sessionId, 10) : sessionId },
|
|
310
|
-
})) as ISession | null;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* Find session by ID with minimal fields (hot-path)
|
|
315
|
-
* @param sessionId - Session ID (string or number)
|
|
316
|
-
*/
|
|
317
|
-
async findByIdLight(
|
|
318
|
-
sessionId: string | number,
|
|
319
|
-
): Promise<Pick<ISession, 'id' | 'version' | 'isRevoked' | 'expiresAt' | 'userId'> | null> {
|
|
320
|
-
const id = typeof sessionId === 'string' ? parseInt(sessionId, 10) : sessionId;
|
|
321
|
-
// Select minimal session fields to reduce DB payload
|
|
322
|
-
const record = (await this.sessionRepository.findOne({
|
|
323
|
-
select: ['id', 'version', 'isRevoked', 'expiresAt', 'userId'],
|
|
324
|
-
where: { id },
|
|
325
|
-
})) as unknown as ISession | null;
|
|
326
|
-
|
|
327
|
-
if (!record) return null;
|
|
328
|
-
const versionValue = (record as unknown as { version?: number }).version ?? (undefined as unknown as number);
|
|
329
|
-
const light = {
|
|
330
|
-
id: record.id,
|
|
331
|
-
version: versionValue,
|
|
332
|
-
isRevoked: record.isRevoked,
|
|
333
|
-
expiresAt: record.expiresAt,
|
|
334
|
-
userId: record.userId,
|
|
335
|
-
} as unknown as Pick<ISession, 'id' | 'version' | 'isRevoked' | 'expiresAt' | 'userId'>;
|
|
336
|
-
return light;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Find session by refresh token hash
|
|
341
|
-
*/
|
|
342
|
-
async findByRefreshToken(refreshTokenHash: string): Promise<ISession | null> {
|
|
343
|
-
return (await this.sessionRepository.findOne({
|
|
344
|
-
select: ['id', 'userId', 'isRevoked', 'tokenFamily', 'expiresAt'],
|
|
345
|
-
where: { refreshTokenHash, isRevoked: false },
|
|
346
|
-
})) as ISession | null;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* Find all active sessions for a user
|
|
351
|
-
* @param userId - Internal user ID (integer)
|
|
352
|
-
* @returns Array of active sessions
|
|
353
|
-
*/
|
|
354
|
-
async findUserSessions(userId: number): Promise<ISession[]> {
|
|
355
|
-
return (await this.sessionRepository.find({
|
|
356
|
-
where: { userId, isRevoked: false },
|
|
357
|
-
order: { createdAt: 'DESC' },
|
|
358
|
-
})) as unknown as ISession[];
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/**
|
|
362
|
-
* Update session activity timestamp
|
|
363
|
-
* @param sessionId - Session ID (can be string from JWT or number)
|
|
364
|
-
*/
|
|
365
|
-
async updateActivity(sessionId: string | number): Promise<void> {
|
|
366
|
-
const id = typeof sessionId === 'string' ? parseInt(sessionId, 10) : sessionId;
|
|
367
|
-
await this.sessionRepository.update(id, {
|
|
368
|
-
lastActivityAt: new Date(),
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* Update session with new tokens (for rotation)
|
|
374
|
-
* @param sessionId - Session ID (can be string from JWT or number)
|
|
375
|
-
*/
|
|
376
|
-
async updateTokens(sessionId: string | number, accessTokenHash: string, refreshTokenHash: string): Promise<void> {
|
|
377
|
-
const id = typeof sessionId === 'string' ? parseInt(sessionId, 10) : sessionId;
|
|
378
|
-
await this.sessionRepository.update(id, {
|
|
379
|
-
accessTokenHash,
|
|
380
|
-
refreshTokenHash,
|
|
381
|
-
lastActivityAt: new Date(),
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* Create a session and update token hashes atomically within one transaction
|
|
387
|
-
*
|
|
388
|
-
* Uses a callback to generate token hashes after obtaining the session ID.
|
|
389
|
-
* This allows callers to embed sessionId in JWTs, then persist hashes atomically.
|
|
390
|
-
*/
|
|
391
|
-
async createSessionAtomic<T>(
|
|
392
|
-
data: {
|
|
393
|
-
userId: number;
|
|
394
|
-
tokenFamily: string;
|
|
395
|
-
deviceId?: string;
|
|
396
|
-
deviceName?: string; // Optional - falls back to parsed value from ClientInfoService
|
|
397
|
-
deviceType?: string; // Optional - falls back to parsed value from ClientInfoService
|
|
398
|
-
// Client info (ipAddress, ipCountry, ipCity, userAgent) automatically extracted from ClientInfoService
|
|
399
|
-
expiresAt: Date;
|
|
400
|
-
isRemembered?: boolean;
|
|
401
|
-
authMethod?: string;
|
|
402
|
-
},
|
|
403
|
-
generateHashes: (sessionId: number) => Promise<{ accessTokenHash: string; refreshTokenHash: string; extra?: T }>,
|
|
404
|
-
): Promise<{ session: ISession; extra?: T }> {
|
|
405
|
-
// Get client info from context (transparent access)
|
|
406
|
-
// ClientInfoService already parsed the user agent in the interceptor
|
|
407
|
-
const clientInfo = this.clientInfoService.get();
|
|
408
|
-
|
|
409
|
-
const result = await this.sessionRepository.manager.transaction(async (trx) => {
|
|
410
|
-
// Use parsed device information from ClientInfoService (already parsed by interceptor)
|
|
411
|
-
// Use provided values or fall back to parsed values from context
|
|
412
|
-
const deviceType = data.deviceType || clientInfo.deviceType || null;
|
|
413
|
-
const deviceName = data.deviceName || clientInfo.deviceName || null;
|
|
414
|
-
const platform = clientInfo.platform || null;
|
|
415
|
-
const browser = clientInfo.browser || null;
|
|
416
|
-
|
|
417
|
-
// Generate deviceId if missing
|
|
418
|
-
let deviceId = data.deviceId;
|
|
419
|
-
if (!deviceId) {
|
|
420
|
-
const crypto = await import('crypto');
|
|
421
|
-
deviceId = crypto.randomUUID();
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
// Create with placeholder hashes (non-nullable columns)
|
|
425
|
-
const sessionEntity = this.sessionRepository.create({
|
|
426
|
-
userId: data.userId,
|
|
427
|
-
accessTokenHash: '',
|
|
428
|
-
refreshTokenHash: '',
|
|
429
|
-
tokenFamily: data.tokenFamily,
|
|
430
|
-
deviceId,
|
|
431
|
-
deviceName,
|
|
432
|
-
deviceType,
|
|
433
|
-
// Client info automatically extracted from ClientInfoService (transparent access)
|
|
434
|
-
ipAddress: clientInfo.ipAddress || null,
|
|
435
|
-
ipCountry: clientInfo.ipCountry || null,
|
|
436
|
-
ipCity: clientInfo.ipCity || null,
|
|
437
|
-
ipLatitude: clientInfo.ipLatitude || null,
|
|
438
|
-
ipLongitude: clientInfo.ipLongitude || null,
|
|
439
|
-
userAgent: clientInfo.userAgent || null,
|
|
440
|
-
platform,
|
|
441
|
-
browser,
|
|
442
|
-
authMethod: data.authMethod || null,
|
|
443
|
-
expiresAt: data.expiresAt,
|
|
444
|
-
isRemembered: data.isRemembered || false,
|
|
445
|
-
lastActivityAt: new Date(),
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
// Debug: Log what we're about to save in atomic transaction
|
|
449
|
-
if (!clientInfo.ipLatitude || !clientInfo.ipLongitude) {
|
|
450
|
-
this.logger?.warn?.(
|
|
451
|
-
`[SessionService.createSessionAtomic] Creating session WITHOUT coordinates: ` +
|
|
452
|
-
`IP=${clientInfo.ipAddress}, country=${clientInfo.ipCountry}, city=${clientInfo.ipCity}, ` +
|
|
453
|
-
`lat=${clientInfo.ipLatitude}, lon=${clientInfo.ipLongitude}`,
|
|
454
|
-
);
|
|
455
|
-
} else {
|
|
456
|
-
this.logger?.debug?.(
|
|
457
|
-
`[SessionService.createSessionAtomic] Creating session WITH coordinates: ` +
|
|
458
|
-
`IP=${clientInfo.ipAddress}, ${clientInfo.ipCity}, ${clientInfo.ipCountry} ` +
|
|
459
|
-
`(${clientInfo.ipLatitude}, ${clientInfo.ipLongitude})`,
|
|
460
|
-
);
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
const saved = await trx.save(sessionEntity);
|
|
464
|
-
const savedId = saved.id as number;
|
|
465
|
-
|
|
466
|
-
const { accessTokenHash, refreshTokenHash, extra } = await generateHashes(savedId);
|
|
467
|
-
|
|
468
|
-
await trx
|
|
469
|
-
.createQueryBuilder()
|
|
470
|
-
.update(this.sessionRepository.target)
|
|
471
|
-
.set({ accessTokenHash, refreshTokenHash, lastActivityAt: new Date() })
|
|
472
|
-
.where({ id: savedId })
|
|
473
|
-
.execute();
|
|
474
|
-
|
|
475
|
-
// Re-fetch minimal session fields to return
|
|
476
|
-
const sessionLight = (await trx.findOne(this.sessionRepository.target, {
|
|
477
|
-
where: { id: savedId },
|
|
478
|
-
})) as unknown as ISession | null;
|
|
479
|
-
|
|
480
|
-
if (!sessionLight) {
|
|
481
|
-
throw new Error('Failed to load session after creation');
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
return { session: sessionLight, extra } as { session: ISession; extra?: T };
|
|
485
|
-
});
|
|
486
|
-
|
|
487
|
-
// ============================================================================
|
|
488
|
-
// Audit: Record session creation
|
|
489
|
-
// ============================================================================
|
|
490
|
-
try {
|
|
491
|
-
await this.auditService?.recordEvent({
|
|
492
|
-
userId: data.userId,
|
|
493
|
-
eventType: AuthAuditEventType.SESSION_CREATED,
|
|
494
|
-
eventStatus: 'INFO',
|
|
495
|
-
sessionId: result.session.id,
|
|
496
|
-
authMethod: data.authMethod || null,
|
|
497
|
-
// Client info automatically included from context
|
|
498
|
-
metadata: {
|
|
499
|
-
deviceId: result.session.deviceId,
|
|
500
|
-
deviceName: result.session.deviceName,
|
|
501
|
-
deviceType: result.session.deviceType,
|
|
502
|
-
isRemembered: result.session.isRemembered,
|
|
503
|
-
},
|
|
504
|
-
});
|
|
505
|
-
} catch (auditError) {
|
|
506
|
-
// Non-blocking: Log but continue
|
|
507
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
508
|
-
this.logger?.error?.(`Failed to record SESSION_CREATED audit event: ${errorMessage}`, {
|
|
509
|
-
error: auditError,
|
|
510
|
-
userId: data.userId,
|
|
511
|
-
sessionId: result.session.id,
|
|
512
|
-
});
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
return result;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
/**
|
|
519
|
-
* Revoke a single session
|
|
520
|
-
* @param sessionId - Session ID (can be string from JWT or number)
|
|
521
|
-
* @param reason - Optional reason for revocation
|
|
522
|
-
* @param metadata - Optional metadata to include in audit trail
|
|
523
|
-
*/
|
|
524
|
-
async revokeSession(sessionId: string | number, reason?: string, metadata?: Record<string, unknown>): Promise<void> {
|
|
525
|
-
const id = typeof sessionId === 'string' ? parseInt(sessionId, 10) : sessionId;
|
|
526
|
-
|
|
527
|
-
// Get session to retrieve userId for audit logging
|
|
528
|
-
const session = await this.findById(id);
|
|
529
|
-
if (!session) {
|
|
530
|
-
return; // Session doesn't exist, nothing to revoke
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
await this.sessionRepository.update(id, {
|
|
534
|
-
isRevoked: true,
|
|
535
|
-
revokedAt: new Date(),
|
|
536
|
-
revokeReason: reason,
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
// ============================================================================
|
|
540
|
-
// Audit: Record session revocation
|
|
541
|
-
// ============================================================================
|
|
542
|
-
try {
|
|
543
|
-
await this.auditService?.recordEvent({
|
|
544
|
-
userId: session.userId,
|
|
545
|
-
eventType: AuthAuditEventType.SESSION_REVOKED,
|
|
546
|
-
eventStatus: 'INFO',
|
|
547
|
-
sessionId: id,
|
|
548
|
-
reason: reason || 'User logout',
|
|
549
|
-
description: `Session revoked: ${reason || 'User logout'}`,
|
|
550
|
-
// Client info automatically included from context
|
|
551
|
-
metadata: metadata || undefined,
|
|
552
|
-
});
|
|
553
|
-
} catch (auditError) {
|
|
554
|
-
// Non-blocking: Log but continue
|
|
555
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
556
|
-
this.logger?.error?.(`Failed to record SESSION_REVOKED audit event: ${errorMessage}`, {
|
|
557
|
-
error: auditError,
|
|
558
|
-
userId: session.userId,
|
|
559
|
-
sessionId: id,
|
|
560
|
-
});
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
/**
|
|
565
|
-
* Revoke all sessions for a user (global signout)
|
|
566
|
-
* @param userId - Internal user ID (integer)
|
|
567
|
-
* @param reason - Optional reason for revocation
|
|
568
|
-
* @returns Number of sessions revoked
|
|
569
|
-
*/
|
|
570
|
-
async revokeAllUserSessions(userId: number, reason?: string): Promise<number> {
|
|
571
|
-
// Get sessions before revoking for audit logging
|
|
572
|
-
const sessions = await this.findUserSessions(userId);
|
|
573
|
-
|
|
574
|
-
const result = await this.sessionRepository.update(
|
|
575
|
-
{ userId, isRevoked: false },
|
|
576
|
-
{
|
|
577
|
-
isRevoked: true,
|
|
578
|
-
revokedAt: new Date(),
|
|
579
|
-
revokeReason: reason,
|
|
580
|
-
},
|
|
581
|
-
);
|
|
582
|
-
|
|
583
|
-
const revokedCount = result.affected || 0;
|
|
584
|
-
|
|
585
|
-
// ============================================================================
|
|
586
|
-
// Audit: Record session revocations (one event per session for global signout)
|
|
587
|
-
// ============================================================================
|
|
588
|
-
if (revokedCount > 0) {
|
|
589
|
-
try {
|
|
590
|
-
const isGlobalSignout = reason === 'Global signout';
|
|
591
|
-
|
|
592
|
-
if (isGlobalSignout) {
|
|
593
|
-
// For global signout, record individual SESSION_REVOKED event for each session
|
|
594
|
-
// AuthService.logoutAll() will record a GLOBAL_SIGNOUT event separately
|
|
595
|
-
for (const session of sessions) {
|
|
596
|
-
try {
|
|
597
|
-
await this.auditService?.recordEvent({
|
|
598
|
-
userId,
|
|
599
|
-
eventType: AuthAuditEventType.SESSION_REVOKED,
|
|
600
|
-
eventStatus: 'INFO',
|
|
601
|
-
reason: 'Global signout',
|
|
602
|
-
description: `Session revoked by global signout`,
|
|
603
|
-
sessionId: session.id,
|
|
604
|
-
// Client info automatically included from context
|
|
605
|
-
metadata: {
|
|
606
|
-
revokedBy: 'global_signout',
|
|
607
|
-
},
|
|
608
|
-
});
|
|
609
|
-
} catch (sessionAuditError) {
|
|
610
|
-
// Non-blocking: Log but continue with other sessions
|
|
611
|
-
const errorMessage = sessionAuditError instanceof Error ? sessionAuditError.message : 'Unknown error';
|
|
612
|
-
this.logger?.error?.(
|
|
613
|
-
`Failed to record SESSION_REVOKED audit event for session ${session.id}: ${errorMessage}`,
|
|
614
|
-
{
|
|
615
|
-
error: sessionAuditError,
|
|
616
|
-
userId,
|
|
617
|
-
sessionId: session.id,
|
|
618
|
-
},
|
|
619
|
-
);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
} else {
|
|
623
|
-
// For other reasons (e.g., "Login from new session"), record one summary event
|
|
624
|
-
await this.auditService?.recordEvent({
|
|
625
|
-
userId,
|
|
626
|
-
eventType: AuthAuditEventType.SESSION_REVOKED,
|
|
627
|
-
eventStatus: 'INFO',
|
|
628
|
-
reason: reason || 'Session revocation',
|
|
629
|
-
description: `All user sessions revoked (${revokedCount} session(s))`,
|
|
630
|
-
// Client info automatically included from context
|
|
631
|
-
metadata: {
|
|
632
|
-
revokedCount,
|
|
633
|
-
sessionIds: sessions.map((s) => s.id),
|
|
634
|
-
},
|
|
635
|
-
});
|
|
636
|
-
}
|
|
637
|
-
} catch (auditError) {
|
|
638
|
-
// Non-blocking: Log but continue
|
|
639
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
640
|
-
this.logger?.error?.(`Failed to record SESSION_REVOKED audit event (all sessions): ${errorMessage}`, {
|
|
641
|
-
error: auditError,
|
|
642
|
-
userId,
|
|
643
|
-
revokedCount,
|
|
644
|
-
});
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
return revokedCount;
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
/**
|
|
652
|
-
* Revoke all sessions in a token family (for reuse detection)
|
|
653
|
-
*/
|
|
654
|
-
async revokeTokenFamily(tokenFamily: string, reason?: string): Promise<number> {
|
|
655
|
-
const result = await this.sessionRepository.update(
|
|
656
|
-
{ tokenFamily, isRevoked: false },
|
|
657
|
-
{
|
|
658
|
-
isRevoked: true,
|
|
659
|
-
revokedAt: new Date(),
|
|
660
|
-
revokeReason: reason || 'Token reuse detected',
|
|
661
|
-
},
|
|
662
|
-
);
|
|
663
|
-
|
|
664
|
-
return result.affected || 0;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
/**
|
|
668
|
-
* Cleanup expired sessions
|
|
669
|
-
*/
|
|
670
|
-
async cleanupExpiredSessions(): Promise<number> {
|
|
671
|
-
const result = await this.sessionRepository.delete({
|
|
672
|
-
expiresAt: LessThan(new Date()),
|
|
673
|
-
});
|
|
674
|
-
|
|
675
|
-
return result.affected || 0;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
/**
|
|
679
|
-
* Count active sessions for a user
|
|
680
|
-
* @param userId - Internal user ID (integer)
|
|
681
|
-
* @returns Number of active sessions
|
|
682
|
-
*/
|
|
683
|
-
async countUserSessions(userId: number): Promise<number> {
|
|
684
|
-
return await this.sessionRepository.count({
|
|
685
|
-
where: { userId, isRevoked: false },
|
|
686
|
-
});
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
// ============================================================================
|
|
690
|
-
// Token Reuse Detection (Security Feature)
|
|
691
|
-
// ============================================================================
|
|
692
|
-
|
|
693
|
-
/**
|
|
694
|
-
* Mark a refresh token as used
|
|
695
|
-
*
|
|
696
|
-
* Stores the token hash in cache with expiration matching the refresh token TTL.
|
|
697
|
-
* Used to detect token reuse attacks where stolen tokens are reused multiple times.
|
|
698
|
-
*
|
|
699
|
-
* ⚠️ SECURITY CRITICAL: This prevents token replay attacks
|
|
700
|
-
*
|
|
701
|
-
* @param tokenHash - SHA-256 hash of the refresh token
|
|
702
|
-
* @param ttlSeconds - Time to live in seconds (should match refresh token expiry)
|
|
703
|
-
*
|
|
704
|
-
* @example
|
|
705
|
-
* ```typescript
|
|
706
|
-
* // Mark token as used during refresh
|
|
707
|
-
* await sessionService.markRefreshTokenAsUsed(tokenHash, 30 * 24 * 60 * 60);
|
|
708
|
-
* ```
|
|
709
|
-
*/
|
|
710
|
-
async markRefreshTokenAsUsed(tokenHash: string, ttlSeconds: number): Promise<boolean> {
|
|
711
|
-
const key = `used-token:${tokenHash}`;
|
|
712
|
-
|
|
713
|
-
// Use atomic set-if-not-exists operation
|
|
714
|
-
// Returns null if key already exists (NX failed), string if key was set
|
|
715
|
-
const result = await this.storageAdapter.set(key, 'true', ttlSeconds, { nx: true });
|
|
716
|
-
|
|
717
|
-
return result !== null; // True if successfully set, false if already existed
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
/**
|
|
721
|
-
* Check if a refresh token has been used before
|
|
722
|
-
*
|
|
723
|
-
* If token has been used, it indicates a token reuse attack and the entire
|
|
724
|
-
* token family should be revoked immediately.
|
|
725
|
-
*
|
|
726
|
-
* @param tokenHash - SHA-256 hash of the refresh token
|
|
727
|
-
* @returns True if token has been used before, false otherwise
|
|
728
|
-
*
|
|
729
|
-
* @example
|
|
730
|
-
* ```typescript
|
|
731
|
-
* const isReused = await sessionService.isRefreshTokenUsed(tokenHash);
|
|
732
|
-
* if (isReused) {
|
|
733
|
-
* // TOKEN REUSE DETECTED - SECURITY BREACH!
|
|
734
|
-
* await sessionService.revokeTokenFamily(session.tokenFamily);
|
|
735
|
-
* throw new UnauthorizedException('Token reuse detected');
|
|
736
|
-
* }
|
|
737
|
-
* ```
|
|
738
|
-
*/
|
|
739
|
-
async isRefreshTokenUsed(tokenHash: string): Promise<boolean> {
|
|
740
|
-
const key = `used-token:${tokenHash}`;
|
|
741
|
-
return await this.storageAdapter.exists(key);
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
/**
|
|
745
|
-
* Acquire a distributed lock for token refresh
|
|
746
|
-
*
|
|
747
|
-
* Uses atomic set-if-not-exists (NX) operation to prevent concurrent refresh attempts.
|
|
748
|
-
* Lock is automatically released after TTL expires, or manually via releaseRefreshLock.
|
|
749
|
-
*
|
|
750
|
-
* @param lockKey - Lock key (e.g., `session-refresh:${sessionId}` or `refresh-lock:${tokenHash}`)
|
|
751
|
-
* @param ttlMs - Lock TTL in milliseconds (default: 10000ms)
|
|
752
|
-
* @returns True if lock was acquired, false if already locked by another request
|
|
753
|
-
*
|
|
754
|
-
* @example
|
|
755
|
-
* ```typescript
|
|
756
|
-
* const lockKey = `session-refresh:${sessionId}`;
|
|
757
|
-
* const lockAcquired = await sessionService.acquireRefreshLock(lockKey, 10000);
|
|
758
|
-
* if (!lockAcquired) {
|
|
759
|
-
* throw new Error('Refresh already in progress');
|
|
760
|
-
* }
|
|
761
|
-
* try {
|
|
762
|
-
* // ... perform refresh ...
|
|
763
|
-
* } finally {
|
|
764
|
-
* await sessionService.releaseRefreshLock(lockKey);
|
|
765
|
-
* }
|
|
766
|
-
* ```
|
|
767
|
-
*/
|
|
768
|
-
async acquireRefreshLock(lockKey: string, ttlMs: number = 10000): Promise<boolean> {
|
|
769
|
-
// ============================================================================
|
|
770
|
-
// CRITICAL FIX: Use atomic set-if-not-exists (NX) for lock acquisition
|
|
771
|
-
// ============================================================================
|
|
772
|
-
// The set() method with nx: true uses a transaction with pessimistic locking
|
|
773
|
-
// to atomically check and insert. This ensures only one request can acquire
|
|
774
|
-
// the lock even when multiple requests arrive simultaneously.
|
|
775
|
-
// Increased default TTL to 10 seconds to handle slower database operations
|
|
776
|
-
// Add small jitter (±5%) to reduce synchronized expirations under load
|
|
777
|
-
const baseTtlSeconds = Math.max(1, Math.ceil(ttlMs / 1000));
|
|
778
|
-
const jitterMax = Math.max(1, Math.floor(baseTtlSeconds * 0.05));
|
|
779
|
-
const jitter = Math.floor(Math.random() * (jitterMax * 2 + 1)) - jitterMax; // [-jitterMax, +jitterMax]
|
|
780
|
-
const ttlWithJitter = Math.max(1, baseTtlSeconds + jitter);
|
|
781
|
-
|
|
782
|
-
const result = await this.storageAdapter.set(lockKey, 'locked', ttlWithJitter, { nx: true });
|
|
783
|
-
|
|
784
|
-
const acquired = result !== null;
|
|
785
|
-
|
|
786
|
-
// Debug logging to help diagnose lock issues
|
|
787
|
-
if (!acquired) {
|
|
788
|
-
// Lock acquisition failed - another request has it
|
|
789
|
-
// This is expected behavior when multiple requests try to refresh simultaneously
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
return acquired;
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
/**
|
|
796
|
-
* Release a distributed lock for token refresh
|
|
797
|
-
*
|
|
798
|
-
* @param lockKey - Lock key (must match the key used in acquireRefreshLock)
|
|
799
|
-
*/
|
|
800
|
-
async releaseRefreshLock(lockKey: string): Promise<void> {
|
|
801
|
-
await this.storageAdapter.del(lockKey);
|
|
802
|
-
}
|
|
803
|
-
}
|