Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
GET /categories/canonical/taxonomy: kova→alt-yaprak + populasyon sayısı (B2C API kontratı). getCanonicalTree (vehicle + catalog) artık subLeaves geçip her kovayı alt-gruplara böler (materialize + on-the-fly sub fallback). jant-tire slug fix (jant-lastik üst-kova ile çakışıyordu). Kategori sub-kapsama: Filtre %88, Fren/ Aydınlatma %74, Motor/Süsp/Elektrik %60-66. Eşleşmeyen → Genel/SP manuel. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
163 lines
5.2 KiB
TypeScript
163 lines
5.2 KiB
TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
import {
|
|
Body,
|
|
Controller,
|
|
Get,
|
|
HttpCode,
|
|
HttpStatus,
|
|
Logger,
|
|
Param,
|
|
Post,
|
|
Query,
|
|
Req,
|
|
Res,
|
|
UnauthorizedException,
|
|
UseGuards,
|
|
} from "@nestjs/common";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import type { Request, Response } from "express";
|
|
import { Public } from "../common/decorators/public.decorator";
|
|
import { InternalTokenGuard } from "../common/guards/internal-token.guard";
|
|
import { ImpersonationService } from "./impersonation.service";
|
|
|
|
const CONSUME_TOKEN_TTL_SECONDS = 60;
|
|
|
|
type ConsumePayload = {
|
|
cookieName: string;
|
|
cookieValue: string;
|
|
redirect: string;
|
|
expiresAtMs: number;
|
|
notBeforeMs: number;
|
|
};
|
|
|
|
@Controller()
|
|
export class ImpersonationController {
|
|
private readonly logger = new Logger(ImpersonationController.name);
|
|
|
|
constructor(
|
|
private impersonation: ImpersonationService,
|
|
private configService: ConfigService,
|
|
) {}
|
|
|
|
// Internal: panel → spoke. Returns a short-lived "consume URL" the panel
|
|
// opens in a new tab. Hand-off via a signed payload avoids cross-site
|
|
// Set-Cookie restrictions (panel domain ≠ sase.tr domain).
|
|
@Post("internal/admin/users/:id/impersonate-readonly")
|
|
@Public()
|
|
@UseGuards(InternalTokenGuard)
|
|
@HttpCode(HttpStatus.OK)
|
|
async impersonateReadonly(
|
|
@Param("id") userId: string,
|
|
@Body() body: { ttlMinutes?: number; reason?: string; founderId?: string },
|
|
@Req() req: Request,
|
|
) {
|
|
if (!body.founderId) {
|
|
throw new UnauthorizedException("founderId required");
|
|
}
|
|
const reason = (body.reason ?? "").slice(0, 500);
|
|
if (!reason) {
|
|
throw new UnauthorizedException("reason required");
|
|
}
|
|
|
|
const { cookieName, cookieValue, expiresAt, sessionId } =
|
|
await this.impersonation.createReadonlySession({
|
|
targetUserId: userId,
|
|
founderId: body.founderId,
|
|
ttlMinutes: body.ttlMinutes ?? 15,
|
|
reason,
|
|
ipAddress: this.getIp(req),
|
|
userAgent:
|
|
typeof req.headers["user-agent"] === "string" ? req.headers["user-agent"] : undefined,
|
|
});
|
|
|
|
const baseUrl = this.configService.get<string>("auth.url") ?? "";
|
|
const payload: ConsumePayload = {
|
|
cookieName,
|
|
cookieValue,
|
|
redirect: "/",
|
|
expiresAtMs: expiresAt.getTime(),
|
|
notBeforeMs: Date.now() - 1000,
|
|
};
|
|
const consumeToken = this.signConsumePayload(payload);
|
|
const redirectUrl = `${baseUrl}/api/admin/impersonate/consume?t=${encodeURIComponent(consumeToken)}`;
|
|
|
|
return {
|
|
success: true,
|
|
redirectUrl,
|
|
expiresAt: expiresAt.toISOString(),
|
|
sessionIdPrefix: sessionId.slice(0, 8),
|
|
};
|
|
}
|
|
|
|
// Public: founder's browser GET. Verifies the signed payload, sets the
|
|
// Better Auth session cookie, redirects to the spoke. One-shot, time-boxed.
|
|
@Get("admin/impersonate/consume")
|
|
@Public()
|
|
async consume(@Query("t") token: string, @Res() res: Response) {
|
|
if (!token) {
|
|
res.status(HttpStatus.BAD_REQUEST).send("missing token");
|
|
return;
|
|
}
|
|
const payload = this.verifyConsumePayload(token);
|
|
if (!payload) {
|
|
res.status(HttpStatus.UNAUTHORIZED).send("invalid or expired consume token");
|
|
return;
|
|
}
|
|
const now = Date.now();
|
|
if (now < payload.notBeforeMs || now > payload.expiresAtMs) {
|
|
res.status(HttpStatus.UNAUTHORIZED).send("consume token outside validity window");
|
|
return;
|
|
}
|
|
|
|
const isHttps = (this.configService.get<string>("auth.url") ?? "").startsWith("https://");
|
|
res.cookie(payload.cookieName, payload.cookieValue, {
|
|
httpOnly: true,
|
|
secure: isHttps,
|
|
sameSite: "lax",
|
|
// Path scope: site-wide
|
|
path: "/",
|
|
// maxAge in ms, browser will expire alongside server-side session row
|
|
maxAge: payload.expiresAtMs - now,
|
|
});
|
|
res.redirect(payload.redirect || "/");
|
|
}
|
|
|
|
// Sign a JSON payload with HMAC-SHA256(secret) and embed: base64(json).base64(sig)
|
|
private signConsumePayload(payload: ConsumePayload): string {
|
|
const secret = this.consumeSecret();
|
|
const json = JSON.stringify(payload);
|
|
const body = Buffer.from(json, "utf8").toString("base64url");
|
|
const sig = createHmac("sha256", secret).update(body).digest("base64url");
|
|
return `${body}.${sig}`;
|
|
}
|
|
|
|
private verifyConsumePayload(token: string): ConsumePayload | null {
|
|
const [body, sig] = token.split(".");
|
|
if (!body || !sig) return null;
|
|
const secret = this.consumeSecret();
|
|
const expected = createHmac("sha256", secret).update(body).digest("base64url");
|
|
const a = Buffer.from(sig);
|
|
const b = Buffer.from(expected);
|
|
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
|
try {
|
|
return JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private consumeSecret(): string {
|
|
const secret =
|
|
process.env.INTERNAL_IMPERSONATION_SECRET ?? this.configService.get<string>("auth.secret");
|
|
if (!secret) throw new Error("INTERNAL_IMPERSONATION_SECRET or auth.secret required");
|
|
return secret;
|
|
}
|
|
|
|
private getIp(req: Request): string | undefined {
|
|
const xff = req.headers["x-forwarded-for"];
|
|
if (typeof xff === "string") return xff.split(",")[0]?.trim();
|
|
if (req.socket?.remoteAddress) return req.socket.remoteAddress;
|
|
return undefined;
|
|
}
|
|
}
|