fix and update

This commit is contained in:
lendry
2026-07-07 09:01:49 +03:00
parent 881e5d764b
commit 29306eb2ec
14 changed files with 551 additions and 60 deletions

View File

@@ -1,14 +1,20 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomBytes, randomUUID } from 'node:crypto';
import { RedisService } from '../infra/redis.service';
import { AuthService } from './auth.service';
const QR_TTL_SECONDS = 120;
const QR_LOGIN_TTL_SECONDS = 120;
const QR_DEVICE_LINK_TTL_SECONDS = 300;
type QrSessionMode = 'login' | 'device_link';
interface StoredQrSession {
status: 'PENDING' | 'APPROVED' | 'EXPIRED';
mode?: QrSessionMode;
initiatorUserId?: string;
claimed?: boolean;
deviceName: string;
fingerprint: string;
deviceType?: string;
@@ -53,7 +59,7 @@ export class AdvancedAuthService {
async createQrSession(input: CreateQrSessionInput) {
const sessionId = randomUUID();
const expiresAt = new Date(Date.now() + QR_TTL_SECONDS * 1000);
const expiresAt = new Date(Date.now() + QR_LOGIN_TTL_SECONDS * 1000);
const apiBase = this.config.get<string>('PUBLIC_API_URL') ?? 'http://localhost:3001';
const qrPayload = JSON.stringify({
v: 1,
@@ -65,6 +71,7 @@ export class AdvancedAuthService {
const data: StoredQrSession = {
status: 'PENDING',
mode: 'login',
deviceName: input.deviceName,
fingerprint: input.fingerprint,
deviceType: input.deviceType,
@@ -73,45 +80,85 @@ export class AdvancedAuthService {
expiresAt: expiresAt.toISOString()
};
await this.redis.client.set(`qr:session:${sessionId}`, JSON.stringify(data), 'EX', QR_TTL_SECONDS);
await this.redis.client.set(`qr:session:${sessionId}`, JSON.stringify(data), 'EX', QR_LOGIN_TTL_SECONDS);
return {
return this.buildQrSessionResponse(sessionId, data, qrPayload);
}
async createDeviceLinkSession(userId: string, frontendUrl: string) {
const sessionId = randomUUID();
const expiresAt = new Date(Date.now() + QR_DEVICE_LINK_TTL_SECONDS * 1000);
const frontendBase = frontendUrl.replace(/\/$/, '');
const claimUrl = `${frontendBase}/auth/login?qrLink=${encodeURIComponent(sessionId)}`;
const qrPayload = JSON.stringify({
v: 1,
type: 'idp_qr_device_link',
sessionId,
claimUrl,
expiresAt: expiresAt.toISOString()
});
const data: StoredQrSession = {
status: 'PENDING',
expiresAt: expiresAt.toISOString(),
qrPayload
mode: 'device_link',
initiatorUserId: userId,
claimed: false,
deviceName: '',
fingerprint: '',
expiresAt: expiresAt.toISOString()
};
await this.redis.client.set(`qr:session:${sessionId}`, JSON.stringify(data), 'EX', QR_DEVICE_LINK_TTL_SECONDS);
return this.buildQrSessionResponse(sessionId, data, qrPayload);
}
async claimQrSession(sessionId: string, input: CreateQrSessionInput) {
const key = `qr:session:${sessionId}`;
const raw = await this.redis.client.get(key);
if (!raw) {
throw new NotFoundException('QR-сессия не найдена или истекла');
}
const data = JSON.parse(raw) as StoredQrSession;
if (data.mode !== 'device_link') {
throw new BadRequestException('Эта QR-сессия не предназначена для подключения устройства');
}
if (data.status !== 'PENDING') {
throw new BadRequestException('QR-сессия уже обработана');
}
if (new Date(data.expiresAt).getTime() <= Date.now()) {
throw new BadRequestException('QR-сессия истекла');
}
if (!data.claimed) {
data.deviceName = input.deviceName;
data.fingerprint = input.fingerprint;
data.deviceType = input.deviceType;
data.ipAddress = input.ipAddress;
data.userAgent = input.userAgent;
data.claimed = true;
const ttl = await this.redis.client.ttl(key);
await this.redis.client.set(key, JSON.stringify(data), 'EX', Math.max(ttl, 30));
} else if (data.fingerprint !== input.fingerprint) {
throw new BadRequestException('QR-сессия уже привязана к другому устройству');
}
return this.buildQrSessionResponse(sessionId, data);
}
async pollQrSession(sessionId: string) {
const raw = await this.redis.client.get(`qr:session:${sessionId}`);
if (!raw) {
return { sessionId, status: 'EXPIRED', expiresAt: new Date().toISOString() };
return { sessionId, status: 'EXPIRED', expiresAt: new Date().toISOString(), claimed: false };
}
const data = JSON.parse(raw) as StoredQrSession;
if (data.status === 'PENDING' && new Date(data.expiresAt).getTime() <= Date.now()) {
return { sessionId, status: 'EXPIRED', expiresAt: data.expiresAt };
return { sessionId, status: 'EXPIRED', expiresAt: data.expiresAt, claimed: Boolean(data.claimed) };
}
return {
sessionId,
status: data.status,
expiresAt: data.expiresAt,
qrPayload: undefined,
auth: data.auth
? {
accessToken: data.auth.accessToken,
refreshToken: data.auth.refreshToken,
sessionId: data.auth.sessionId,
pinVerified: data.auth.pinVerified,
expiresAt: data.auth.expiresAt,
requiresTotp: data.auth.requiresTotp,
totpChallengeToken: data.auth.totpChallengeToken,
user: data.auth.user
}
: undefined
};
return this.buildQrSessionResponse(sessionId, data);
}
async approveQrSession(sessionId: string, userId: string) {
@@ -128,6 +175,14 @@ export class AdvancedAuthService {
if (new Date(data.expiresAt).getTime() <= Date.now()) {
throw new BadRequestException('QR-сессия истекла');
}
if (data.mode === 'device_link') {
if (data.initiatorUserId !== userId) {
throw new ForbiddenException('Подтвердить подключение может только владелец сессии');
}
if (!data.claimed || !data.fingerprint) {
throw new BadRequestException('Новое устройство ещё не отсканировало QR-код');
}
}
const auth = await this.auth.createQrApprovedLogin(userId, {
fingerprint: data.fingerprint,
@@ -142,20 +197,29 @@ export class AdvancedAuthService {
const ttl = await this.redis.client.ttl(key);
await this.redis.client.set(key, JSON.stringify(data), 'EX', Math.max(ttl, 30));
return this.buildQrSessionResponse(sessionId, data);
}
private buildQrSessionResponse(sessionId: string, data: StoredQrSession, qrPayload?: string) {
return {
sessionId,
status: 'APPROVED',
status: data.status,
expiresAt: data.expiresAt,
auth: {
accessToken: auth.accessToken,
refreshToken: auth.refreshToken,
sessionId: auth.sessionId,
pinVerified: auth.pinVerified,
expiresAt: auth.expiresAt,
requiresTotp: auth.requiresTotp,
totpChallengeToken: auth.totpChallengeToken,
user: auth.user
}
qrPayload,
claimed: Boolean(data.claimed && data.fingerprint),
claimedDeviceName: data.claimed && data.deviceName ? data.deviceName : undefined,
auth: data.auth
? {
accessToken: data.auth.accessToken,
refreshToken: data.auth.refreshToken,
sessionId: data.auth.sessionId,
pinVerified: data.auth.pinVerified,
expiresAt: data.auth.expiresAt,
requiresTotp: data.auth.requiresTotp,
totpChallengeToken: data.auth.totpChallengeToken,
user: data.auth.user
}
: undefined
};
}

View File

@@ -948,6 +948,29 @@ export class AuthGrpcController {
});
}
@GrpcMethod('AdvancedAuthService', 'CreateDeviceLinkSession')
createDeviceLinkSession(command: { userId: string; frontendUrl: string }) {
return this.advancedAuth.createDeviceLinkSession(command.userId, command.frontendUrl);
}
@GrpcMethod('AdvancedAuthService', 'ClaimQrSession')
claimQrSession(command: {
sessionId: string;
deviceName: string;
fingerprint?: string;
deviceType?: string;
ipAddress?: string;
userAgent?: string;
}) {
return this.advancedAuth.claimQrSession(command.sessionId, {
deviceName: command.deviceName,
fingerprint: command.fingerprint ?? command.deviceName,
deviceType: command.deviceType,
ipAddress: command.ipAddress,
userAgent: command.userAgent
});
}
@GrpcMethod('AdvancedAuthService', 'PollQrSession')
pollQrSession(command: { sessionId: string }) {
return this.advancedAuth.pollQrSession(command.sessionId);