Add rotating session nonce binding
This commit is contained in:
parent
d51fc3b954
commit
6395b63098
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "Session" ADD COLUMN "nonceHash" TEXT NOT NULL DEFAULT '';
|
||||||
@ -549,6 +549,7 @@ model Session {
|
|||||||
userId String
|
userId String
|
||||||
ipHash String
|
ipHash String
|
||||||
userAgentHash String
|
userAgentHash String
|
||||||
|
nonceHash String @default("")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
lastSeenAt DateTime @default(now())
|
lastSeenAt DateTime @default(now())
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
export type RequestContext = {
|
export type RequestContext = {
|
||||||
ipAddress?: string;
|
ipAddress?: string;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
|
sessionNonce?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function requestContextFrom(req: { ip?: string; headers?: { [key: string]: unknown } }): RequestContext {
|
export function requestContextFrom(req: { ip?: string; headers?: { [key: string]: unknown } }): RequestContext {
|
||||||
@ -9,5 +10,8 @@ export function requestContextFrom(req: { ip?: string; headers?: { [key: string]
|
|||||||
return {
|
return {
|
||||||
ipAddress: typeof forwardedFor === "string" ? forwardedFor.split(",")[0].trim() : req.ip,
|
ipAddress: typeof forwardedFor === "string" ? forwardedFor.split(",")[0].trim() : req.ip,
|
||||||
userAgent: typeof userAgent === "string" ? userAgent : undefined,
|
userAgent: typeof userAgent === "string" ? userAgent : undefined,
|
||||||
|
sessionNonce: typeof req.headers?.["x-ledgerone-session-nonce"] === "string"
|
||||||
|
? req.headers["x-ledgerone-session-nonce"]
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,11 +52,12 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
await this.emailService.sendVerificationEmail(email, verifyToken);
|
await this.emailService.sendVerificationEmail(email, verifyToken);
|
||||||
|
|
||||||
const { accessToken, refreshToken } = await this.issueTokensForUser(user.id, context);
|
const { accessToken, refreshToken, sessionNonce } = await this.issueTokensForUser(user.id, context);
|
||||||
return {
|
return {
|
||||||
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
|
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
|
sessionNonce,
|
||||||
message: "Registration successful. Please verify your email.",
|
message: "Registration successful. Please verify your email.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -82,11 +83,12 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.login", metadata: { email } } });
|
await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.login", metadata: { email } } });
|
||||||
const { accessToken, refreshToken } = await this.issueTokensForUser(user.id, context);
|
const { accessToken, refreshToken, sessionNonce } = await this.issueTokensForUser(user.id, context);
|
||||||
return {
|
return {
|
||||||
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
|
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
|
sessionNonce,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,21 +112,22 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException("Invalid or expired refresh token.");
|
throw new UnauthorizedException("Invalid or expired refresh token.");
|
||||||
}
|
}
|
||||||
this.assertSessionMatches(record.session, context);
|
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.refreshToken.update({ where: { id: record.id }, data: { revokedAt: new Date() } });
|
||||||
await this.prisma.session.update({
|
await this.prisma.session.update({
|
||||||
where: { id: record.sessionId! },
|
where: { id: record.sessionId! },
|
||||||
data: { lastSeenAt: new Date() },
|
data: { lastSeenAt: new Date(), nonceHash: this.hashToken(sessionNonce) },
|
||||||
});
|
});
|
||||||
const accessToken = this.signAccessToken(record.userId, record.sessionId!);
|
const accessToken = this.signAccessToken(record.userId, record.sessionId!);
|
||||||
const refreshToken = await this.createRefreshToken(record.userId, record.sessionId!);
|
const refreshToken = await this.createRefreshToken(record.userId, record.sessionId!);
|
||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken, sessionNonce };
|
||||||
}
|
}
|
||||||
|
|
||||||
async issueTokensForUser(userId: string, context?: RequestContext) {
|
async issueTokensForUser(userId: string, context?: RequestContext) {
|
||||||
const session = await this.createSession(userId, context);
|
const { session, sessionNonce } = await this.createSession(userId, context);
|
||||||
const accessToken = this.signAccessToken(userId, session.id);
|
const accessToken = this.signAccessToken(userId, session.id);
|
||||||
const refreshToken = await this.createRefreshToken(userId, session.id);
|
const refreshToken = await this.createRefreshToken(userId, session.id);
|
||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken, sessionNonce };
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout(rawRefreshToken: string) {
|
async logout(rawRefreshToken: string) {
|
||||||
@ -267,18 +270,21 @@ export class AuthService {
|
|||||||
|
|
||||||
private async createSession(userId: string, context?: RequestContext) {
|
private async createSession(userId: string, context?: RequestContext) {
|
||||||
const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 86400 * 1000);
|
const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 86400 * 1000);
|
||||||
return this.prisma.session.create({
|
const sessionNonce = this.createSessionNonce();
|
||||||
|
const session = await this.prisma.session.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
ipHash: this.hashBindingValue(context?.ipAddress ?? "unknown-ip"),
|
ipHash: this.hashBindingValue(context?.ipAddress ?? "unknown-ip"),
|
||||||
userAgentHash: this.hashBindingValue(context?.userAgent ?? "unknown-user-agent"),
|
userAgentHash: this.hashBindingValue(context?.userAgent ?? "unknown-user-agent"),
|
||||||
|
nonceHash: this.hashToken(sessionNonce),
|
||||||
expiresAt,
|
expiresAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return { session, sessionNonce };
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertSessionMatches(
|
private assertSessionMatches(
|
||||||
session: { revokedAt: Date | null; expiresAt: Date; ipHash: string; userAgentHash: string },
|
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()) {
|
||||||
@ -289,6 +295,13 @@ export class AuthService {
|
|||||||
if (session.ipHash !== ipHash || session.userAgentHash !== userAgentHash) {
|
if (session.ipHash !== ipHash || session.userAgentHash !== userAgentHash) {
|
||||||
throw new UnauthorizedException("Session binding mismatch.");
|
throw new UnauthorizedException("Session binding mismatch.");
|
||||||
}
|
}
|
||||||
|
if (!session.nonceHash || !context?.sessionNonce || session.nonceHash !== this.hashToken(context.sessionNonce)) {
|
||||||
|
throw new UnauthorizedException("Session nonce mismatch.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSessionNonce(): string {
|
||||||
|
return crypto.randomBytes(32).toString("base64url");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createRefreshToken(userId: string, sessionId: string): Promise<string> {
|
private async createRefreshToken(userId: string, sessionId: string): Promise<string> {
|
||||||
|
|||||||
@ -46,21 +46,30 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
throw new UnauthorizedException("Session-bound token required.");
|
throw new UnauthorizedException("Session-bound token required.");
|
||||||
}
|
}
|
||||||
const session = await this.prisma.session.findUnique({ where: { id: payload.sid } });
|
const session = await this.prisma.session.findUnique({ where: { id: payload.sid } });
|
||||||
const context = requestContextFrom(request);
|
const requestContext = requestContextFrom(request);
|
||||||
if (
|
if (
|
||||||
!session ||
|
!session ||
|
||||||
session.userId !== payload.sub ||
|
session.userId !== payload.sub ||
|
||||||
session.revokedAt ||
|
session.revokedAt ||
|
||||||
session.expiresAt < new Date() ||
|
session.expiresAt < new Date() ||
|
||||||
session.ipHash !== this.hashBindingValue(context.ipAddress ?? "unknown-ip") ||
|
session.ipHash !== this.hashBindingValue(requestContext.ipAddress ?? "unknown-ip") ||
|
||||||
session.userAgentHash !== this.hashBindingValue(context.userAgent ?? "unknown-user-agent")
|
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.");
|
throw new UnauthorizedException("Invalid session binding.");
|
||||||
}
|
}
|
||||||
|
const nextNonce = crypto.randomBytes(32).toString("base64url");
|
||||||
|
const response = context.switchToHttp().getResponse<{ setHeader(name: string, value: string): void }>();
|
||||||
await this.prisma.session.update({
|
await this.prisma.session.update({
|
||||||
where: { id: session.id },
|
where: { id: session.id },
|
||||||
data: { lastSeenAt: new Date() },
|
data: {
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
nonceHash: this.hashBindingValue(nextNonce),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
response.setHeader("X-LedgerOne-Next-Nonce", nextNonce);
|
||||||
(request as Request & { user: { sub: string; sid: string } }).user = { sub: payload.sub, sid: payload.sid };
|
(request as Request & { user: { sub: string; sid: string } }).user = { sub: payload.sub, sid: payload.sid };
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -127,12 +127,13 @@ describe("AuthService session-bound tokens", () => {
|
|||||||
userAgent: "jest",
|
userAgent: "jest",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ accessToken: "access_token", refreshToken: expect.any(String) });
|
expect(result).toEqual({ accessToken: "access_token", refreshToken: expect.any(String), sessionNonce: expect.any(String) });
|
||||||
expect((prisma as any).session.create).toHaveBeenCalledWith({
|
expect((prisma as any).session.create).toHaveBeenCalledWith({
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
userId: "user_1",
|
userId: "user_1",
|
||||||
ipHash: expect.any(String),
|
ipHash: expect.any(String),
|
||||||
userAgentHash: expect.any(String),
|
userAgentHash: expect.any(String),
|
||||||
|
nonceHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(jwt.sign).toHaveBeenCalledWith({ sub: "user_1", sid: "session_1" });
|
expect(jwt.sign).toHaveBeenCalledWith({ sub: "user_1", sid: "session_1" });
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user