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
|
||||
ipHash String
|
||||
userAgentHash String
|
||||
nonceHash String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
lastSeenAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
export type RequestContext = {
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
sessionNonce?: string;
|
||||
};
|
||||
|
||||
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 {
|
||||
ipAddress: typeof forwardedFor === "string" ? forwardedFor.split(",")[0].trim() : req.ip,
|
||||
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);
|
||||
|
||||
const { accessToken, refreshToken } = await this.issueTokensForUser(user.id, context);
|
||||
const { accessToken, refreshToken, sessionNonce } = await this.issueTokensForUser(user.id, context);
|
||||
return {
|
||||
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
|
||||
accessToken,
|
||||
refreshToken,
|
||||
sessionNonce,
|
||||
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 } } });
|
||||
const { accessToken, refreshToken } = await this.issueTokensForUser(user.id, context);
|
||||
const { accessToken, refreshToken, sessionNonce } = await this.issueTokensForUser(user.id, context);
|
||||
return {
|
||||
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
|
||||
accessToken,
|
||||
refreshToken,
|
||||
sessionNonce,
|
||||
};
|
||||
}
|
||||
|
||||
@ -110,21 +112,22 @@ export class AuthService {
|
||||
throw new UnauthorizedException("Invalid or expired refresh token.");
|
||||
}
|
||||
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({
|
||||
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 refreshToken = await this.createRefreshToken(record.userId, record.sessionId!);
|
||||
return { accessToken, refreshToken };
|
||||
return { accessToken, refreshToken, sessionNonce };
|
||||
}
|
||||
|
||||
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 refreshToken = await this.createRefreshToken(userId, session.id);
|
||||
return { accessToken, refreshToken };
|
||||
return { accessToken, refreshToken, sessionNonce };
|
||||
}
|
||||
|
||||
async logout(rawRefreshToken: string) {
|
||||
@ -267,18 +270,21 @@ export class AuthService {
|
||||
|
||||
private async createSession(userId: string, context?: RequestContext) {
|
||||
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: {
|
||||
userId,
|
||||
ipHash: this.hashBindingValue(context?.ipAddress ?? "unknown-ip"),
|
||||
userAgentHash: this.hashBindingValue(context?.userAgent ?? "unknown-user-agent"),
|
||||
nonceHash: this.hashToken(sessionNonce),
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
return { session, sessionNonce };
|
||||
}
|
||||
|
||||
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,
|
||||
) {
|
||||
if (session.revokedAt || session.expiresAt < new Date()) {
|
||||
@ -289,6 +295,13 @@ export class AuthService {
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
private createSessionNonce(): string {
|
||||
return crypto.randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
const session = await this.prisma.session.findUnique({ where: { id: payload.sid } });
|
||||
const context = requestContextFrom(request);
|
||||
const requestContext = requestContextFrom(request);
|
||||
if (
|
||||
!session ||
|
||||
session.userId !== payload.sub ||
|
||||
session.revokedAt ||
|
||||
session.expiresAt < new Date() ||
|
||||
session.ipHash !== this.hashBindingValue(context.ipAddress ?? "unknown-ip") ||
|
||||
session.userAgentHash !== this.hashBindingValue(context.userAgent ?? "unknown-user-agent")
|
||||
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.");
|
||||
}
|
||||
const nextNonce = crypto.randomBytes(32).toString("base64url");
|
||||
const response = context.switchToHttp().getResponse<{ setHeader(name: string, value: string): void }>();
|
||||
await this.prisma.session.update({
|
||||
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 };
|
||||
return true;
|
||||
} catch {
|
||||
|
||||
@ -127,12 +127,13 @@ describe("AuthService session-bound tokens", () => {
|
||||
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({
|
||||
data: expect.objectContaining({
|
||||
userId: "user_1",
|
||||
ipHash: expect.any(String),
|
||||
userAgentHash: expect.any(String),
|
||||
nonceHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
}),
|
||||
});
|
||||
expect(jwt.sign).toHaveBeenCalledWith({ sub: "user_1", sid: "session_1" });
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user