ledgerone_backend/src/common/guards/jwt-auth.guard.ts

93 lines
3.2 KiB
TypeScript

import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { JwtService } from "@nestjs/jwt";
import { Request } from "express";
import * as crypto from "crypto";
import { AbuseService } from "../../abuse/abuse.service";
import { requestContextFrom } from "../../abuse/abuse.types";
import { PrismaService } from "../../prisma/prisma.service";
export const IS_PUBLIC_KEY = "isPublic";
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly reflector: Reflector,
private readonly abuseService: AbuseService,
private readonly prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException("Missing bearer token.");
}
try {
const payload = this.jwtService.verify<{ sub: string; sid?: string }>(token, {
secret: process.env.JWT_SECRET,
});
if (!payload.sid) {
throw new UnauthorizedException("Session-bound token required.");
}
const session = await this.prisma.session.findUnique({ where: { id: payload.sid } });
const requestContext = requestContextFrom(request);
if (
!session ||
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.");
}
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(),
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 {
await this.abuseService.recordInvalidToken(requestContextFrom(request));
throw new UnauthorizedException("Invalid or expired token.");
}
}
private extractToken(request: Request): string | null {
const auth = request.headers.authorization;
if (auth?.startsWith("Bearer ")) {
return auth.slice(7);
}
return null;
}
private hashBindingValue(value: string): string {
return crypto.createHash("sha256").update(value).digest("hex");
}
}