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>
87 lines
3.2 KiB
TypeScript
87 lines
3.2 KiB
TypeScript
import { createHmac, randomBytes } from "node:crypto";
|
||
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||
import { ConfigService } from "@nestjs/config";
|
||
import { eq } from "drizzle-orm";
|
||
import { DATABASE, type Database } from "../database/database.provider";
|
||
import { sessions, users } from "../database/schema/core";
|
||
|
||
const SESSION_COOKIE_NAME_INSECURE = "better-auth.session_token";
|
||
const SESSION_COOKIE_NAME_SECURE = "__Secure-better-auth.session_token";
|
||
const DEFAULT_TTL_MINUTES = 15;
|
||
const MAX_TTL_MINUTES = 60;
|
||
|
||
@Injectable()
|
||
export class ImpersonationService {
|
||
private readonly logger = new Logger(ImpersonationService.name);
|
||
|
||
constructor(
|
||
@Inject(DATABASE) private db: Database,
|
||
private configService: ConfigService,
|
||
) {}
|
||
|
||
async createReadonlySession(params: {
|
||
targetUserId: string;
|
||
founderId: string;
|
||
ttlMinutes: number;
|
||
reason: string;
|
||
ipAddress?: string;
|
||
userAgent?: string;
|
||
}): Promise<{
|
||
cookieName: string;
|
||
cookieValue: string;
|
||
expiresAt: Date;
|
||
sessionId: string;
|
||
}> {
|
||
const ttl = Math.min(Math.max(params.ttlMinutes || DEFAULT_TTL_MINUTES, 1), MAX_TTL_MINUTES);
|
||
|
||
// Verify target exists (and isn't an admin — defense in depth)
|
||
const [target] = await this.db
|
||
.select({ id: users.id, role: users.role })
|
||
.from(users)
|
||
.where(eq(users.id, params.targetUserId))
|
||
.limit(1);
|
||
if (!target) throw new NotFoundException("Kullanıcı bulunamadı");
|
||
if (target.role === "admin") {
|
||
throw new NotFoundException("Admin kullanıcılar impersonate edilemez");
|
||
}
|
||
|
||
const secret = this.configService.get<string>("auth.secret");
|
||
if (!secret) throw new Error("BETTER_AUTH_SECRET not configured");
|
||
|
||
// Better Auth session shape: id is a text random id, token is text random.
|
||
// Both must be unique. Use 32 random bytes hex for plenty of entropy.
|
||
const sessionId = randomBytes(32).toString("hex");
|
||
const token = randomBytes(32).toString("hex");
|
||
const now = new Date();
|
||
const expiresAt = new Date(now.getTime() + ttl * 60_000);
|
||
|
||
await this.db.insert(sessions).values({
|
||
id: sessionId,
|
||
userId: params.targetUserId,
|
||
token,
|
||
expiresAt,
|
||
ipAddress: params.ipAddress ?? null,
|
||
userAgent: params.userAgent
|
||
? `${params.userAgent} (impersonated:readonly)`
|
||
: "impersonated:readonly",
|
||
impersonatedBy: params.founderId,
|
||
impersonationReadonly: true,
|
||
});
|
||
|
||
// Cookie format matches better-call's signCookieValue:
|
||
// urlEncode(`${token}.${standardBase64(HMAC_SHA256(token, secret))}`)
|
||
const signature = createHmac("sha256", secret).update(token).digest("base64");
|
||
const signedValue = `${token}.${signature}`;
|
||
const cookieValue = encodeURIComponent(signedValue);
|
||
|
||
const isHttps = (this.configService.get<string>("auth.url") ?? "").startsWith("https://");
|
||
const cookieName = isHttps ? SESSION_COOKIE_NAME_SECURE : SESSION_COOKIE_NAME_INSECURE;
|
||
|
||
this.logger.log(
|
||
`Impersonation session created: founder=${params.founderId} target=${params.targetUserId} ttl=${ttl}m reason="${params.reason.slice(0, 80)}" sessionId=${sessionId.slice(0, 8)}…`,
|
||
);
|
||
|
||
return { cookieName, cookieValue, expiresAt, sessionId };
|
||
}
|
||
}
|