From 6dacba2786a574c2dfdeac88a53d3e5ce83ac14d Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 27 Nov 2025 18:17:58 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=B0=D1=88=D0=B5=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B8=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 7 +- backend/routes/auth.js | 102 ++++++----- backend/services/auth-service.js | 13 +- frontend/src/services/wallet.js | 156 ++-------------- frontend/src/utils/wallet.js | 306 +++++++++++++++---------------- 5 files changed, 227 insertions(+), 357 deletions(-) diff --git a/.gitignore b/.gitignore index 02dff3a..b3a3ab8 100644 --- a/.gitignore +++ b/.gitignore @@ -215,4 +215,9 @@ dle-template.tar.gz.part-* dle-template.tar.gz.join.sh # Guide files -pages-guide.md \ No newline at end of file +pages-guide.md + +# Problem documentation (internal) +SIWE_PROBLEM.md +REBUILD_COMMANDS.md +SIWE_ISSUE_DESCRIPTION.md \ No newline at end of file diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 2e1ee95..82e8d56 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -46,6 +46,16 @@ router.get('/nonce', async (req, res) => { return res.status(400).json({ error: 'Address is required' }); } + // Нормализуем адрес: сначала через ethers.getAddress (EIP-55 checksum), потом toLowerCase для хранения + // Это гарантирует, что адрес валиден и всегда сохраняется в нижнем регистре + let normalizedAddress; + try { + normalizedAddress = ethers.getAddress(address).toLowerCase(); + } catch (error) { + logger.error(`[nonce] Invalid address format: ${address}`, error); + return res.status(400).json({ error: 'Invalid address format' }); + } + // Очищаем истекшие nonce перед генерацией нового try { await db.getQuery()( @@ -70,33 +80,34 @@ router.get('/nonce', async (req, res) => { try { // Проверяем, существует ли уже nonce для этого адреса + // Используем normalizedAddress (уже в нижнем регистре) const existingNonces = await db.getQuery()( 'SELECT id FROM nonces WHERE identity_value_encrypted = encrypt_text($1, $2)', - [address.toLowerCase(), encryptionKey] + [normalizedAddress, encryptionKey] ); if (existingNonces.rows.length > 0) { // Обновляем существующий nonce - logger.info(`[nonce] Updating existing nonce for address: ${address.toLowerCase()}`); + logger.info(`[nonce] Updating existing nonce for address: ${normalizedAddress}`); await db.getQuery()( 'UPDATE nonces SET nonce_encrypted = encrypt_text($1, $2), expires_at = $3 WHERE id = $4', [nonce, encryptionKey, new Date(Date.now() + 15 * 60 * 1000), existingNonces.rows[0].id] ); } else { // Создаем новый nonce - logger.info(`[nonce] Creating new nonce for address: ${address.toLowerCase()}`); + logger.info(`[nonce] Creating new nonce for address: ${normalizedAddress}`); await db.getQuery()( 'INSERT INTO nonces (identity_value_encrypted, nonce_encrypted, expires_at) VALUES (encrypt_text($1, $2), encrypt_text($3, $2), $4)', - [address.toLowerCase(), encryptionKey, nonce, new Date(Date.now() + 15 * 60 * 1000)] + [normalizedAddress, encryptionKey, nonce, new Date(Date.now() + 15 * 60 * 1000)] ); } } catch (dbError) { console.error('Database error:', dbError); // Fallback: просто возвращаем nonce без сохранения в БД - logger.warn(`Nonce ${nonce} generated for address ${address} but not saved to DB due to error`); + logger.warn(`Nonce ${nonce} generated for address ${normalizedAddress} but not saved to DB due to error`); } - logger.info(`Nonce ${nonce} сохранен для адреса ${address}`); + logger.info(`Nonce ${nonce} сохранен для адреса ${normalizedAddress}`); res.json({ nonce }); } catch (error) { @@ -136,6 +147,8 @@ router.post('/verify', async (req, res) => { const encryptionKey = encryptionUtils.getEncryptionKey(); // Проверяем nonce в базе данных с проверкой времени истечения + // ВАЖНО: nonce привязан к адресу, поэтому проверяем для того адреса, который пришел в запросе + logger.info(`[verify] Checking nonce for address: ${normalizedAddressLower} (normalized from: ${address})`); const nonceResult = await db.getQuery()( 'SELECT nonce_encrypted, expires_at FROM nonces WHERE identity_value_encrypted = encrypt_text($1, $2)', [normalizedAddressLower, encryptionKey] @@ -143,7 +156,8 @@ router.post('/verify', async (req, res) => { if (nonceResult.rows.length === 0) { logger.error(`[verify] Nonce not found for address: ${normalizedAddressLower}`); - return res.status(401).json({ success: false, error: 'Nonce not found' }); + logger.error(`[verify] This may happen if user switched wallet between nonce request and signature`); + return res.status(401).json({ success: false, error: 'Nonce not found. Please request a new nonce.' }); } // Проверяем, не истек ли срок действия nonce @@ -173,46 +187,32 @@ router.post('/verify', async (req, res) => { } // ВАЖНО: Для SIWE сообщения ВСЕГДА используем хост из запроса, чтобы он совпадал с фронтендом - // Фронтенд использует window.location.host и window.location.origin, поэтому бэкенд должен использовать то же самое - // Это означает, что даже если в БД есть домен (например, 185.221.214.140), для SIWE будет использоваться - // хост из текущего запроса (например, localhost:9000), если запрос приходит с localhost + // Фронтенд использует window.location.host, поэтому бэкенд должен использовать req.get('host') + // Логика формирования domain должна точно соответствовать фронтенду const protocol = req.protocol || 'http'; let host = req.get('host') || 'localhost:9000'; - logger.info(`[verify] Request protocol: ${protocol}, host header: ${req.get('host')}, original host: ${host}`); + logger.info(`[verify] Request protocol: ${protocol}, host header: ${req.get('host')}`); - // Убеждаемся, что порт присутствует для localhost - if (host === 'localhost' || host.startsWith('localhost:')) { - if (!host.includes(':')) { - // Если порта нет, добавляем стандартный порт для протокола - const defaultPort = protocol === 'https' ? '443' : '9000'; - host = `${host}:${defaultPort}`; - logger.info(`[verify] Added default port to localhost: ${host}`); - } - } - - // Формируем domain и origin для SIWE сообщения из текущего запроса - // domain - это host (например, "localhost:9000" или "example.com:443") - // ВАЖНО: domain и origin для SIWE НИКОГДА не берутся из БД, только из запроса! - const baseUrlForResources = `${protocol}://${host}`; - - // Извлекаем домен и origin из baseUrlForResources для SIWE сообщения - const baseUrlObj = new URL(baseUrlForResources); - // Используем host (включает порт, если он нестандартный) или hostname + port - let domain = baseUrlObj.host; // Домен для SIWE (например, "localhost:9000" или "example.com") - // Если порт стандартный (80 для http, 443 для https), он может не быть в host - // В этом случае добавляем порт явно для localhost или если порт указан в URL + // Формируем domain для SIWE (должен совпадать с фронтендом) + // Используем ту же логику, что и на фронтенде: window.location.host + добавление порта для localhost/IP + let domain = host; if (!domain.includes(':')) { - if (baseUrlObj.port) { - // Порт есть в URL, но не в host (стандартный порт) - domain = `${baseUrlObj.hostname}:${baseUrlObj.port}`; - } else if (baseUrlObj.hostname === 'localhost' || baseUrlObj.hostname === '127.0.0.1') { - // Для localhost добавляем порт явно - const defaultPort = baseUrlObj.protocol === 'https:' ? '443' : '9000'; - domain = `${baseUrlObj.hostname}:${defaultPort}`; + // Извлекаем hostname из host (если порта нет, host = hostname) + const hostname = host; + if (hostname === 'localhost' || hostname === '127.0.0.1') { + domain = `${hostname}:9000`; + } else if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) { + // IP адрес - добавляем порт + domain = `${hostname}:9000`; } } - const origin = baseUrlForResources; // URI для SIWE (полный URL) + + // Формируем origin и baseUrl для resources + // ВАЖНО: origin должен совпадать с origin на фронтенде (getOrigin()), + // поэтому используем именно domain (с портом), а не host из заголовка + const origin = `${protocol}://${domain}`; + const baseUrlForResources = origin; // Получаем список документов для подписания и добавляем их в resources const documentTitles = Object.keys(DOCUMENT_CONSENT_MAP); @@ -222,6 +222,8 @@ router.post('/verify', async (req, res) => { ); let resources = [`${baseUrlForResources}/api/auth/verify`]; + // Добавляем общую ссылку на страницу опубликованных документов + resources.push(`${baseUrlForResources}/content/published`); if (tableExistsRes.rows[0].exists) { const { rows: documents } = await db.getQuery()(` SELECT id FROM ${tableName} @@ -236,13 +238,16 @@ router.post('/verify', async (req, res) => { }); } - // Сортируем resources для консистентности (должно совпадать с фронтендом) + // Сортируем resources для консистентности resources = resources.sort(); // Используем issuedAt из запроса, если он есть, иначе создаем новый const messageIssuedAt = issuedAt || new Date().toISOString(); const { SiweMessage } = require('siwe'); + + // Реконструируем SIWE-сообщение на бэкенде, НО уже с теми же полями, + // что и на фронтенде: domain, origin (uri), resources const message = new SiweMessage({ domain, address: normalizedAddress, @@ -254,20 +259,21 @@ router.post('/verify', async (req, res) => { issuedAt: messageIssuedAt, resources: resources, }); - + const messageToVerify = message; + const messageToSign = message.prepareMessage(); logger.info(`[verify] SIWE message for verification: ${messageToSign}`); - logger.info(`[verify] Resources: ${JSON.stringify(resources)}`); - logger.info(`[verify] IssuedAt: ${messageIssuedAt}`); - logger.info(`[verify] Domain: ${domain}, Origin: ${origin}`); - logger.info(`[verify] Normalized address: ${normalizedAddress}`); + logger.info(`[verify] Resources (backend expectation): ${JSON.stringify(resources)}`); + logger.info(`[verify] IssuedAt (backend): ${messageIssuedAt}`); + logger.info(`[verify] Domain (backend): ${domain}, Origin (backend): ${origin}`); + logger.info(`[verify] Normalized address from request: ${normalizedAddress}`); logger.info(`[verify] Request headers origin: ${req.get('origin')}`); logger.info(`[verify] Request headers host: ${req.get('host')}`); logger.info(`[verify] Request headers referer: ${req.get('referer')}`); - // Проверяем подпись через SiweMessage.verify() (передаем объект сообщения, а не строку) - const isValid = await authService.verifySignature(message, signature, normalizedAddress); + // Проверяем подпись через SiweMessage.verify() + const isValid = await authService.verifySignature(messageToVerify, signature, normalizedAddress); if (!isValid) { logger.error(`[verify] Invalid signature for address: ${normalizedAddress}`); return res.status(401).json({ success: false, error: 'Invalid signature' }); diff --git a/backend/services/auth-service.js b/backend/services/auth-service.js index c23a8a1..ffe6d38 100644 --- a/backend/services/auth-service.js +++ b/backend/services/auth-service.js @@ -55,6 +55,7 @@ class AuthService { } // Проверяем подпись через SiweMessage.verify() + // SiweMessage.verify() уже гарантирует, что адрес из подписи совпадает с адресом в сообщении const { success, data } = await message.verify({ signature }); // Логируем для отладки @@ -63,23 +64,15 @@ class AuthService { logger.info(`[verifySignature] Verified address from signature: ${data.address}`); logger.info(`[verifySignature] Address in message: ${message.address}`); logger.info(`[verifySignature] Expected address (from request): ${normalizedAddress}`); - logger.info(`[verifySignature] Signature address matches message address: ${ethers.getAddress(data.address) === ethers.getAddress(message.address)}`); - logger.info(`[verifySignature] Signature address matches request address: ${ethers.getAddress(data.address) === normalizedAddress}`); } - logger.info(`[verifySignature] Signature: ${signature}`); if (!success) { logger.error(`[verifySignature] SIWE verification failed. Success: ${success}, Data: ${JSON.stringify(data)}`); return false; } - // КРИТИЧЕСКАЯ ПРОВЕРКА: Адрес из подписи должен совпадать с адресом в сообщении - if (data && message.address && ethers.getAddress(data.address) !== ethers.getAddress(message.address)) { - logger.error(`[verifySignature] КРИТИЧЕСКАЯ ОШИБКА: Адрес из подписи (${data.address}) не совпадает с адресом в сообщении (${message.address})!`); - return false; - } - - // Сравниваем нормализованные адреса: адрес из подписи должен совпадать с адресом из запроса + // Проверяем, что адрес из подписи совпадает с адресом из запроса + // (SiweMessage.verify() уже проверил соответствие подписи и сообщения) const addressesMatch = data && ethers.getAddress(data.address) === normalizedAddress; if (!addressesMatch) { logger.error(`[verifySignature] Адрес из подписи (${data.address}) не совпадает с адресом из запроса (${normalizedAddress})!`); diff --git a/frontend/src/services/wallet.js b/frontend/src/services/wallet.js index eb2bcd1..8673099 100644 --- a/frontend/src/services/wallet.js +++ b/frontend/src/services/wallet.js @@ -10,149 +10,19 @@ * GitHub: https://github.com/VC-HB3-Accelerator */ -import { ethers } from 'ethers'; -import axios from '../api/axios'; -import { SiweMessage } from 'siwe'; +// ВАЖНО: +// Здесь мы больше не дублируем SIWE-логику. +// Вся единая и отлаженная реализация находится в `src/utils/wallet.js` (connectWallet), +// а этот сервис просто проксирует вызов, чтобы компоненты могли по-прежнему +// использовать знакомый API `connectWithWallet`. +import { connectWallet } from '../utils/wallet'; + +/** + * Обёртка над `connectWallet` для совместимости со старыми импортами. + * Возвращает объект формата: + * { success: boolean, address?: string, userId?: number, error?: string } + */ export async function connectWithWallet() { - // console.log('Starting wallet connection...'); - - try { - // Проверяем наличие MetaMask - if (!window.ethereum) { - throw new Error('MetaMask not detected. Please install MetaMask.'); - } - - // console.log('MetaMask detected, requesting accounts...'); - - // Запрашиваем доступ к аккаунтам - const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' }); - - // console.log('Got accounts:', accounts); - - if (!accounts || accounts.length === 0) { - throw new Error('No accounts found. Please unlock MetaMask.'); - } - - // Берем первый аккаунт - const address = ethers.getAddress(accounts[0]); - // console.log('Normalized address:', address); - - // Запрашиваем nonce с сервера - // console.log('Requesting nonce...'); - const nonceResponse = await axios.get(`/auth/nonce?address=${address}`); - const nonce = nonceResponse.data.nonce; - // console.log('Got nonce:', nonce); - - if (!nonce) { - throw new Error('Не удалось получить nonce с сервера'); - } - - // Получаем список документов для подписания - let resources = [`${window.location.origin}/api/auth/verify`]; - try { - const docsResponse = await axios.get('/consent/documents'); - if (docsResponse.data && docsResponse.data.length > 0) { - docsResponse.data.forEach(doc => { - resources.push(`${window.location.origin}/content/published/${doc.id}`); - }); - } - } catch (error) { - // Если не удалось получить документы, продолжаем без них - console.warn('Не удалось получить список документов для подписания:', error); - } - - // Создаем сообщение для подписи - const domain = window.location.host; - const origin = window.location.origin; - const statement = 'Sign in with Ethereum to the app.\n\nПодписывая это сообщение, вы подтверждаете ознакомление с документами, указанными в Resources, и согласие на обработку персональных данных.'; - - const issuedAt = new Date().toISOString(); - - // Создаем копию resources и сортируем (не мутируем исходный массив) - const sortedResources = [...resources].sort(); - - const siweMessage = new SiweMessage({ - domain, - address, - statement, - uri: origin, - version: '1', - chainId: 1, - nonce, - issuedAt, - resources: sortedResources, - }); - - const message = siweMessage.prepareMessage(); - // console.log('SIWE message:', message); - // console.log('SIWE message details:', { - // domain, - // address, - // statement, - // uri: origin, - // version: '1', - // chainId: 1, - // nonce, - // issuedAt, - // resources: [`${origin}/auth/verify`], - // }); - - // Запрашиваем подпись - // console.log('Requesting signature...'); - const signature = await window.ethereum.request({ - method: 'personal_sign', - params: [message, address.toLowerCase()], - }); - - // console.log('Got signature:', signature); - - // Отправляем подпись на сервер для верификации - // console.log('Sending verification request...'); - const verificationResponse = await axios.post('/auth/verify', { - signature, - address, - nonce, - issuedAt, - }); - - // console.log('Verification response:', verificationResponse.data); - - // Обновляем состояние аутентификации - if (verificationResponse.data.success) { - // Обновляем состояние аутентификации в localStorage - localStorage.setItem('isAuthenticated', 'true'); - localStorage.setItem('userId', verificationResponse.data.userId); - localStorage.setItem('address', verificationResponse.data.address); - } - - return verificationResponse.data; - } catch (error) { - // console.error('Error connecting wallet:', error); - - // Улучшенная обработка ошибок MetaMask - let errorMessage = 'Произошла ошибка при подключении кошелька.'; - - if (error.message && error.message.includes('MetaMask extension not found')) { - errorMessage = 'Расширение MetaMask не найдено. Пожалуйста, установите MetaMask и обновите страницу.'; - } else if (error.message && error.message.includes('Failed to connect to MetaMask')) { - errorMessage = 'Не удалось подключиться к MetaMask. Проверьте, что расширение установлено и активно.'; - } else if (error.code === 4001) { - errorMessage = 'Вы отклонили запрос на подключение в MetaMask.'; - } else if (error.message && error.message.includes('No accounts found')) { - errorMessage = 'Аккаунты не найдены. Пожалуйста, разблокируйте MetaMask и попробуйте снова.'; - } else if (error.message && error.message.includes('MetaMask not detected')) { - errorMessage = 'MetaMask не обнаружен. Пожалуйста, установите расширение MetaMask.'; - } else if (error.response && error.response.data && error.response.data.error) { - errorMessage = error.response.data.error; - } else if (error.message) { - errorMessage = error.message; - } - - // Возвращаем объект с ошибкой вместо выброса исключения - return { - success: false, - error: errorMessage - }; - } + return await connectWallet(); } diff --git a/frontend/src/utils/wallet.js b/frontend/src/utils/wallet.js index 962ce9b..d9fff1c 100644 --- a/frontend/src/utils/wallet.js +++ b/frontend/src/utils/wallet.js @@ -10,30 +10,85 @@ * GitHub: https://github.com/VC-HB3-Accelerator */ -import axios from 'axios'; +// ВАЖНО: используем общий axios-инстанс с baseURL `/api`, +// чтобы все запросы шли через один и тот же API-слой +import api from '../api/axios'; import { ethers } from 'ethers'; import { SiweMessage } from 'siwe'; +/** + * Нормализует Ethereum адрес + */ +const normalizeAddress = (address) => { + return ethers.getAddress ? ethers.getAddress(address) : ethers.utils.getAddress(address); +}; + +/** + * Получает актуальный адрес из кошелька + * ВАЖНО: используем ethereum.selectedAddress, т.к. некоторые кошельки + * могут подписывать сообщением активным аккаунтом, игнорируя список eth_accounts. + * Если selectedAddress недоступен, падаем обратно на eth_accounts. + */ +const getCurrentAddress = async () => { + if (!window.ethereum) { + return null; + } + + let rawAddress = null; + + // 1. Пробуем взять текущий активный аккаунт + if (window.ethereum.selectedAddress) { + rawAddress = window.ethereum.selectedAddress; + } else { + // 2. Фоллбек на eth_accounts + const accounts = await window.ethereum.request({ method: 'eth_accounts' }); + if (!accounts || accounts.length === 0) { + return null; + } + rawAddress = accounts[0]; + } + + return normalizeAddress(rawAddress); +}; + +/** + * Формирует domain для SIWE сообщения (должен совпадать с бэкендом) + */ +const getDomain = () => { + let domain = window.location.host; + // Если порта нет, добавляем его для localhost или IP адресов + if (!domain.includes(':')) { + if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { + domain = `${window.location.hostname}:${window.location.port || '9000'}`; + } else if (/^\d+\.\d+\.\d+\.\d+$/.test(window.location.hostname)) { + domain = `${window.location.hostname}:${window.location.port || '9000'}`; + } + } + return domain; +}; + +/** + * Формирует origin для SIWE сообщения (должен совпадать с бэкендом) + * window.location.origin может не включать порт, поэтому формируем явно + */ +const getOrigin = () => { + const protocol = window.location.protocol; // Уже содержит ':' (например, 'http:') + const domain = getDomain(); // Используем domain, который уже содержит порт + return `${protocol}//${domain}`; // Двойной слеш после протокола (http:// или https://) +}; + export const connectWallet = async () => { try { - // console.log('Starting wallet connection...'); - - // Проверяем наличие MetaMask или другого Ethereum провайдера + // Проверяем наличие MetaMask if (!window.ethereum) { - // console.error('No Ethereum provider (like MetaMask) detected!'); return { success: false, - error: - 'Не найден кошелек MetaMask или другой Ethereum провайдер. Пожалуйста, установите расширение MetaMask.', + error: 'Не найден кошелек MetaMask или другой Ethereum провайдер. Пожалуйста, установите расширение MetaMask.', }; } - // console.log('MetaMask detected, requesting accounts...'); - // Запрашиваем доступ к аккаунтам const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' }); - // console.log('Got accounts:', accounts); - if (!accounts || accounts.length === 0) { return { success: false, @@ -41,36 +96,43 @@ export const connectWallet = async () => { }; } - // Берем первый аккаунт в списке - const address = accounts[0]; - // Нормализуем адрес (используем getAddress для совместимости) - // Проверяем версию ethers - если v6, используем ethers.getAddress, иначе ethers.utils.getAddress - const normalizedAddress = ethers.getAddress ? ethers.getAddress(address) : ethers.utils.getAddress(address); - // console.log('Normalized address:', normalizedAddress); - - // ВАЖНО: Получаем актуальный адрес ПЕРЕД запросом nonce, чтобы избежать проблем с переключением кошелька - const currentAccounts = await window.ethereum.request({ method: 'eth_accounts' }); - if (!currentAccounts || currentAccounts.length === 0) { + // КРИТИЧЕСКИ ВАЖНО: Получаем актуальный адрес ОДИН РАЗ и используем его везде + // Это гарантирует, что весь процесс (nonce, сообщение, подпись) использует один и тот же адрес + const walletAddress = await getCurrentAddress(); + if (!walletAddress) { return { success: false, error: 'Кошелек не подключен. Пожалуйста, подключите кошелек и попробуйте снова.', }; } - - // Используем актуальный адрес из кошелька - const currentAddress = ethers.getAddress ? ethers.getAddress(currentAccounts[0]) : ethers.utils.getAddress(currentAccounts[0]); - - // Проверяем, что адрес совпадает с изначальным - if (ethers.getAddress(currentAddress) !== ethers.getAddress(normalizedAddress)) { - console.warn('⚠️ [Frontend] Адрес кошелька изменился с момента подключения! Используем актуальный адрес:', currentAddress); + + // Формируем domain и origin (должны совпадать с бэкендом) + const domain = getDomain(); + const origin = getOrigin(); + + // Получаем список документов для подписания + // ВАЖНО: Пути должны точно совпадать с бэкендом для успешной верификации SIWE + let resources = [`${origin}/api/auth/verify`]; + // Добавляем общую ссылку на страницу опубликованных документов + resources.push(`${origin}/content/published`); + try { + const docsResponse = await api.get('/consent/documents'); + if (docsResponse.data && docsResponse.data.length > 0) { + docsResponse.data.forEach(doc => { + // Используем тот же путь, что и на бэкенде: /content/published/${doc.id} + resources.push(`${origin}/content/published/${doc.id}`); + }); + } + } catch (error) { + console.warn('Не удалось получить список документов для подписания:', error); } + const issuedAt = new Date().toISOString(); + const sortedResources = [...resources].sort(); - // Запрашиваем nonce с сервера для АКТУАЛЬНОГО адреса - // console.log('Requesting nonce...'); - const nonceResponse = await axios.get(`/auth/nonce?address=${currentAddress}`); + // Запрашиваем nonce для адреса кошелька + // ВАЖНО: Бэкенд сохраняет nonce для address.toLowerCase(), поэтому отправляем адрес в нижнем регистре + const nonceResponse = await api.get(`/auth/nonce?address=${walletAddress.toLowerCase()}`); const nonce = nonceResponse.data.nonce; - // console.log('Got nonce:', nonce); - if (!nonce) { return { success: false, @@ -78,108 +140,87 @@ export const connectWallet = async () => { }; } - // Для SIWE используем personal_sign напрямую через window.ethereum - // Не используем ethers signer, так как он добавляет префикс, который нарушает SIWE формат - - // Получаем список документов для подписания - let resources = [`${window.location.origin}/api/auth/verify`]; - try { - const docsResponse = await axios.get('/consent/documents'); - if (docsResponse.data && docsResponse.data.length > 0) { - docsResponse.data.forEach(doc => { - resources.push(`${window.location.origin}/public/page/${doc.id}`); - }); - } - } catch (error) { - // Если не удалось получить документы, продолжаем без них - console.warn('Не удалось получить список документов для подписания:', error); - } - - // Создаем сообщение для подписи - // Важно: domain должен быть hostname без протокола и порта (если порт стандартный) - const domain = window.location.hostname === 'localhost' ? - `localhost:${window.location.port}` : - window.location.hostname; - const origin = window.location.origin; - - // Создаем issuedAt один раз, чтобы использовать одинаковый в сообщении и запросе - const issuedAt = new Date().toISOString(); - - // Создаем копию resources и сортируем (не мутируем исходный массив) - const sortedResources = [...resources].sort(); - - // КРИТИЧЕСКАЯ ПРОВЕРКА: Получаем актуальный адрес ПЕРЕД созданием сообщения - const accountsBeforeMessage = await window.ethereum.request({ method: 'eth_accounts' }); - if (!accountsBeforeMessage || accountsBeforeMessage.length === 0) { + // КРИТИЧЕСКАЯ ПРОВЕРКА: Убеждаемся, что адрес не изменился перед подписанием + // personal_sign всегда использует текущий активный аккаунт, поэтому адрес в сообщении + // должен совпадать с адресом, который будет подписывать + const addressBeforeSign = await getCurrentAddress(); + if (!addressBeforeSign) { return { success: false, - error: 'Кошелек отключен. Пожалуйста, подключите кошелек и попробуйте снова.', + error: 'Не удалось получить адрес кошелька. Пожалуйста, попробуйте снова.', }; } - - const addressForMessage = ethers.getAddress ? ethers.getAddress(accountsBeforeMessage[0]) : ethers.utils.getAddress(accountsBeforeMessage[0]); + + // Нормализуем адрес для использования в сообщении + // ВАЖНО: SiweMessage может нормализовать адрес, поэтому нормализуем его заранее + const normalizedAddressForMessage = normalizeAddress(addressBeforeSign); // Проверяем, что адрес не изменился - if (ethers.getAddress(addressForMessage) !== ethers.getAddress(currentAddress)) { - console.warn('⚠️ [Frontend] Адрес изменился перед созданием сообщения! Используем актуальный адрес:', addressForMessage); + const normalizedWalletAddress = normalizeAddress(walletAddress); + if (normalizedAddressForMessage !== normalizedWalletAddress) { + console.error('❌ [Frontend] Адрес изменился перед подписанием!'); + console.error(' Ожидался (нормализован):', normalizedWalletAddress); + console.error(' Получен (нормализован):', normalizedAddressForMessage); + return { + success: false, + error: 'Адрес кошелька изменился. Пожалуйста, попробуйте снова.', + }; } - - // Создаем SIWE сообщение с документами в resources, используя АКТУАЛЬНЫЙ адрес + + // Создаем SIWE сообщение с нормализованным адресом + // ВАЖНО: адрес в сообщении должен совпадать с адресом, который подписывает const message = new SiweMessage({ domain, - address: addressForMessage, // Используем актуальный адрес из кошелька ПЕРЕД созданием сообщения + address: normalizedAddressForMessage, // Используем нормализованный адрес statement: 'Sign in with Ethereum to the app.\n\nПодписывая это сообщение, вы подтверждаете ознакомление с документами, указанными в Resources, и согласие на обработку персональных данных.', uri: origin, version: '1', - chainId: 1, // Ethereum mainnet + chainId: 1, nonce: nonce, issuedAt: issuedAt, resources: sortedResources, }); - - // Получаем строку сообщения для подписи + const messageToSign = message.prepareMessage(); + + // КРИТИЧЕСКАЯ ПРОВЕРКА: Убеждаемся, что адрес в сообщении совпадает с адресом, который будет подписывать + // SiweMessage может нормализовать адрес, поэтому проверяем после создания сообщения + const messageAddress = message.address; + const normalizedMessageAddress = normalizeAddress(messageAddress); + const normalizedSignAddress = normalizeAddress(addressBeforeSign); - // КРИТИЧЕСКАЯ ПРОВЕРКА: Получаем актуальный адрес ПЕРЕД подписанием - const accountsBeforeSign = await window.ethereum.request({ method: 'eth_accounts' }); - if (!accountsBeforeSign || accountsBeforeSign.length === 0) { + if (normalizedMessageAddress !== normalizedSignAddress) { + console.error('❌ [Frontend] КРИТИЧЕСКАЯ ОШИБКА: Адрес в сообщении не совпадает с адресом для подписи!'); + console.error(' Адрес в сообщении (исходный):', messageAddress); + console.error(' Адрес в сообщении (нормализован):', normalizedMessageAddress); + console.error(' Адрес для подписи (исходный):', addressBeforeSign); + console.error(' Адрес для подписи (нормализован):', normalizedSignAddress); return { success: false, - error: 'Кошелек отключен перед подписанием. Пожалуйста, подключите кошелек и попробуйте снова.', + error: 'Несоответствие адресов в сообщении и подписи. Пожалуйста, попробуйте снова.', }; } - - const addressForSign = ethers.getAddress ? ethers.getAddress(accountsBeforeSign[0]) : ethers.utils.getAddress(accountsBeforeSign[0]); - - // Проверяем, что адрес для подписи совпадает с адресом в сообщении - if (ethers.getAddress(addressForSign) !== ethers.getAddress(addressForMessage)) { - console.error('❌ [Frontend] КРИТИЧЕСКАЯ ОШИБКА: Адрес для подписи не совпадает с адресом в сообщении!'); - console.error(' Адрес в сообщении:', addressForMessage); - console.error(' Адрес для подписи:', addressForSign); - return { - success: false, - error: 'Адрес кошелька изменился перед подписанием. Пожалуйста, попробуйте снова.', - }; - } - + // Логируем для отладки console.log('🔐 [Frontend] Domain:', domain); console.log('🔐 [Frontend] Origin:', origin); - console.log('🔐 [Frontend] Address:', currentAddress); + console.log('🔐 [Frontend] Address in message (original):', messageAddress); + console.log('🔐 [Frontend] Address in message (normalized):', normalizedMessageAddress); + console.log('🔐 [Frontend] Address for sign (original):', addressBeforeSign); + console.log('🔐 [Frontend] Address for sign (normalized):', normalizedSignAddress); + console.log('🔐 [Frontend] Addresses match (normalized):', normalizedMessageAddress === normalizedSignAddress); console.log('🔐 [Frontend] Nonce:', nonce); console.log('🔐 [Frontend] IssuedAt:', issuedAt); console.log('🔐 [Frontend] Resources:', JSON.stringify(sortedResources)); console.log('🔐 [Frontend] SIWE message to sign:', messageToSign); - console.log('🔐 [Frontend] Message length:', messageToSign.length); - // Запрашиваем подпись через personal_sign (правильный способ для SIWE) - // personal_sign подписывает сообщение С префиксом "\x19Ethereum Signed Message:\n" - // ethers.verifyMessage() также добавляет этот префикс, поэтому они совместимы - // Параметры: [message, address] - MetaMask принимает строку напрямую - // ВАЖНО: Используем addressForSign, чтобы подпись была от актуального кошелька + // Запрашиваем подпись + // ВАЖНО: personal_sign может игнорировать второй параметр (адрес) и использовать текущий активный аккаунт + // Поэтому мы должны убедиться, что адрес в сообщении совпадает с адресом, который будет подписывать + // Используем нормализованный адрес для подписи const signature = await window.ethereum.request({ method: 'personal_sign', - params: [messageToSign, addressForSign.toLowerCase()], + params: [messageToSign, normalizedMessageAddress.toLowerCase()], // Используем нормализованный адрес из сообщения }); if (!signature) { @@ -189,80 +230,38 @@ export const connectWallet = async () => { }; } - // console.log('Got signature:', signature); - - // КРИТИЧЕСКАЯ ПРОВЕРКА: Убеждаемся, что адрес не изменился после подписи - const finalAccounts = await window.ethereum.request({ method: 'eth_accounts' }); - if (!finalAccounts || finalAccounts.length === 0) { - return { - success: false, - error: 'Кошелек отключен. Пожалуйста, подключите кошелек и попробуйте снова.', - }; - } - - const finalAddress = ethers.getAddress ? ethers.getAddress(finalAccounts[0]) : ethers.utils.getAddress(finalAccounts[0]); - - // Проверяем, что адрес совпадает с тем, который использовался для подписи - if (ethers.getAddress(finalAddress) !== ethers.getAddress(addressForSign)) { - console.error('❌ [Frontend] КРИТИЧЕСКАЯ ОШИБКА: Адрес кошелька изменился после подписи!'); - console.error(' Адрес при подписи:', addressForSign); - console.error(' Текущий адрес:', finalAddress); - return { - success: false, - error: 'Адрес кошелька изменился после подписи. Пожалуйста, попробуйте снова.', - }; - } - - // Проверяем, что адрес в сообщении совпадает с адресом, который подписывает - const messageAddress = message.address; - if (ethers.getAddress(messageAddress) !== ethers.getAddress(addressForSign)) { - console.error('❌ [Frontend] КРИТИЧЕСКАЯ ОШИБКА: Адрес в сообщении не совпадает с адресом подписи!'); - console.error(' Адрес в сообщении:', messageAddress); - console.error(' Адрес подписи:', addressForSign); - return { - success: false, - error: 'Несоответствие адресов в сообщении и подписи. Пожалуйста, попробуйте снова.', - }; - } - // Отправляем верификацию на сервер - // console.log('Sending verification request...'); + // Используем нормализованный адрес из сообщения (должен совпадать с адресом, который подписал) const requestData = { - address: addressForSign, // Используем адрес, который подписал сообщение + address: normalizedMessageAddress, // Нормализованный адрес из сообщения (должен совпадать с адресом подписи) signature, nonce, - issuedAt: issuedAt, // Используем тот же issuedAt, что и в сообщении + issuedAt: issuedAt, }; - // console.log('Request data:', requestData); - const verifyResponse = await axios.post('/auth/verify', requestData, { + const verifyResponse = await api.post('/auth/verify', requestData, { withCredentials: true, }); - // Обновляем интерфейс для отображения подключенного состояния + // Обновляем интерфейс document.body.classList.add('wallet-connected'); - // Обновляем отображение адреса кошелька в UI const authDisplayEl = document.getElementById('auth-display'); if (authDisplayEl) { - const shortAddress = `${normalizedAddress.substring(0, 6)}...${normalizedAddress.substring(normalizedAddress.length - 4)}`; + const shortAddress = `${walletAddress.substring(0, 6)}...${walletAddress.substring(walletAddress.length - 4)}`; authDisplayEl.innerHTML = `Кошелек: ${shortAddress}`; authDisplayEl.style.display = 'inline-block'; } - // Скрываем кнопки авторизации и показываем кнопку выхода const authButtonsEl = document.getElementById('auth-buttons'); const logoutButtonEl = document.getElementById('logout-button'); - if (authButtonsEl) authButtonsEl.style.display = 'none'; if (logoutButtonEl) logoutButtonEl.style.display = 'inline-block'; - // console.log('Verification response:', verifyResponse.data); - if (verifyResponse.data.success) { return { success: true, - address: normalizedAddress, + address: walletAddress, userId: verifyResponse.data.userId, }; } else { @@ -272,9 +271,6 @@ export const connectWallet = async () => { }; } } catch (error) { - // console.error('Error connecting wallet:', error); - - // Формируем понятное сообщение об ошибке let errorMessage = 'Произошла ошибка при подключении кошелька.'; if (error.message && error.message.includes('MetaMask extension not found')) {