Compare commits
No commits in common. "70701aa124add3f3a7ed089cc13d0092ee87e030" and "40902f001a87aa39751e4a9b3cc630da1216f620" have entirely different histories.
70701aa124
...
40902f001a
@ -112,7 +112,7 @@ export class AuthService {
|
|||||||
if (!record || record.revokedAt || record.expiresAt < new Date() || !record.session) {
|
if (!record || record.revokedAt || record.expiresAt < new Date() || !record.session) {
|
||||||
throw new UnauthorizedException("Invalid or expired refresh token.");
|
throw new UnauthorizedException("Invalid or expired refresh token.");
|
||||||
}
|
}
|
||||||
await this.assertSessionMatches(record.session, context);
|
this.assertSessionMatches(record.session, context);
|
||||||
const sessionNonce = this.createSessionNonce();
|
const sessionNonce = this.createSessionNonce();
|
||||||
await this.prisma.refreshToken.update({ where: { id: record.id }, data: { revokedAt: new Date() } });
|
await this.prisma.refreshToken.update({ where: { id: record.id }, data: { revokedAt: new Date() } });
|
||||||
await this.prisma.session.update({
|
await this.prisma.session.update({
|
||||||
@ -511,32 +511,20 @@ export class AuthService {
|
|||||||
return { session, sessionNonce };
|
return { session, sessionNonce };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertSessionMatches(
|
private assertSessionMatches(
|
||||||
session: { id: string; userId: string; revokedAt: Date | null; expiresAt: Date; ipHash: string; userAgentHash: string; nonceHash?: string | null },
|
session: { revokedAt: Date | null; expiresAt: Date; ipHash: string; userAgentHash: string; nonceHash?: string | null },
|
||||||
context?: RequestContext,
|
context?: RequestContext,
|
||||||
) {
|
) {
|
||||||
if (session.revokedAt || session.expiresAt < new Date()) {
|
if (session.revokedAt || session.expiresAt < new Date()) {
|
||||||
throw new UnauthorizedException("Session expired.");
|
throw new UnauthorizedException("Session expired.");
|
||||||
}
|
}
|
||||||
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 ipHash = this.hashBindingValue(context?.ipAddress ?? "unknown-ip");
|
||||||
const userAgentHash = this.hashBindingValue(context?.userAgent ?? "unknown-user-agent");
|
const userAgentHash = this.hashBindingValue(context?.userAgent ?? "unknown-user-agent");
|
||||||
const ipChanged = session.ipHash !== ipHash;
|
if (session.ipHash !== ipHash || session.userAgentHash !== userAgentHash) {
|
||||||
const userAgentChanged = session.userAgentHash !== userAgentHash;
|
throw new UnauthorizedException("Session binding mismatch.");
|
||||||
if (ipChanged || userAgentChanged) {
|
}
|
||||||
await this.abuseService?.recordEvent({
|
if (!session.nonceHash || !context?.sessionNonce || session.nonceHash !== this.hashToken(context.sessionNonce)) {
|
||||||
userId: session.userId,
|
throw new UnauthorizedException("Session nonce mismatch.");
|
||||||
eventType: "SESSION_BINDING_DRIFT",
|
|
||||||
riskPoints: ipChanged && userAgentChanged ? 6 : 3,
|
|
||||||
severity: "low",
|
|
||||||
ipAddress: context?.ipAddress,
|
|
||||||
userAgent: context?.userAgent,
|
|
||||||
metadata: { ipChanged, userAgentChanged, via: "refresh" },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -52,32 +52,14 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
session.userId !== payload.sub ||
|
session.userId !== payload.sub ||
|
||||||
session.revokedAt ||
|
session.revokedAt ||
|
||||||
session.expiresAt < new Date() ||
|
session.expiresAt < new Date() ||
|
||||||
|
session.ipHash !== this.hashBindingValue(requestContext.ipAddress ?? "unknown-ip") ||
|
||||||
|
session.userAgentHash !== this.hashBindingValue(requestContext.userAgent ?? "unknown-user-agent") ||
|
||||||
!session.nonceHash ||
|
!session.nonceHash ||
|
||||||
!requestContext.sessionNonce ||
|
!requestContext.sessionNonce ||
|
||||||
session.nonceHash !== this.hashBindingValue(requestContext.sessionNonce)
|
session.nonceHash !== this.hashBindingValue(requestContext.sessionNonce)
|
||||||
) {
|
) {
|
||||||
throw new UnauthorizedException("Invalid session binding.");
|
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 nextNonce = crypto.randomBytes(32).toString("base64url");
|
||||||
const response = context.switchToHttp().getResponse<{ setHeader(name: string, value: string): void }>();
|
const response = context.switchToHttp().getResponse<{ setHeader(name: string, value: string): void }>();
|
||||||
await this.prisma.session.update({
|
await this.prisma.session.update({
|
||||||
|
|||||||
10
src/main.ts
10
src/main.ts
@ -25,16 +25,6 @@ async function bootstrap() {
|
|||||||
rawBody: true, // Required for Stripe webhook signature verification
|
rawBody: true, // Required for Stripe webhook signature verification
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Trust the reverse proxy / load balancer in front of this app ─────────
|
|
||||||
// Without this, Express's req.ip (and therefore per-IP rate limiting) sees
|
|
||||||
// the proxy's own connecting IP for every request instead of the real
|
|
||||||
// client IP, so every user behind the same proxy shares one rate-limit
|
|
||||||
// bucket. Only relevant in production, where a proxy is expected; in local
|
|
||||||
// dev the app is hit directly.
|
|
||||||
if (isProduction) {
|
|
||||||
app.getHttpAdapter().getInstance().set("trust proxy", 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Security headers ─────────────────────────────────────────────────────
|
// ─── Security headers ─────────────────────────────────────────────────────
|
||||||
const scriptSrc = isProduction ? ["'self'"] : ["'self'", "'unsafe-inline'"];
|
const scriptSrc = isProduction ? ["'self'"] : ["'self'", "'unsafe-inline'"];
|
||||||
const styleSrc = isProduction ? ["'self'"] : ["'self'", "'unsafe-inline'"];
|
const styleSrc = isProduction ? ["'self'"] : ["'self'", "'unsafe-inline'"];
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user