Files
liqa/src/auth/auth.service.ts
T
ars9 6fd991811f feat(logging): add trace ID propagation via AsyncLocalStorage
- add TraceInterceptor that reads x-trace-id header or generates a UUID
- add AsyncLocalStorage store in common/trace-context for request-scoped trace ID
- replace NestJS Logger with TraceLogger (extends ConsoleLogger) that injects trace ID into log context
- register TraceInterceptor globally before LoggingInterceptor
- move HealthController into HealthModule so global interceptors apply to /healthz
- omit body/response from log lines when empty or null
2026-04-08 12:20:06 +03:00

162 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Injectable,
BadRequestException,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { TraceLogger } from '../common/trace-logger';
import { ConfigService } from '@nestjs/config';
import { chromium } from 'playwright';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { SessionService } from '../session/session.service';
import { EnvironmentService } from '../environment/environment.service';
interface KeyDescriptor {
keyFile?: string;
login?: string;
password: string;
}
@Injectable()
export class AuthService {
private readonly logger = new TraceLogger(AuthService.name);
private readonly keysDir: string;
constructor(
private readonly config: ConfigService,
private readonly sessionService: SessionService,
private readonly environmentService: EnvironmentService,
) {
this.keysDir = path.resolve(
this.config.get<string>('KEYS_DIR', 'keys'),
);
}
listKeys(): string[] {
if (!fs.existsSync(this.keysDir)) return [];
return fs.readdirSync(this.keysDir)
.filter(f => f.endsWith('.json'))
.map(f => path.basename(f, '.json'));
}
async login(keyId: string, environmentName: string, sessionName?: string): Promise<{ token: string; sessionName: string }> {
const resolvedSession = sessionName ?? crypto.randomUUID();
const env = await this.environmentService.findAll().then(({ data }) => data.find(e => e.name === environmentName));
if (!env) throw new NotFoundException(`Environment "${environmentName}" not found`);
const loginUrl = env.urls.id_url;
const cabinetUrl = env.urls.cabinet_url;
if (!loginUrl) throw new BadRequestException(`Environment "${environmentName}" is missing id_url`);
if (!cabinetUrl) throw new BadRequestException(`Environment "${environmentName}" is missing cabinet_url`);
const keyJsonPath = path.join(this.keysDir, `${keyId}.json`);
if (!fs.existsSync(keyJsonPath)) {
throw new BadRequestException(`Key not found: ${keyId}`);
}
let descriptor: KeyDescriptor;
try {
descriptor = JSON.parse(fs.readFileSync(keyJsonPath, 'utf-8'));
} catch (err) {
throw new BadRequestException(`Cannot read key descriptor: ${keyId}`, { cause: err });
}
const useLoginPassword = !!descriptor.login;
if (!useLoginPassword) {
if (!descriptor.keyFile) {
throw new BadRequestException(`Key descriptor for "${keyId}" must have either "login" or "keyFile"`);
}
const keyFilePath = path.resolve(this.keysDir, descriptor.keyFile);
if (!fs.existsSync(keyFilePath)) {
throw new BadRequestException(`Key file not found: ${descriptor.keyFile}`);
}
}
const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH });
try {
const context = await browser.newContext();
const page = await context.newPage();
this.logger.log(`Navigating to ${loginUrl}`);
await page.goto(loginUrl, { waitUntil: 'networkidle' });
if (useLoginPassword) {
// Click "Логін і пароль" auth method
await page.locator('p[aria-label="Логін і пароль"]').click();
// Fill login and password
await page.getByLabel('Електронна пошта').fill(descriptor.login!);
await page.getByLabel('Пароль').fill(descriptor.password);
// Click "Увійти"
await page.locator('button:has-text("Увійти")').click();
} else {
const keyFilePath = path.resolve(this.keysDir, descriptor.keyFile!);
// Click "Файловий ключ" button
await page.getByText('Файловий ключ').click();
// Upload key file via hidden file input
const fileInput = page.locator('input[accept=".dat,.pfx,.pk8,.zs2,.jks"][type="file"]');
await fileInput.setInputFiles(keyFilePath);
// Enter password
await page
.locator('#id-app-login-file-key-password')
.fill(descriptor.password);
// Click "Продовжити"
await page.locator('#id-app-login-file-key-sign-button').click();
}
// Wait until redirected to cabinet
this.logger.log(`Waiting for redirect to ${cabinetUrl}`);
await page.waitForURL(cabinetUrl, { timeout: 30000 });
// Extract token from localStorage
const token = await page.evaluate(() => localStorage.getItem('token'));
if (!token) {
throw new InternalServerErrorException(
'Login succeeded but token was not found in localStorage',
);
}
// Capture all cookies and localStorage from the browser context
const cookies = await context.cookies();
const localStorageData = await page.evaluate(() => {
const entries: Record<string, string> = {};
for (let i = 0; i < window.localStorage.length; i++) {
const k = window.localStorage.key(i);
if (k !== null) entries[k] = window.localStorage.getItem(k) ?? '';
}
return entries;
});
await this.sessionService.upsert(resolvedSession, token, cookies, localStorageData);
this.logger.log(`Login successful for key ${keyId}, session: ${resolvedSession}`);
return { token, sessionName: resolvedSession };
} catch (err) {
if (
err instanceof BadRequestException ||
err instanceof InternalServerErrorException
) {
throw err;
}
this.logger.error(`Login failed: ${(err as Error).message}`);
throw new InternalServerErrorException(
`Login automation failed: ${(err as Error).message}`,
{ cause: err },
);
} finally {
await browser.close();
}
}
}