93 lines
3.2 KiB
TypeScript
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");
|
|
}
|
|
}
|