fix and update
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user