diff --git a/apps/api-gateway/src/controllers/advanced-auth.controller.ts b/apps/api-gateway/src/controllers/advanced-auth.controller.ts index 921629c..f82d3a8 100644 --- a/apps/api-gateway/src/controllers/advanced-auth.controller.ts +++ b/apps/api-gateway/src/controllers/advanced-auth.controller.ts @@ -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 { firstValueFrom } from 'rxjs'; import { enrichAuthClientMeta } from '../client-request.util'; import { JwtService } from '@nestjs/jwt'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { CoreGrpcService } from '../core-grpc.service'; import { QrSessionDto, WebAuthnDto } from '../dto/identity.dto'; import { resolveAuthorizedPayload } from '../session-auth'; +import { resolveFrontendUrl } from '../lib/oauth-issuer'; @ApiTags('Биометрия и QR-вход') @Controller('auth/advanced') @@ -53,6 +55,30 @@ export class AdvancedAuthController { 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') @ApiBearerAuth() @ApiOperation({ summary: 'Подтвердить QR-вход', description: 'Подтверждает QR-сессию с мобильного приложения уже авторизованным пользователем.' }) diff --git a/apps/api-gateway/src/controllers/fedcm.controller.ts b/apps/api-gateway/src/controllers/fedcm.controller.ts index bf05cab..ff11d24 100644 --- a/apps/api-gateway/src/controllers/fedcm.controller.ts +++ b/apps/api-gateway/src/controllers/fedcm.controller.ts @@ -31,6 +31,7 @@ import { resolveFedcmEndpoints } from '../lib/fedcm-config'; import { resolveFedcmSessionFromRequest, setFedcmSessionCookie } from '../lib/fedcm-cookie'; +import { resolveFedcmSessionPinState } from '../lib/fedcm-session'; import { verifyAccessToken } from '../session-auth'; type FedcmAccountsResponse = { @@ -128,12 +129,17 @@ export class FedcmController { applyFedcmCorsHeaders(res, origin); 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) { applyFedcmLoginStatus(res, false); return { accounts: [] }; } + if (requiresPin) { + applyFedcmLoginStatus(res, false); + return { accounts: [] }; + } + try { const result = (await firstValueFrom( this.core.fedcm.GetAccounts({ userId: session.sub, sessionId: session.sessionId }) @@ -180,11 +186,16 @@ export class FedcmController { applyFedcmCorsHeaders(res, rpOrigin); 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) { throw new UnauthorizedException('Сессия FedCM не найдена'); } + if (requiresPin) { + applyFedcmLoginStatus(res, false); + throw new UnauthorizedException('Требуется подтверждение PIN-кода'); + } + const body = (req.body ?? {}) as Record; const clientId = String(body.client_id ?? body.clientId ?? '').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).' }) async loginStatus(@Req() req: Request, @Res() res: Response) { - const session = await resolveFedcmSessionFromRequest(this.jwt, req.headers.cookie); - const loggedIn = Boolean(session); + const { session, requiresPin } = await resolveFedcmSessionPinState(this.jwt, this.core, req.headers.cookie); + const loggedIn = Boolean(session && !requiresPin); applyFedcmLoginStatus(res, loggedIn); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('Cache-Control', 'no-store'); diff --git a/apps/api-gateway/src/controllers/security.controller.ts b/apps/api-gateway/src/controllers/security.controller.ts index 7457d0e..1bc514e 100644 --- a/apps/api-gateway/src/controllers/security.controller.ts +++ b/apps/api-gateway/src/controllers/security.controller.ts @@ -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 { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { firstValueFrom } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; import { OptionalPinDto, PinDto, TotpCodeDto, VerifySecurityPinDto } from '../dto/security.dto'; import { resolveAuthorizedPayload } from '../session-auth'; +import { resolveFrontendUrl } from '../lib/oauth-issuer'; @ApiTags('Безопасность') @ApiBearerAuth() @@ -148,4 +150,20 @@ export class SecurityController { const payload = await resolveAuthorizedPayload(this.jwt, this.core, authorization); 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 })); + } } diff --git a/apps/api-gateway/src/lib/fedcm-config.ts b/apps/api-gateway/src/lib/fedcm-config.ts index 0761f58..a448673 100644 --- a/apps/api-gateway/src/lib/fedcm-config.ts +++ b/apps/api-gateway/src/lib/fedcm-config.ts @@ -40,7 +40,7 @@ function buildFedcmEndpointUrls(issuer: string, frontendUrl: string) { idAssertionEndpoint: `${base}/fedcm/id_assertion`, // FedCM: login_url MUST be same-origin with config.json (W3C FedCM / Chrome). // На split-domain UI проксируется через nginx: api.idpmvk.lpr/auth/login → frontend. - loginUrl: `${base}/auth/login`, + loginUrl: `${base}/auth/login?fedcm=1`, signupUrl: `${front}/auth/register`, webIdentityUrl: `${webIdentityOrigin}/.well-known/web-identity` }; diff --git a/apps/api-gateway/src/lib/fedcm-session.ts b/apps/api-gateway/src/lib/fedcm-session.ts new file mode 100644 index 0000000..a48b11d --- /dev/null +++ b/apps/api-gateway/src/lib/fedcm-session.ts @@ -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 }; +} diff --git a/apps/frontend/app/auth/login/page.tsx b/apps/frontend/app/auth/login/page.tsx index 90670b3..3f4ac62 100644 --- a/apps/frontend/app/auth/login/page.tsx +++ b/apps/frontend/app/auth/login/page.tsx @@ -4,11 +4,11 @@ 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'; @@ -33,10 +33,9 @@ import { PhoneInput, phoneCountries, toE164 } from '@/components/ui/phone-input' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; 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 } from '@/lib/fedcm-login-bridge'; +import { isFedcmLoginPopup, finalizeFedcmLogin } from '@/lib/fedcm-login-bridge'; import { 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 = { export default function LoginPage() { + return ( + + + + } + > + + + ); +} + +function LoginPageContent() { const router = useRouter(); + const searchParams = useSearchParams(); const finishLoginRedirect = useCallback(() => { if (typeof window !== 'undefined') { @@ -269,9 +283,18 @@ export default function LoginPage() { void (async () => { const status = await fetchFedcmSessionStatus(publicApiUrl); - if (cancelled || !status?.active || !status.requiresPin || !status.sessionId) return; - setPendingSessionId(status.sessionId); - setStep('pin'); + if (cancelled || !status?.active) return; + + 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 () => { @@ -279,6 +302,34 @@ export default function LoginPage() { }; }, [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(() => { if (step !== 'otp' || !recipient) return; const saved = readOtpResendAvailableAt(recipient); @@ -319,7 +370,7 @@ export default function LoginPage() { useEffect(() => { - if (step !== 'qr' || !qrSessionId) return; + if ((step !== 'qr' && step !== 'qrLink') || !qrSessionId) return; let cancelled = false; @@ -335,7 +386,13 @@ export default function LoginPage() { if (session.status === 'EXPIRED') { - showToast('QR-код истёк, создайте новый'); + showToast(step === 'qrLink' ? 'QR-код истёк. Отсканируйте новый код на основном устройстве.' : 'QR-код истёк, создайте новый'); + + if (step === 'qrLink') { + setStep('identify'); + setQrSessionId(null); + return; + } setStep('identify'); @@ -1383,6 +1440,34 @@ export default function LoginPage() { + {step === 'qrLink' ? ( + +
+ + + +

+ + Подтверждаем подключение устройства. Дождитесь автоматического входа в аккаунт. + +

+ + {qrExpiresAt ? ( + +

+ + QR-код действует до {new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(new Date(qrExpiresAt))} + +

+ + ) : null} + +
+ + ) : null} + + + {step === 'pin' && pendingSessionId ? (
diff --git a/apps/frontend/app/security/page.tsx b/apps/frontend/app/security/page.tsx index 7119801..abf7d70 100644 --- a/apps/frontend/app/security/page.tsx +++ b/apps/frontend/app/security/page.tsx @@ -12,6 +12,7 @@ import { Monitor, type LucideIcon, Phone, + QrCode, ShieldCheck, Smartphone, 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 { markPinIdleWatch } from '@/lib/pin-idle-storage'; 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 { QRCodeSVG } from 'qrcode.react'; -import { useRequireAuth } from '@/hooks/use-require-auth'; function formatDate(value: string) { return new Intl.DateTimeFormat('ru-RU', { @@ -91,6 +93,7 @@ export default function SecurityPage() { const [passwordTotpCode, setPasswordTotpCode] = useState(''); const [newPassword, setNewPassword] = useState(''); const [passwordOtpSending, setPasswordOtpSending] = useState(false); + const [deviceLinkOpen, setDeviceLinkOpen] = useState(false); const hasSmsContact = Boolean(user?.phone || user?.backupPhone); const hasEmailContact = Boolean(user?.email || user?.backupEmail); @@ -418,7 +421,20 @@ export default function SecurityPage() {
-

Другие устройства

+
+

Другие устройства

+ +
{isSecurityLoading ? (
Загружаем устройства...
) : devices.length ? ( @@ -729,6 +745,16 @@ export default function SecurityPage() { ) : null} + + {user && token ? ( + void loadSecurity()} + /> + ) : null} ); } diff --git a/apps/frontend/components/id/auth-provider.tsx b/apps/frontend/components/id/auth-provider.tsx index 9701150..97434a2 100644 --- a/apps/frontend/components/id/auth-provider.tsx +++ b/apps/frontend/components/id/auth-provider.tsx @@ -703,6 +703,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (shouldFinalizeFedcmLogin(pathname)) { await finalizeFedcmLogin(response.accessToken); + } else { + void syncFedcmSession(response.accessToken); } }, [pathname, router, user?.id, warmUpAndApplySession] diff --git a/apps/frontend/components/id/device-link-qr-dialog.tsx b/apps/frontend/components/id/device-link-qr-dialog.tsx new file mode 100644 index 0000000..84b07eb --- /dev/null +++ b/apps/frontend/components/id/device-link-qr-dialog.tsx @@ -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(null); + const [loading, setLoading] = React.useState(false); + const [approving, setApproving] = React.useState(false); + const [error, setError] = React.useState(null); + const [successMessage, setSuccessMessage] = React.useState(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 ( + + + + Подключить новое устройство + + + {loading && !session ? ( +
+ +

Генерируем QR-код...

+
+ ) : null} + + {session?.qrPayload ? ( +
+
+ +
+

+ Отсканируйте QR-код камерой на новом устройстве. Откроется страница входа, и устройство подключится к вашему аккаунту. +

+

+ Код действует {countdown} и обновится автоматически +

+ {approving ? ( +

+ + Подключаем устройство... +

+ ) : null} + {successMessage ?

{successMessage}

: null} + {error ?

{error}

: null} + +
+ ) : null} +
+
+ ); +} diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index 9406bb4..ae22f81 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -236,6 +236,8 @@ export interface QrLoginSession { status: 'PENDING' | 'APPROVED' | 'EXPIRED'; expiresAt: string; qrPayload?: string; + claimed?: boolean; + claimedDeviceName?: string; auth?: AuthTokens & { user?: PublicUser }; } @@ -255,6 +257,23 @@ export async function pollQrLoginSession(sessionId: string) { return apiFetch(`/auth/advanced/qr/session/${sessionId}`); } +export async function createDeviceLinkQrSession(userId: string, token: string) { + return apiFetch(`/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(`/auth/advanced/qr/session/${sessionId}/claim`, { + method: 'POST', + body: JSON.stringify(device) + }); +} + +export async function approveQrLoginSession(sessionId: string, token: string) { + return apiFetch(`/auth/advanced/qr/session/${sessionId}/approve`, { method: 'POST' }, token); +} + export interface TotpSetupResponse { secret: string; otpauthUrl: string; diff --git a/apps/sso-core/src/domain/advanced-auth.service.ts b/apps/sso-core/src/domain/advanced-auth.service.ts index 744af53..64f6bf0 100644 --- a/apps/sso-core/src/domain/advanced-auth.service.ts +++ b/apps/sso-core/src/domain/advanced-auth.service.ts @@ -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('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 }; } diff --git a/apps/sso-core/src/domain/auth-grpc.controller.ts b/apps/sso-core/src/domain/auth-grpc.controller.ts index 8a448ce..4882597 100644 --- a/apps/sso-core/src/domain/auth-grpc.controller.ts +++ b/apps/sso-core/src/domain/auth-grpc.controller.ts @@ -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); diff --git a/install.sh b/install.sh index 125ff0c..17506c0 100644 --- a/install.sh +++ b/install.sh @@ -3324,7 +3324,9 @@ verify_fedcm_endpoints() { if [[ -n "$config_login_url" ]]; then echo -e " ${GREEN}✔${NC} FedCM config.json login_url=${config_login_url}" 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 " Иначе Chrome: «invalid URL for login_url». Пересоберите api-gateway и ./install.sh --fix-all" fi diff --git a/shared/proto/identity.proto b/shared/proto/identity.proto index e71c6a3..f006633 100644 --- a/shared/proto/identity.proto +++ b/shared/proto/identity.proto @@ -28,6 +28,8 @@ service AdvancedAuthService { rpc CreateWebAuthnRegistrationChallenge (WebAuthnRequest) returns (ChallengeResponse); rpc CreateWebAuthnLoginChallenge (WebAuthnRequest) returns (ChallengeResponse); rpc CreateQrSession (QrSessionRequest) returns (QrSessionResponse); + rpc CreateDeviceLinkSession (CreateDeviceLinkSessionRequest) returns (QrSessionResponse); + rpc ClaimQrSession (ClaimQrSessionRequest) returns (QrSessionResponse); rpc PollQrSession (QrPollRequest) returns (QrSessionResponse); rpc ApproveQrSession (ApproveQrSessionRequest) returns (QrSessionResponse); } @@ -243,6 +245,22 @@ message QrSessionResponse { string expiresAt = 3; optional string qrPayload = 4; 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 {