fix and update
This commit is contained in:
@@ -1,11 +1,13 @@
|
|||||||
import { Body, Controller, Get, Headers, Param, Post, Req } from '@nestjs/common';
|
import { Body, Controller, ForbiddenException, Get, Headers, Param, Post, Req } from '@nestjs/common';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { enrichAuthClientMeta } from '../client-request.util';
|
import { enrichAuthClientMeta } from '../client-request.util';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { QrSessionDto, WebAuthnDto } from '../dto/identity.dto';
|
import { QrSessionDto, WebAuthnDto } from '../dto/identity.dto';
|
||||||
import { resolveAuthorizedPayload } from '../session-auth';
|
import { resolveAuthorizedPayload } from '../session-auth';
|
||||||
|
import { resolveFrontendUrl } from '../lib/oauth-issuer';
|
||||||
|
|
||||||
@ApiTags('Биометрия и QR-вход')
|
@ApiTags('Биометрия и QR-вход')
|
||||||
@Controller('auth/advanced')
|
@Controller('auth/advanced')
|
||||||
@@ -53,6 +55,30 @@ export class AdvancedAuthController {
|
|||||||
return this.core.advancedAuth.PollQrSession({ sessionId });
|
return this.core.advancedAuth.PollQrSession({ sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('qr/session/:sessionId/claim')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Привязать QR-сессию к устройству',
|
||||||
|
description: 'Новое устройство подтверждает сканирование QR-кода для подключения из раздела «Безопасность».'
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'sessionId', description: 'ID QR-сессии' })
|
||||||
|
@ApiBody({ type: QrSessionDto })
|
||||||
|
@ApiResponse({ status: 201, description: 'QR-сессия привязана к устройству' })
|
||||||
|
claimQr(@Param('sessionId') sessionId: string, @Body() dto: QrSessionDto, @Req() req: Request) {
|
||||||
|
const enriched = enrichAuthClientMeta(req, {
|
||||||
|
deviceName: dto.deviceName,
|
||||||
|
fingerprint: dto.fingerprint,
|
||||||
|
deviceType: dto.deviceType ?? 'WEB'
|
||||||
|
});
|
||||||
|
return this.core.advancedAuth.ClaimQrSession({
|
||||||
|
sessionId,
|
||||||
|
deviceName: enriched.deviceName,
|
||||||
|
fingerprint: enriched.fingerprint,
|
||||||
|
deviceType: enriched.deviceType,
|
||||||
|
ipAddress: enriched.ipAddress,
|
||||||
|
userAgent: enriched.userAgent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Post('qr/session/:sessionId/approve')
|
@Post('qr/session/:sessionId/approve')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Подтвердить QR-вход', description: 'Подтверждает QR-сессию с мобильного приложения уже авторизованным пользователем.' })
|
@ApiOperation({ summary: 'Подтвердить QR-вход', description: 'Подтверждает QR-сессию с мобильного приложения уже авторизованным пользователем.' })
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
resolveFedcmEndpoints
|
resolveFedcmEndpoints
|
||||||
} from '../lib/fedcm-config';
|
} from '../lib/fedcm-config';
|
||||||
import { resolveFedcmSessionFromRequest, setFedcmSessionCookie } from '../lib/fedcm-cookie';
|
import { resolveFedcmSessionFromRequest, setFedcmSessionCookie } from '../lib/fedcm-cookie';
|
||||||
|
import { resolveFedcmSessionPinState } from '../lib/fedcm-session';
|
||||||
import { verifyAccessToken } from '../session-auth';
|
import { verifyAccessToken } from '../session-auth';
|
||||||
|
|
||||||
type FedcmAccountsResponse = {
|
type FedcmAccountsResponse = {
|
||||||
@@ -128,12 +129,17 @@ export class FedcmController {
|
|||||||
applyFedcmCorsHeaders(res, origin);
|
applyFedcmCorsHeaders(res, origin);
|
||||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
|
||||||
const session = await resolveFedcmSessionFromRequest(this.jwt, req.headers.cookie);
|
const { session, requiresPin } = await resolveFedcmSessionPinState(this.jwt, this.core, req.headers.cookie);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
applyFedcmLoginStatus(res, false);
|
applyFedcmLoginStatus(res, false);
|
||||||
return { accounts: [] };
|
return { accounts: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requiresPin) {
|
||||||
|
applyFedcmLoginStatus(res, false);
|
||||||
|
return { accounts: [] };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = (await firstValueFrom(
|
const result = (await firstValueFrom(
|
||||||
this.core.fedcm.GetAccounts({ userId: session.sub, sessionId: session.sessionId })
|
this.core.fedcm.GetAccounts({ userId: session.sub, sessionId: session.sessionId })
|
||||||
@@ -180,11 +186,16 @@ export class FedcmController {
|
|||||||
applyFedcmCorsHeaders(res, rpOrigin);
|
applyFedcmCorsHeaders(res, rpOrigin);
|
||||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
|
||||||
const session = await resolveFedcmSessionFromRequest(this.jwt, req.headers.cookie);
|
const { session, requiresPin } = await resolveFedcmSessionPinState(this.jwt, this.core, req.headers.cookie);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new UnauthorizedException('Сессия FedCM не найдена');
|
throw new UnauthorizedException('Сессия FedCM не найдена');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requiresPin) {
|
||||||
|
applyFedcmLoginStatus(res, false);
|
||||||
|
throw new UnauthorizedException('Требуется подтверждение PIN-кода');
|
||||||
|
}
|
||||||
|
|
||||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||||
const clientId = String(body.client_id ?? body.clientId ?? '').trim();
|
const clientId = String(body.client_id ?? body.clientId ?? '').trim();
|
||||||
const accountId = String(body.account_id ?? body.accountId ?? '').trim();
|
const accountId = String(body.account_id ?? body.accountId ?? '').trim();
|
||||||
@@ -298,8 +309,8 @@ export class FedcmController {
|
|||||||
'Минимальная HTML-страница на API-домене: Set-Login + navigator.login.setStatus для Chrome FedCM (origin login_url).'
|
'Минимальная HTML-страница на API-домене: Set-Login + navigator.login.setStatus для Chrome FedCM (origin login_url).'
|
||||||
})
|
})
|
||||||
async loginStatus(@Req() req: Request, @Res() res: Response) {
|
async loginStatus(@Req() req: Request, @Res() res: Response) {
|
||||||
const session = await resolveFedcmSessionFromRequest(this.jwt, req.headers.cookie);
|
const { session, requiresPin } = await resolveFedcmSessionPinState(this.jwt, this.core, req.headers.cookie);
|
||||||
const loggedIn = Boolean(session);
|
const loggedIn = Boolean(session && !requiresPin);
|
||||||
applyFedcmLoginStatus(res, loggedIn);
|
applyFedcmLoginStatus(res, loggedIn);
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Body, Controller, Get, Headers, Param, Post } from '@nestjs/common';
|
import { Body, Controller, ForbiddenException, Get, Headers, Param, Post } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { OptionalPinDto, PinDto, TotpCodeDto, VerifySecurityPinDto } from '../dto/security.dto';
|
import { OptionalPinDto, PinDto, TotpCodeDto, VerifySecurityPinDto } from '../dto/security.dto';
|
||||||
import { resolveAuthorizedPayload } from '../session-auth';
|
import { resolveAuthorizedPayload } from '../session-auth';
|
||||||
|
import { resolveFrontendUrl } from '../lib/oauth-issuer';
|
||||||
|
|
||||||
@ApiTags('Безопасность')
|
@ApiTags('Безопасность')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -148,4 +150,20 @@ export class SecurityController {
|
|||||||
const payload = await resolveAuthorizedPayload(this.jwt, this.core, authorization);
|
const payload = await resolveAuthorizedPayload(this.jwt, this.core, authorization);
|
||||||
return this.core.security.RevokeAllSessions({ userId, exceptSessionId: payload.sessionId });
|
return this.core.security.RevokeAllSessions({ userId, exceptSessionId: payload.sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('users/:userId/device-link/session')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Создать QR для подключения устройства',
|
||||||
|
description: 'Генерирует QR-код (5 минут) для входа на новом устройстве через раздел «Безопасность».'
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
|
@ApiResponse({ status: 201, description: 'QR-сессия для подключения устройства создана' })
|
||||||
|
async createDeviceLinkSession(@Param('userId') userId: string, @Headers('authorization') authorization?: string) {
|
||||||
|
const payload = await resolveAuthorizedPayload(this.jwt, this.core, authorization);
|
||||||
|
if (payload.sub !== userId) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав для подключения устройства');
|
||||||
|
}
|
||||||
|
const frontendUrl = await resolveFrontendUrl(this.core);
|
||||||
|
return firstValueFrom(this.core.advancedAuth.CreateDeviceLinkSession({ userId, frontendUrl }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function buildFedcmEndpointUrls(issuer: string, frontendUrl: string) {
|
|||||||
idAssertionEndpoint: `${base}/fedcm/id_assertion`,
|
idAssertionEndpoint: `${base}/fedcm/id_assertion`,
|
||||||
// FedCM: login_url MUST be same-origin with config.json (W3C FedCM / Chrome).
|
// FedCM: login_url MUST be same-origin with config.json (W3C FedCM / Chrome).
|
||||||
// На split-domain UI проксируется через nginx: api.idpmvk.lpr/auth/login → frontend.
|
// На split-domain UI проксируется через nginx: api.idpmvk.lpr/auth/login → frontend.
|
||||||
loginUrl: `${base}/auth/login`,
|
loginUrl: `${base}/auth/login?fedcm=1`,
|
||||||
signupUrl: `${front}/auth/register`,
|
signupUrl: `${front}/auth/register`,
|
||||||
webIdentityUrl: `${webIdentityOrigin}/.well-known/web-identity`
|
webIdentityUrl: `${webIdentityOrigin}/.well-known/web-identity`
|
||||||
};
|
};
|
||||||
|
|||||||
26
apps/api-gateway/src/lib/fedcm-session.ts
Normal file
26
apps/api-gateway/src/lib/fedcm-session.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
|
import { FedcmSessionPayload, resolveFedcmSessionFromRequest } from './fedcm-cookie';
|
||||||
|
|
||||||
|
export async function resolveFedcmSessionPinState(
|
||||||
|
jwt: JwtService,
|
||||||
|
core: CoreGrpcService,
|
||||||
|
cookieHeader?: string
|
||||||
|
): Promise<{ session: FedcmSessionPayload | null; requiresPin: boolean }> {
|
||||||
|
const session = await resolveFedcmSessionFromRequest(jwt, cookieHeader);
|
||||||
|
if (!session) {
|
||||||
|
return { session: null, requiresPin: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = (await firstValueFrom(
|
||||||
|
core.auth.ValidateSession({
|
||||||
|
userId: session.sub,
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
touchActivity: false
|
||||||
|
})
|
||||||
|
)) as { requiresPin: boolean; pinVerified?: boolean };
|
||||||
|
|
||||||
|
const requiresPin = Boolean(validation.requiresPin || !session.pinVerified);
|
||||||
|
return { session, requiresPin };
|
||||||
|
}
|
||||||
@@ -4,11 +4,11 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { FormEvent, useCallback, useEffect, useState } from 'react';
|
import { FormEvent, Suspense, useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
import { ChevronLeft, Download, KeyRound, Mail, Network, Phone, QrCode, ShieldCheck, ShieldQuestion, UserRound } from 'lucide-react';
|
import { ChevronLeft, Download, KeyRound, Loader2, Mail, Network, Phone, QrCode, ShieldCheck, ShieldQuestion, UserRound } from 'lucide-react';
|
||||||
|
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
|
||||||
@@ -33,10 +33,9 @@ import { PhoneInput, phoneCountries, toE164 } from '@/components/ui/phone-input'
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
|
||||||
import type { AuthTokens, LoginMethod, OtpChannel, OtpSendResponse } from '@/lib/api';
|
import type { AuthTokens, LoginMethod, OtpChannel, OtpSendResponse } from '@/lib/api';
|
||||||
|
import { AUTH_TOKEN_KEY, claimQrLoginSession, createQrLoginSession, fetchFedcmSessionStatus, pollQrLoginSession } from '@/lib/api';
|
||||||
|
|
||||||
import { createQrLoginSession, fetchFedcmSessionStatus, pollQrLoginSession } from '@/lib/api';
|
import { isFedcmLoginPopup, finalizeFedcmLogin } from '@/lib/fedcm-login-bridge';
|
||||||
|
|
||||||
import { isFedcmLoginPopup } from '@/lib/fedcm-login-bridge';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
clearOtpResendAvailableAt,
|
clearOtpResendAvailableAt,
|
||||||
@@ -48,7 +47,7 @@ import {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
type Step = 'identify' | 'otp' | 'otpChannel' | 'password' | 'pin' | 'ldap' | 'qr' | 'totp';
|
type Step = 'identify' | 'otp' | 'otpChannel' | 'password' | 'pin' | 'ldap' | 'qr' | 'qrLink' | 'totp';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -83,8 +82,23 @@ const channelLabel: Record<string, string> = {
|
|||||||
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-[#1f2128] text-white">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-[#b9bdc9]" />
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LoginPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginPageContent() {
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const finishLoginRedirect = useCallback(() => {
|
const finishLoginRedirect = useCallback(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -269,9 +283,18 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const status = await fetchFedcmSessionStatus(publicApiUrl);
|
const status = await fetchFedcmSessionStatus(publicApiUrl);
|
||||||
if (cancelled || !status?.active || !status.requiresPin || !status.sessionId) return;
|
if (cancelled || !status?.active) return;
|
||||||
setPendingSessionId(status.sessionId);
|
|
||||||
setStep('pin');
|
if (status.requiresPin && status.sessionId) {
|
||||||
|
setPendingSessionId(status.sessionId);
|
||||||
|
setStep('pin');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = window.localStorage.getItem(AUTH_TOKEN_KEY);
|
||||||
|
if (token) {
|
||||||
|
await finalizeFedcmLogin(token);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -279,6 +302,34 @@ export default function LoginPage() {
|
|||||||
};
|
};
|
||||||
}, [publicApiUrl]);
|
}, [publicApiUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const qrLink = searchParams.get('qrLink');
|
||||||
|
if (!qrLink || qrSessionId) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setQrLoading(true);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const claimed = await claimQrLoginSession(qrLink);
|
||||||
|
if (cancelled) return;
|
||||||
|
setQrSessionId(qrLink);
|
||||||
|
setQrExpiresAt(claimed.expiresAt);
|
||||||
|
setStep('qrLink');
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Не удалось подключить устройство по QR-коду');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setQrLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [qrSessionId, searchParams, showToast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step !== 'otp' || !recipient) return;
|
if (step !== 'otp' || !recipient) return;
|
||||||
const saved = readOtpResendAvailableAt(recipient);
|
const saved = readOtpResendAvailableAt(recipient);
|
||||||
@@ -319,7 +370,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
if (step !== 'qr' || !qrSessionId) return;
|
if ((step !== 'qr' && step !== 'qrLink') || !qrSessionId) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -335,7 +386,13 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
if (session.status === 'EXPIRED') {
|
if (session.status === 'EXPIRED') {
|
||||||
|
|
||||||
showToast('QR-код истёк, создайте новый');
|
showToast(step === 'qrLink' ? 'QR-код истёк. Отсканируйте новый код на основном устройстве.' : 'QR-код истёк, создайте новый');
|
||||||
|
|
||||||
|
if (step === 'qrLink') {
|
||||||
|
setStep('identify');
|
||||||
|
setQrSessionId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setStep('identify');
|
setStep('identify');
|
||||||
|
|
||||||
@@ -1383,6 +1440,34 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{step === 'qrLink' ? (
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
|
||||||
|
<Loader2 className="mx-auto h-8 w-8 animate-spin text-white" />
|
||||||
|
|
||||||
|
<p className="mt-5 text-sm leading-relaxed text-[#b9bdc9]">
|
||||||
|
|
||||||
|
Подтверждаем подключение устройства. Дождитесь автоматического входа в аккаунт.
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{qrExpiresAt ? (
|
||||||
|
|
||||||
|
<p className="mt-2 text-xs text-[#8f92a0]">
|
||||||
|
|
||||||
|
QR-код действует до {new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(new Date(qrExpiresAt))}
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{step === 'pin' && pendingSessionId ? (
|
{step === 'pin' && pendingSessionId ? (
|
||||||
|
|
||||||
<form onSubmit={handlePinSubmit} className="space-y-3">
|
<form onSubmit={handlePinSubmit} className="space-y-3">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Monitor,
|
Monitor,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
Phone,
|
Phone,
|
||||||
|
QrCode,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
Speaker,
|
Speaker,
|
||||||
@@ -29,9 +30,10 @@ import { OtpInput } from '@/components/ui/otp-input';
|
|||||||
import { ActiveDevice, ActiveSession, apiFetch, AUTH_SESSION_KEY, getApiErrorMessage, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api';
|
import { ActiveDevice, ActiveSession, apiFetch, AUTH_SESSION_KEY, getApiErrorMessage, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api';
|
||||||
import { markPinIdleWatch } from '@/lib/pin-idle-storage';
|
import { markPinIdleWatch } from '@/lib/pin-idle-storage';
|
||||||
import { formatUserAgentLabel } from '@/lib/device-client';
|
import { formatUserAgentLabel } from '@/lib/device-client';
|
||||||
|
import { DeviceLinkQrDialog } from '@/components/id/device-link-qr-dialog';
|
||||||
|
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
|
||||||
|
|
||||||
function formatDate(value: string) {
|
function formatDate(value: string) {
|
||||||
return new Intl.DateTimeFormat('ru-RU', {
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
@@ -91,6 +93,7 @@ export default function SecurityPage() {
|
|||||||
const [passwordTotpCode, setPasswordTotpCode] = useState('');
|
const [passwordTotpCode, setPasswordTotpCode] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [passwordOtpSending, setPasswordOtpSending] = useState(false);
|
const [passwordOtpSending, setPasswordOtpSending] = useState(false);
|
||||||
|
const [deviceLinkOpen, setDeviceLinkOpen] = useState(false);
|
||||||
|
|
||||||
const hasSmsContact = Boolean(user?.phone || user?.backupPhone);
|
const hasSmsContact = Boolean(user?.phone || user?.backupPhone);
|
||||||
const hasEmailContact = Boolean(user?.email || user?.backupEmail);
|
const hasEmailContact = Boolean(user?.email || user?.backupEmail);
|
||||||
@@ -418,7 +421,20 @@ export default function SecurityPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 space-y-2">
|
<div className="mt-8 space-y-2">
|
||||||
<h2 className="text-lg font-medium">Другие устройства</h2>
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h2 className="text-lg font-medium">Другие устройства</h2>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl"
|
||||||
|
disabled={!user || !token || isSecurityLoading}
|
||||||
|
onClick={() => setDeviceLinkOpen(true)}
|
||||||
|
>
|
||||||
|
<QrCode className="mr-2 h-4 w-4" />
|
||||||
|
Подключить новое
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{isSecurityLoading ? (
|
{isSecurityLoading ? (
|
||||||
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Загружаем устройства...</div>
|
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Загружаем устройства...</div>
|
||||||
) : devices.length ? (
|
) : devices.length ? (
|
||||||
@@ -729,6 +745,16 @@ export default function SecurityPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{user && token ? (
|
||||||
|
<DeviceLinkQrDialog
|
||||||
|
open={deviceLinkOpen}
|
||||||
|
userId={user.id}
|
||||||
|
token={token}
|
||||||
|
onOpenChange={setDeviceLinkOpen}
|
||||||
|
onLinked={() => void loadSecurity()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</IdShell>
|
</IdShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -703,6 +703,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
if (shouldFinalizeFedcmLogin(pathname)) {
|
if (shouldFinalizeFedcmLogin(pathname)) {
|
||||||
await finalizeFedcmLogin(response.accessToken);
|
await finalizeFedcmLogin(response.accessToken);
|
||||||
|
} else {
|
||||||
|
void syncFedcmSession(response.accessToken);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[pathname, router, user?.id, warmUpAndApplySession]
|
[pathname, router, user?.id, warmUpAndApplySession]
|
||||||
|
|||||||
171
apps/frontend/components/id/device-link-qr-dialog.tsx
Normal file
171
apps/frontend/components/id/device-link-qr-dialog.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Loader2, QrCode } from 'lucide-react';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
approveQrLoginSession,
|
||||||
|
createDeviceLinkQrSession,
|
||||||
|
getApiErrorMessage,
|
||||||
|
pollQrLoginSession,
|
||||||
|
type QrLoginSession
|
||||||
|
} from '@/lib/api';
|
||||||
|
|
||||||
|
interface DeviceLinkQrDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
userId: string;
|
||||||
|
token: string;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onLinked?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCountdown(expiresAt: string) {
|
||||||
|
const secondsLeft = Math.max(0, Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 1000));
|
||||||
|
const minutes = Math.floor(secondsLeft / 60);
|
||||||
|
const seconds = secondsLeft % 60;
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceLinkQrDialog({ open, userId, token, onOpenChange, onLinked }: DeviceLinkQrDialogProps) {
|
||||||
|
const [session, setSession] = React.useState<QrLoginSession | null>(null);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [approving, setApproving] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = React.useState<string | null>(null);
|
||||||
|
const [countdown, setCountdown] = React.useState('5:00');
|
||||||
|
const approveStartedRef = React.useRef(false);
|
||||||
|
const refreshInFlightRef = React.useRef(false);
|
||||||
|
|
||||||
|
const refreshSession = React.useCallback(async () => {
|
||||||
|
if (!userId || !token || refreshInFlightRef.current) return;
|
||||||
|
refreshInFlightRef.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccessMessage(null);
|
||||||
|
approveStartedRef.current = false;
|
||||||
|
try {
|
||||||
|
const created = await createDeviceLinkQrSession(userId, token);
|
||||||
|
setSession(created);
|
||||||
|
} catch (err) {
|
||||||
|
setError(getApiErrorMessage(err, 'Не удалось создать QR-код') ?? 'Не удалось создать QR-код');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
refreshInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}, [token, userId]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSession(null);
|
||||||
|
setError(null);
|
||||||
|
setSuccessMessage(null);
|
||||||
|
approveStartedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void refreshSession();
|
||||||
|
}, [open, refreshSession]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open || !session?.expiresAt) return;
|
||||||
|
const update = () => setCountdown(formatCountdown(session.expiresAt));
|
||||||
|
update();
|
||||||
|
const timer = window.setInterval(update, 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [open, session?.expiresAt]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open || !session?.sessionId || !token || successMessage) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const current = await pollQrLoginSession(session.sessionId);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (current.status === 'EXPIRED') {
|
||||||
|
if (!refreshInFlightRef.current) {
|
||||||
|
await refreshSession();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.claimed && !approveStartedRef.current) {
|
||||||
|
approveStartedRef.current = true;
|
||||||
|
setApproving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await approveQrLoginSession(session.sessionId, token);
|
||||||
|
if (cancelled) return;
|
||||||
|
const label = current.claimedDeviceName?.trim();
|
||||||
|
setSuccessMessage(label ? `Устройство «${label}» подключено` : 'Новое устройство подключено');
|
||||||
|
onLinked?.();
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
approveStartedRef.current = false;
|
||||||
|
setError(getApiErrorMessage(err, 'Не удалось подтвердить подключение') ?? 'Не удалось подтвердить подключение');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setApproving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(getApiErrorMessage(err, 'Не удалось проверить статус QR-кода') ?? 'Не удалось проверить статус QR-кода');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void poll();
|
||||||
|
const timer = window.setInterval(() => void poll(), 2000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [onLinked, open, refreshSession, session?.sessionId, successMessage, token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Подключить новое устройство</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{loading && !session ? (
|
||||||
|
<div className="flex min-h-[280px] flex-col items-center justify-center gap-3 text-[#667085]">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
<p className="text-sm">Генерируем QR-код...</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{session?.qrPayload ? (
|
||||||
|
<div className="space-y-4 text-center">
|
||||||
|
<div className="mx-auto flex w-fit rounded-[24px] border border-[#eceef4] bg-white p-4 shadow-sm">
|
||||||
|
<QRCodeSVG value={session.qrPayload} size={220} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-relaxed text-[#667085]">
|
||||||
|
Отсканируйте QR-код камерой на новом устройстве. Откроется страница входа, и устройство подключится к вашему аккаунту.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[#8f92a0]">
|
||||||
|
Код действует <span className="font-medium text-[#1f2430]">{countdown}</span> и обновится автоматически
|
||||||
|
</p>
|
||||||
|
{approving ? (
|
||||||
|
<p className="flex items-center justify-center gap-2 text-sm text-[#3390ec]">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Подключаем устройство...
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{successMessage ? <p className="text-sm font-medium text-[#1a7f37]">{successMessage}</p> : null}
|
||||||
|
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||||
|
<Button type="button" variant="outline" className="w-full rounded-xl" disabled={loading} onClick={() => void refreshSession()}>
|
||||||
|
<QrCode className="mr-2 h-4 w-4" />
|
||||||
|
{loading ? 'Обновляем...' : 'Обновить QR-код'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -236,6 +236,8 @@ export interface QrLoginSession {
|
|||||||
status: 'PENDING' | 'APPROVED' | 'EXPIRED';
|
status: 'PENDING' | 'APPROVED' | 'EXPIRED';
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
qrPayload?: string;
|
qrPayload?: string;
|
||||||
|
claimed?: boolean;
|
||||||
|
claimedDeviceName?: string;
|
||||||
auth?: AuthTokens & { user?: PublicUser };
|
auth?: AuthTokens & { user?: PublicUser };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +257,23 @@ export async function pollQrLoginSession(sessionId: string) {
|
|||||||
return apiFetch<QrLoginSession>(`/auth/advanced/qr/session/${sessionId}`);
|
return apiFetch<QrLoginSession>(`/auth/advanced/qr/session/${sessionId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createDeviceLinkQrSession(userId: string, token: string) {
|
||||||
|
return apiFetch<QrLoginSession>(`/security/users/${userId}/device-link/session`, { method: 'POST' }, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function claimQrLoginSession(sessionId: string) {
|
||||||
|
const { buildAuthDevicePayload } = await import('@/lib/device-client');
|
||||||
|
const device = buildAuthDevicePayload();
|
||||||
|
return apiFetch<QrLoginSession>(`/auth/advanced/qr/session/${sessionId}/claim`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(device)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveQrLoginSession(sessionId: string, token: string) {
|
||||||
|
return apiFetch<QrLoginSession>(`/auth/advanced/qr/session/${sessionId}/approve`, { method: 'POST' }, token);
|
||||||
|
}
|
||||||
|
|
||||||
export interface TotpSetupResponse {
|
export interface TotpSetupResponse {
|
||||||
secret: string;
|
secret: string;
|
||||||
otpauthUrl: string;
|
otpauthUrl: string;
|
||||||
|
|||||||
@@ -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 { ConfigService } from '@nestjs/config';
|
||||||
import { randomBytes, randomUUID } from 'node:crypto';
|
import { randomBytes, randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { RedisService } from '../infra/redis.service';
|
import { RedisService } from '../infra/redis.service';
|
||||||
import { AuthService } from './auth.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 {
|
interface StoredQrSession {
|
||||||
status: 'PENDING' | 'APPROVED' | 'EXPIRED';
|
status: 'PENDING' | 'APPROVED' | 'EXPIRED';
|
||||||
|
mode?: QrSessionMode;
|
||||||
|
initiatorUserId?: string;
|
||||||
|
claimed?: boolean;
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
fingerprint: string;
|
fingerprint: string;
|
||||||
deviceType?: string;
|
deviceType?: string;
|
||||||
@@ -53,7 +59,7 @@ export class AdvancedAuthService {
|
|||||||
|
|
||||||
async createQrSession(input: CreateQrSessionInput) {
|
async createQrSession(input: CreateQrSessionInput) {
|
||||||
const sessionId = randomUUID();
|
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 apiBase = this.config.get<string>('PUBLIC_API_URL') ?? 'http://localhost:3001';
|
||||||
const qrPayload = JSON.stringify({
|
const qrPayload = JSON.stringify({
|
||||||
v: 1,
|
v: 1,
|
||||||
@@ -65,6 +71,7 @@ export class AdvancedAuthService {
|
|||||||
|
|
||||||
const data: StoredQrSession = {
|
const data: StoredQrSession = {
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
|
mode: 'login',
|
||||||
deviceName: input.deviceName,
|
deviceName: input.deviceName,
|
||||||
fingerprint: input.fingerprint,
|
fingerprint: input.fingerprint,
|
||||||
deviceType: input.deviceType,
|
deviceType: input.deviceType,
|
||||||
@@ -73,45 +80,85 @@ export class AdvancedAuthService {
|
|||||||
expiresAt: expiresAt.toISOString()
|
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,
|
sessionId,
|
||||||
|
claimUrl,
|
||||||
|
expiresAt: expiresAt.toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: StoredQrSession = {
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
expiresAt: expiresAt.toISOString(),
|
mode: 'device_link',
|
||||||
qrPayload
|
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) {
|
async pollQrSession(sessionId: string) {
|
||||||
const raw = await this.redis.client.get(`qr:session:${sessionId}`);
|
const raw = await this.redis.client.get(`qr:session:${sessionId}`);
|
||||||
if (!raw) {
|
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;
|
const data = JSON.parse(raw) as StoredQrSession;
|
||||||
if (data.status === 'PENDING' && new Date(data.expiresAt).getTime() <= Date.now()) {
|
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 {
|
return this.buildQrSessionResponse(sessionId, data);
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async approveQrSession(sessionId: string, userId: string) {
|
async approveQrSession(sessionId: string, userId: string) {
|
||||||
@@ -128,6 +175,14 @@ export class AdvancedAuthService {
|
|||||||
if (new Date(data.expiresAt).getTime() <= Date.now()) {
|
if (new Date(data.expiresAt).getTime() <= Date.now()) {
|
||||||
throw new BadRequestException('QR-сессия истекла');
|
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, {
|
const auth = await this.auth.createQrApprovedLogin(userId, {
|
||||||
fingerprint: data.fingerprint,
|
fingerprint: data.fingerprint,
|
||||||
@@ -142,20 +197,29 @@ export class AdvancedAuthService {
|
|||||||
const ttl = await this.redis.client.ttl(key);
|
const ttl = await this.redis.client.ttl(key);
|
||||||
await this.redis.client.set(key, JSON.stringify(data), 'EX', Math.max(ttl, 30));
|
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 {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
status: 'APPROVED',
|
status: data.status,
|
||||||
expiresAt: data.expiresAt,
|
expiresAt: data.expiresAt,
|
||||||
auth: {
|
qrPayload,
|
||||||
accessToken: auth.accessToken,
|
claimed: Boolean(data.claimed && data.fingerprint),
|
||||||
refreshToken: auth.refreshToken,
|
claimedDeviceName: data.claimed && data.deviceName ? data.deviceName : undefined,
|
||||||
sessionId: auth.sessionId,
|
auth: data.auth
|
||||||
pinVerified: auth.pinVerified,
|
? {
|
||||||
expiresAt: auth.expiresAt,
|
accessToken: data.auth.accessToken,
|
||||||
requiresTotp: auth.requiresTotp,
|
refreshToken: data.auth.refreshToken,
|
||||||
totpChallengeToken: auth.totpChallengeToken,
|
sessionId: data.auth.sessionId,
|
||||||
user: auth.user
|
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')
|
@GrpcMethod('AdvancedAuthService', 'PollQrSession')
|
||||||
pollQrSession(command: { sessionId: string }) {
|
pollQrSession(command: { sessionId: string }) {
|
||||||
return this.advancedAuth.pollQrSession(command.sessionId);
|
return this.advancedAuth.pollQrSession(command.sessionId);
|
||||||
|
|||||||
@@ -3324,7 +3324,9 @@ verify_fedcm_endpoints() {
|
|||||||
if [[ -n "$config_login_url" ]]; then
|
if [[ -n "$config_login_url" ]]; then
|
||||||
echo -e " ${GREEN}✔${NC} FedCM config.json login_url=${config_login_url}"
|
echo -e " ${GREEN}✔${NC} FedCM config.json login_url=${config_login_url}"
|
||||||
if [[ "$config_login_url" != "https://${api_domain}/auth/login" \
|
if [[ "$config_login_url" != "https://${api_domain}/auth/login" \
|
||||||
&& "$config_login_url" != "http://${api_domain}/auth/login" ]]; then
|
&& "$config_login_url" != "http://${api_domain}/auth/login" \
|
||||||
|
&& "$config_login_url" != "https://${api_domain}/auth/login?fedcm=1" \
|
||||||
|
&& "$config_login_url" != "http://${api_domain}/auth/login?fedcm=1" ]]; then
|
||||||
echo -e " ${YELLOW}!${NC} login_url должен быть same-origin с config (${scheme}://${api_domain}/auth/login)"
|
echo -e " ${YELLOW}!${NC} login_url должен быть same-origin с config (${scheme}://${api_domain}/auth/login)"
|
||||||
echo " Иначе Chrome: «invalid URL for login_url». Пересоберите api-gateway и ./install.sh --fix-all"
|
echo " Иначе Chrome: «invalid URL for login_url». Пересоберите api-gateway и ./install.sh --fix-all"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ service AdvancedAuthService {
|
|||||||
rpc CreateWebAuthnRegistrationChallenge (WebAuthnRequest) returns (ChallengeResponse);
|
rpc CreateWebAuthnRegistrationChallenge (WebAuthnRequest) returns (ChallengeResponse);
|
||||||
rpc CreateWebAuthnLoginChallenge (WebAuthnRequest) returns (ChallengeResponse);
|
rpc CreateWebAuthnLoginChallenge (WebAuthnRequest) returns (ChallengeResponse);
|
||||||
rpc CreateQrSession (QrSessionRequest) returns (QrSessionResponse);
|
rpc CreateQrSession (QrSessionRequest) returns (QrSessionResponse);
|
||||||
|
rpc CreateDeviceLinkSession (CreateDeviceLinkSessionRequest) returns (QrSessionResponse);
|
||||||
|
rpc ClaimQrSession (ClaimQrSessionRequest) returns (QrSessionResponse);
|
||||||
rpc PollQrSession (QrPollRequest) returns (QrSessionResponse);
|
rpc PollQrSession (QrPollRequest) returns (QrSessionResponse);
|
||||||
rpc ApproveQrSession (ApproveQrSessionRequest) returns (QrSessionResponse);
|
rpc ApproveQrSession (ApproveQrSessionRequest) returns (QrSessionResponse);
|
||||||
}
|
}
|
||||||
@@ -243,6 +245,22 @@ message QrSessionResponse {
|
|||||||
string expiresAt = 3;
|
string expiresAt = 3;
|
||||||
optional string qrPayload = 4;
|
optional string qrPayload = 4;
|
||||||
optional QrAuthPayload auth = 5;
|
optional QrAuthPayload auth = 5;
|
||||||
|
optional bool claimed = 6;
|
||||||
|
optional string claimedDeviceName = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateDeviceLinkSessionRequest {
|
||||||
|
string userId = 1;
|
||||||
|
string frontendUrl = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClaimQrSessionRequest {
|
||||||
|
string sessionId = 1;
|
||||||
|
string deviceName = 2;
|
||||||
|
optional string fingerprint = 3;
|
||||||
|
optional string deviceType = 4;
|
||||||
|
optional string ipAddress = 5;
|
||||||
|
optional string userAgent = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ApproveQrSessionRequest {
|
message ApproveQrSessionRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user