Files
sase.tr/apps/api/src/internal-admin/impersonation.controller.ts
Semih Yesilyurt 2d5e2d04a9
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
feat(canonical): 2-level serving — taksonomi endpoint + araç sub-gruplama
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>
2026-07-05 16:40:39 +03:00

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;
}
}