diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 4b37622..e56a6bb 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -112,7 +112,7 @@ export class AuthService { if (!record || record.revokedAt || record.expiresAt < new Date() || !record.session) { throw new UnauthorizedException("Invalid or expired refresh token."); } - this.assertSessionMatches(record.session, context); + await this.assertSessionMatches(record.session, context); const sessionNonce = this.createSessionNonce(); await this.prisma.refreshToken.update({ where: { id: record.id }, data: { revokedAt: new Date() } }); await this.prisma.session.update({ @@ -511,21 +511,33 @@ export class AuthService { return { session, sessionNonce }; } - private assertSessionMatches( - session: { revokedAt: Date | null; expiresAt: Date; ipHash: string; userAgentHash: string; nonceHash?: string | null }, + private async assertSessionMatches( + session: { id: string; userId: string; revokedAt: Date | null; expiresAt: Date; ipHash: string; userAgentHash: string; nonceHash?: string | null }, context?: RequestContext, ) { if (session.revokedAt || session.expiresAt < new Date()) { throw new UnauthorizedException("Session expired."); } - const ipHash = this.hashBindingValue(context?.ipAddress ?? "unknown-ip"); - const userAgentHash = this.hashBindingValue(context?.userAgent ?? "unknown-user-agent"); - if (session.ipHash !== ipHash || session.userAgentHash !== userAgentHash) { - throw new UnauthorizedException("Session binding mismatch."); - } if (!session.nonceHash || !context?.sessionNonce || session.nonceHash !== this.hashToken(context.sessionNonce)) { throw new UnauthorizedException("Session nonce mismatch."); } + // IP and user-agent are monitored as abuse/risk signals rather than + // enforced as a hard match — see JwtAuthGuard for the same reasoning. + const ipHash = this.hashBindingValue(context?.ipAddress ?? "unknown-ip"); + const userAgentHash = this.hashBindingValue(context?.userAgent ?? "unknown-user-agent"); + const ipChanged = session.ipHash !== ipHash; + const userAgentChanged = session.userAgentHash !== userAgentHash; + if (ipChanged || userAgentChanged) { + await this.abuseService?.recordEvent({ + userId: session.userId, + eventType: "SESSION_BINDING_DRIFT", + riskPoints: ipChanged && userAgentChanged ? 6 : 3, + severity: "low", + ipAddress: context?.ipAddress, + userAgent: context?.userAgent, + metadata: { ipChanged, userAgentChanged, via: "refresh" }, + }); + } } private createSessionNonce(): string { diff --git a/src/common/guards/jwt-auth.guard.ts b/src/common/guards/jwt-auth.guard.ts index 457cb47..fb23dc0 100644 --- a/src/common/guards/jwt-auth.guard.ts +++ b/src/common/guards/jwt-auth.guard.ts @@ -52,14 +52,32 @@ export class JwtAuthGuard implements CanActivate { session.userId !== payload.sub || session.revokedAt || session.expiresAt < new Date() || - session.ipHash !== this.hashBindingValue(requestContext.ipAddress ?? "unknown-ip") || - session.userAgentHash !== this.hashBindingValue(requestContext.userAgent ?? "unknown-user-agent") || !session.nonceHash || !requestContext.sessionNonce || session.nonceHash !== this.hashBindingValue(requestContext.sessionNonce) ) { throw new UnauthorizedException("Invalid session binding."); } + // IP and user-agent are recorded and monitored as abuse/risk signals + // rather than enforced as a hard match: real users legitimately change + // IP mid-session (mobile networks, corporate NAT pools, VPNs, CDN edge + // changes) far more often than an attacker would be replaying a stolen + // token, and the still-enforced single-use rotating nonce plus + // refresh-token rotation already prevent token replay from another + // device. + const ipChanged = session.ipHash !== this.hashBindingValue(requestContext.ipAddress ?? "unknown-ip"); + const userAgentChanged = session.userAgentHash !== this.hashBindingValue(requestContext.userAgent ?? "unknown-user-agent"); + if (ipChanged || userAgentChanged) { + await this.abuseService.recordEvent({ + userId: session.userId, + eventType: "SESSION_BINDING_DRIFT", + riskPoints: ipChanged && userAgentChanged ? 6 : 3, + severity: "low", + ipAddress: requestContext.ipAddress, + userAgent: requestContext.userAgent, + metadata: { ipChanged, userAgentChanged }, + }); + } const nextNonce = crypto.randomBytes(32).toString("base64url"); const response = context.switchToHttp().getResponse<{ setHeader(name: string, value: string): void }>(); await this.prisma.session.update({