Stop hard-blocking sessions on IP/user-agent change
Session IP and user-agent were enforced as an exact-match gate on every authenticated request and on refresh: any mismatch threw Invalid session binding / Session binding mismatch and forced a logout. In production this made the app nearly unusable, because real users' IPs change constantly and normally in ways that have nothing to do with account theft — mobile network handovers, corporate NAT pools, VPNs, CDN edge changes, Wi-Fi/cellular switching. The still-enforced single-use rotating session nonce plus refresh-token rotation and revocation already prevent a stolen token from being replayed on another device, so the hard IP/UA gate was mostly adding false-positive lockouts rather than real security. Now IP and user-agent changes are recorded as a SESSION_BINDING_DRIFT abuse event (feeding the existing risk-scoring system) instead of rejecting the request, so the security signal isn't lost, it's just no longer a blanket block on legitimate use.
This commit is contained in:
parent
90dfe65fa3
commit
70701aa124
@ -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 {
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user