diff --git a/README.md b/README.md index d3b31b6..f12ad9f 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,46 @@ composer require apoloz/alfa-bank-sdk ``` +## Подготовка сертификатов (mTLS) + +Взаимодействие с API Альфа-Банка требует двухстороннего TLS-соединения. Банк не выдает закрытый ключ, вы должны сгенерировать его самостоятельно. + +**1. Генерация закрытого ключа и запроса (CSR):** +Используйте OpenSSL для создания ключа и файла запроса. Создайте файл `openssl.cnf`: + +```ini +[req] +distinguished_name = req_distinguished_name +prompt = no +[req_distinguished_name] +C = RU +L = Moscow +O = Название Вашей Компании +CN = ФИО Ответственного +emailAddress = your_email@company.com +1.2.643.100.4 = 1234567890 (Ваш ИНН) +``` + +Выполните команды в терминале: + +```bash +# Генерация закрытого ключа (храните его в секрете!) +openssl genrsa -out private.key 2048 + +# Генерация запроса на сертификат +openssl req -new -key private.key -out request.csr -config openssl.cnf -utf8 +``` + +**2. Отправка в Банк:** +Отправьте файл `request.csr` на почту `alfa_api@alfabank.ru`. В ответ банк пришлет публичный сертификат (`client.cer` или `client.crt`). + +**3. Использование:** +Поместите полученный сертификат и ваш закрытый ключ в надежное место и укажите пути к ним в `.env` (переменные `ALFA_BAAS_CERT_PATH` и `ALFA_BAAS_KEY_PATH`). + ## Конфигурация (Laravel) Опубликуйте файл конфигурации: + ```bash php artisan vendor:publish --tag=alfa-bank-config ``` @@ -86,16 +123,19 @@ SDK поддерживает две разные модели безопасно ### 1. Подключение к СБП (Pay-in) **Шаги для получения доступа:** + 1. Генерируете пару ключей: `openssl req -x509 -newkey rsa:2048 -keyout client.key -out client.crt -nodes -days 3650`. 2. Отправляете `client.crt` менеджеру банка (acquiring@alfabank.ru). 3. Получаете от банка `alias` (key-name) и `TermNo`. **Поток данных и подпись:** -* **Transport**: Обязательный **MTLS**. Вы должны передать `client.crt` и `client.key` в конфигурацию Guzzle/Saloon. -* **Payload**: Тело запроса канонизируется по алгоритму **JCS**. -* **Signature**: SHA256withRSA подпись JCS-строки помещается в заголовок `Authorization`. + +- **Transport**: Обязательный **MTLS**. Вы должны передать `client.crt` и `client.key` в конфигурацию Guzzle/Saloon. +- **Payload**: Тело запроса канонизируется по алгоритму **JCS**. +- **Signature**: SHA256withRSA подпись JCS-строки помещается в заголовок `Authorization`. **SDK делает это автоматически**: + ```php // В config/alfa-bank.php 'sbp' => [ @@ -110,15 +150,18 @@ SDK поддерживает две разные модели безопасно ### 2. Подключение к BaaS (Номинальные счета) **Шаги для получения доступа:** + 1. Регистрация в личном кабинете партнера. 2. Получение `client_id` и `client_secret` для OAuth2. 3. Регистрация публичного ключа для проверки подписей выплат. **Поток данных и подпись:** -* **Auth**: Стандартный OAuth2 Bearer Token. -* **Payload Signature**: Для критических операций (выплаты, идентификация) требуется `digestSignatures`. Это массив CMS-подписей (PKCS7) в формате Base64, которыми подписываются финансовые поля. + +- **Auth**: Стандартный OAuth2 Bearer Token. +- **Payload Signature**: Для критических операций (выплаты, идентификация) требуется `digestSignatures`. Это массив CMS-подписей (PKCS7) в формате Base64, которыми подписываются финансовые поля. **Пример формирования CMS в SDK:** + ```php use Apoloz\AlfaBank\Utils\CmsSigner; @@ -128,11 +171,59 @@ $signature = CmsSigner::sign($payload, $privateKey, $certificate); // $signature помещается в массив digestSignatures ``` +### 3. Авторизация Authorization Code Flow (H2H) + +Для упрощения работы с OAuth2 (H2H) в SDK добавлен специальный helper-сервис `H2HAuthService`. + +**1. Инициализация сервиса:** + +```php +$auth = AlfaBank::auth( + clientId: '...', + clientSecret: '...', + redirectUri: 'https://...', + certificatePath: '...', + privateKeyPath: '...' +); +``` + +**2. Получение ссылки на вход:** + +```php +$url = $auth->getAuthUrl(['beneficiaries']); +// Перенаправьте пользователя на $url +``` + +**3. Обмен кода на токен:** + +```php +$tokenData = $auth->exchangeCode($code); +// Сохраните $tokenData->refresh_token в БД +``` + +**4. Обновление токена:** + +```php +$tokenData = $auth->refreshToken($savedRefreshToken); +``` + +Полный пример: [examples/13_h2h_auth_service.php](examples/13_h2h_auth_service.php) + +### 4. Генерация Client Secret (Утилита) + +Если банк выдал только сертификаты, вы можете сгенерировать `client_secret` самостоятельно: + +```php +// См. examples/14_generate_client_secret.php +$baas->send(new RefreshClientSecretRequest($clientId)); +``` + --- ## Архитектура и Потоки данных ### Гибридный поток: СБП -> Номинальный счет + ```mermaid sequenceDiagram participant Client as Покупатель @@ -147,7 +238,7 @@ sequenceDiagram Note over API, BaaS: Webhook with Signature API->>BaaS: 4. Webhook (Входящий платеж) - + Note over BaaS, API: OAuth2 + CMS Signature BaaS->>API: 5. IdentifyTransactionRequest(beneficiaryId) API-->>BaaS: 6. 200 OK (Зачислено) @@ -158,6 +249,7 @@ sequenceDiagram ## Примеры использования ### 1. СБП: Прием платежа на номинальный счет + ```php use Apoloz\AlfaBank\Laravel\Facades\Sbp; use Apoloz\AlfaBank\Sbp\Requests\GetQrCodeRequest; @@ -175,6 +267,7 @@ echo "QR URL: " . $response->dto()->payload; ``` ### 2. Идентификация платежа (BaaS) + Когда платеж пришел на номинальный счет, его нужно привязать к бенефициару: ```php @@ -195,9 +288,11 @@ AlfaBank::send(new IdentifyTransactionRequest($data)); ``` ### 3. Работа с номинальными счетами (Бенефициары) + ... (existing content) ... ### 4. Самозанятые (Регистрация доходов) + ```php use Apoloz\AlfaBank\Requests\SelfEmployed\RegisterIncomeRequest; use Apoloz\AlfaBank\Data\SelfEmployed\CreateIncomeRequestData; @@ -213,7 +308,9 @@ $response = AlfaBank::send(new RegisterIncomeRequest($data)); ``` ### 5. Файловый обмен (KYC) + Загрузка сканов документов бенефициаров через pre-signed ссылки: + ```php use Apoloz\AlfaBank\Requests\Files\UploadFileRequest; @@ -228,7 +325,9 @@ AlfaBank::send(new UploadFileRequest($uploadUrl, $file)); ## Продвинутые возможности ### Управление возвратами СБП + Просмотр всей истории попыток возврата по транзакции: + ```php $response = Sbp::send(new GetQrcReversalHistoryRequest($payrrn, $termNo)); foreach ($response->dto()->refunds as $refund) { @@ -237,6 +336,7 @@ foreach ($response->dto()->refunds as $refund) { ``` ### Webhook Handling (Laravel) + ```php // Обработка СБП уведомлений в Listener public function handle(AlfaWebhookReceived $event) { diff --git a/examples/01_baas_flow.php b/examples/01_baas_flow.php index ecf884b..278856d 100644 --- a/examples/01_baas_flow.php +++ b/examples/01_baas_flow.php @@ -16,10 +16,26 @@ $baas = AlfaBank::baas( try { echo "Authenticating...\n"; - $baas->login( + + // METHOD A: Standard Client Credentials (for simple services) + // $baas->login( + // clientId: 'your-client-id', + // clientSecret: 'your-client-secret' + // ); + + // METHOD B: Authorization Code Flow (H2H) (for Nominal Accounts) + // See examples/13_h2h_auth_service.php for the full setup + $auth = \Apoloz\AlfaBank\AlfaBank::auth( clientId: 'your-client-id', - clientSecret: 'your-client-secret' + clientSecret: 'your-client-secret', + redirectUri: 'https://...', + certificatePath: __DIR__ . '/certs/client.cer', + privateKeyPath: __DIR__ . '/certs/private.key', + environment: Environment::SANDBOX ); + // Assuming you have a saved refresh token: + // $auth->refreshToken('saved_refresh_token'); + echo "Authentication Successful!\n"; // 2. Create a Beneficiary (Counterparty) diff --git a/examples/04_nominal_accounts.php b/examples/04_nominal_accounts.php index 01d28a1..dcf9f3d 100644 --- a/examples/04_nominal_accounts.php +++ b/examples/04_nominal_accounts.php @@ -43,7 +43,14 @@ $payload = new CreateBeneficiaryRequestData( ); // 2. Login and send request -$baas->login('client-id', 'client-secret'); +// For Nominal Accounts, use H2H Auth (Refresh Token) +// See examples/13_h2h_auth_service.php +$auth = AlfaBank::auth( + 'client-id', 'client-secret', 'https://callback', + 'path/to/cert.pem', 'path/to/key.pem', Environment::SANDBOX +); +$auth->refreshToken('saved-refresh-token'); // Authenticates the connector + $response = $baas->send(new CreateBeneficiaryRequest($payload)); if ($response->successful()) { diff --git a/examples/08_nominal_accounts_full_lifecycle.php b/examples/08_nominal_accounts_full_lifecycle.php index 9fd751e..b79df2c 100644 --- a/examples/08_nominal_accounts_full_lifecycle.php +++ b/examples/08_nominal_accounts_full_lifecycle.php @@ -33,7 +33,12 @@ $signCert = file_get_contents('signing.crt'); $signKey = file_get_contents('signing.key'); try { - $baas->login('client_id', 'client_secret'); + // Auth: Nominal Accounts require H2H Flow + $auth = AlfaBank::auth( + 'client_id', 'client_secret', 'https://callback', + 'transport.crt', 'transport.key' + ); + $auth->refreshToken('saved_refresh_token_from_db'); // --- ЭТАП 1: Создание Бенефициара (Контрагента) --- echo "1. Регистрация бенефициара...\n"; diff --git a/examples/12_auth_code_flow.php b/examples/12_auth_code_flow.php new file mode 100644 index 0000000..2f5439b --- /dev/null +++ b/examples/12_auth_code_flow.php @@ -0,0 +1,80 @@ + __DIR__ . '/certs/client.cer', + 'key' => __DIR__ . '/certs/private.key', + 'client_id' => getenv('ALFA_BAAS_CLIENT_ID'), + 'client_secret' => getenv('ALFA_BAAS_CLIENT_SECRET'), + 'redirect_uri' => 'https://your-app.com/callback', // Тот же, что указывали при получении кода + 'account_number' => '40702810...' +]; + +// 2. Инициализация коннектора +$baas = AlfaBank::baas( + certificatePath: $config['cert'], + privateKeyPath: $config['key'], + environment: Environment::SANDBOX +); +// ВАЖНО: Мы пока НЕ вызываем login()! + +try { + // --- СЦЕНАРИЙ А: Первый вход (у нас есть только код из браузера) --- + // Ссылка для получения кода: https://baas.alfabank.ru/oidc/authorize?response_type=code&client_id=... + + $authCode = 'code_from_browser_url'; // <-- Вставьте сюда код + + if ($authCode) { + echo "Обмениваем код на токены...\n"; + $tokenData = $baas->authWithCode($authCode, $config['redirect_uri']); + + echo "Access Token получен!\n"; + echo "Refresh Token: " . $tokenData->refresh_token . "\n"; + echo "(Сохраните refresh_token в базу данных)\n\n"; + } + + // --- СЦЕНАРИЙ Б: Повторный вход (у нас есть сохраненный refresh_token) --- + $savedRefreshToken = 'your_saved_refresh_token_from_db'; + + if ($savedRefreshToken) { + echo "Обновляем сессию по Refresh Token...\n"; + $tokenData = $baas->refreshToken($savedRefreshToken); + + echo "Сессия обновлена. Новый Access Token действует " . $tokenData->expires_in . " сек.\n"; + if ($tokenData->refresh_token) { + echo "Пришел новый Refresh Token (сохраните его): " . $tokenData->refresh_token . "\n"; + } + } + + // 3. Выполняем защищенный запрос (например, список бенефициаров) + // Коннектор уже авторизован (внутри updateAccessToken/authWithCode вызывается authenticate()) + + echo "Запрашиваем список...\n"; + $response = $baas->send(new GetBeneficiariesRequest( + accountNumber: $config['account_number'], + type: 'FL_RESIDENT' + )); + + echo "Успешно! Найдено: " . count($response->dto()->beneficiaries) . "\n"; + +} catch (\Exception $e) { + echo "Ошибка: " . $e->getMessage() . "\n"; + if (method_exists($e, 'getResponse')) { + echo "Body: " . $e->getResponse()->body() . "\n"; + } +} + diff --git a/examples/13_h2h_auth_service.php b/examples/13_h2h_auth_service.php new file mode 100644 index 0000000..d61fc89 --- /dev/null +++ b/examples/13_h2h_auth_service.php @@ -0,0 +1,79 @@ +getAuthUrl(scopes: ['beneficiaries', 'payment']); +echo "1. Перейдите по ссылке для авторизации:\n$link\n\n"; + + +// --- СЦЕНАРИЙ 2: Обмен кода на токен (в callback-скрипте) --- +// $code приходит в GET-параметре: /callback?code=... +$codeFromUrl = '...код из браузера...'; + +if ($codeFromUrl !== '...код из браузера...') { + try { + echo "2. Обмен кода на токены...\n"; + $tokenData = $authService->exchangeCode($codeFromUrl); + + echo "Access Token: " . substr($tokenData->access_token, 0, 20) . "...\n"; + echo "Refresh Token: " . $tokenData->refresh_token . "\n"; + echo "Expires In: " . $tokenData->expires_in . " sec\n"; + + // TODO: Сохраните refresh_token в базу данных! + // DB::table('settings')->updateOrInsert(['key' => 'alfa_refresh_token'], ['value' => $tokenData->refresh_token]); + + } catch ( +Exception $e) { + echo "Ошибка обмена кода: " . $e->getMessage() . "\n"; + } +} + +// --- СЦЕНАРИЙ 3: Регулярное обновление токена (Cron / Middleware) --- +$savedRefreshToken = '...saved_refresh_token...'; + +if ($savedRefreshToken !== '...saved_refresh_token...') { + try { + echo "3. Обновление токена...\n"; + $newTokenData = $authService->refreshToken($savedRefreshToken); + + echo "Токен обновлен!\n"; + + // Если пришел новый refresh_token — обновляем его + if ($newTokenData->refresh_token) { + // DB::update(...) + echo "Новый Refresh Token получен.\n"; + } + } catch ( +Exception $e) { + echo "Ошибка обновления токена: " . $e->getMessage() . "\n"; + } +} + diff --git a/examples/14_generate_client_secret.php b/examples/14_generate_client_secret.php new file mode 100644 index 0000000..b8d742e --- /dev/null +++ b/examples/14_generate_client_secret.php @@ -0,0 +1,58 @@ +send($request); + + // 4. Output Result + $data = $response->dto(); // Returns ClientSecretData + + echo "\n-------------------------------------------------------\n"; + echo "SUCCESS! New Client Secret Generated:\n"; + echo "-------------------------------------------------------\n"; + echo "Client ID: " . $data->clientId . "\n"; + echo "Client Secret: " . $data->clientSecret . "\n"; + echo "-------------------------------------------------------\n"; + echo "IMPORTANT: Save this to your .env file immediately.\n"; + echo "It will not be shown again.\n"; + +} catch (\Exception $e) { + echo "\nERROR: Could not generate secret.\n"; + echo "Message: " . $e->getMessage() . "\n"; + if (method_exists($e, 'getResponse')) { + echo "Response: " . $e->getResponse()->body() . "\n"; + } +} diff --git a/src/AlfaBank.php b/src/AlfaBank.php index 48166d6..6cfcb78 100644 --- a/src/AlfaBank.php +++ b/src/AlfaBank.php @@ -11,10 +11,36 @@ use Apoloz\AlfaBank\Core\AlfaConfig; use Apoloz\AlfaBank\Enums\Environment; use Apoloz\AlfaBank\Services\OpenSslCmsSigner; use Apoloz\AlfaBank\Sbp\SbpConnector; +use Apoloz\AlfaBank\Services\H2HAuthService; use Apoloz\AlfaBank\Requests\Accounts\GetAccountsRequest; class AlfaBank { + /** + * Creates a helper service for H2H Authorization (Authorization Code Flow). + */ + public static function auth( + string $clientId, + string $clientSecret, + string $redirectUri, + string $certificatePath, + string $privateKeyPath, + Environment $environment = Environment::SANDBOX + ): H2HAuthService { + $config = new \Apoloz\AlfaBank\Core\AlfaConfig( + baseUrl: $environment->value, + certificatePath: $certificatePath, + privateKeyPath: $privateKeyPath, + clientId: $clientId, + clientSecret: $clientSecret + ); + + $signer = new \Apoloz\AlfaBank\Services\OpenSslCmsSigner($certificatePath, $privateKeyPath); + $connector = new \Apoloz\AlfaBank\Core\AlfaBankConnector($config, $signer); + + return new H2HAuthService($connector, $clientId, $redirectUri, $environment); + } + /** * Performs a health check on the connection. * Throws an exception if authentication or account listing fails. diff --git a/src/Core/AlfaBankConnector.php b/src/Core/AlfaBankConnector.php index 56136f1..7a63b73 100644 --- a/src/Core/AlfaBankConnector.php +++ b/src/Core/AlfaBankConnector.php @@ -79,6 +79,55 @@ class AlfaBankConnector extends Connector implements HasPagination return new AlfaOffsetPaginator($this, $request); } + /** + * Exchange Authorization Code for tokens (H2H Flow). + */ + public function authWithCode(string $code, string $redirectUri): \Apoloz\AlfaBank\Data\Auth\TokenData + { + if (!$this->alfaConfig->clientId || !$this->alfaConfig->clientSecret) { + throw new \RuntimeException("Client ID/Secret not configured."); + } + + $request = new GetTokenRequest( + clientId: $this->alfaConfig->clientId, + clientSecret: $this->alfaConfig->clientSecret, + code: $code, + redirectUri: $redirectUri, + grantType: 'authorization_code' + ); + + $response = $this->send($request); + $data = $response->dto(); + + $this->authenticate(new \Saloon\Http\Auth\TokenAuthenticator($data->access_token)); + + return $data; + } + + /** + * Refresh access token using refresh token. + */ + public function refreshToken(string $refreshToken): \Apoloz\AlfaBank\Data\Auth\TokenData + { + if (!$this->alfaConfig->clientId || !$this->alfaConfig->clientSecret) { + throw new \RuntimeException("Client ID/Secret not configured."); + } + + $request = new GetTokenRequest( + clientId: $this->alfaConfig->clientId, + clientSecret: $this->alfaConfig->clientSecret, + refreshToken: $refreshToken, + grantType: 'refresh_token' + ); + + $response = $this->send($request); + $data = $response->dto(); + + $this->authenticate(new \Saloon\Http\Auth\TokenAuthenticator($data->access_token)); + + return $data; + } + public function login(?string $clientId = null, ?string $clientSecret = null): static { // Allow overriding config via method arguments diff --git a/src/Requests/Auth/GetTokenRequest.php b/src/Requests/Auth/GetTokenRequest.php index 20a7159..614f316 100644 --- a/src/Requests/Auth/GetTokenRequest.php +++ b/src/Requests/Auth/GetTokenRequest.php @@ -42,6 +42,7 @@ class GetTokenRequest extends Request implements HasBody protected string $clientSecret, protected ?string $code = null, protected ?string $redirectUri = null, + protected ?string $refreshToken = null, protected string $grantType = 'authorization_code' ) {} // @@ -64,11 +65,15 @@ class GetTokenRequest extends Request implements HasBody 'client_secret' => $this->clientSecret, ]; - if($this->grantType === 'authorization_code') { + if ($this->grantType === 'authorization_code') { $body['code'] = $this->code; $body['redirect_uri'] = $this->redirectUri; } + if ($this->grantType === 'refresh_token') { + $body['refresh_token'] = $this->refreshToken; + } + return $body; } // diff --git a/src/Services/H2HAuthService.php b/src/Services/H2HAuthService.php new file mode 100644 index 0000000..ee20720 --- /dev/null +++ b/src/Services/H2HAuthService.php @@ -0,0 +1,61 @@ + $scopes List of scopes (e.g. ['beneficiaries', 'payment']) + * @param string|null $state Optional state parameter for CSRF protection + */ + public function getAuthUrl(array $scopes = ['beneficiaries'], ?string $state = null): string + { + $baseUrl = $this->environment === Environment::PRODUCTION + ? 'https://id.alfabank.ru/oidc/authorize' + : 'https://id-sandbox.alfabank.ru/oidc/authorize'; + + $params = [ + 'response_type' => 'code', + 'client_id' => $this->clientId, + 'redirect_uri' => $this->redirectUri, + 'scope' => implode(' ', $scopes), + ]; + + if ($state) { + $params['state'] = $state; + } + + return $baseUrl . '?' . http_build_query($params); + } + + /** + * Step 2: Exchange the received code for Access & Refresh Tokens. + */ + public function exchangeCode(string $code): TokenData + { + return $this->connector->authWithCode($code, $this->redirectUri); + } + + /** + * Step 3: Refresh the token using a stored refresh token. + */ + public function refreshToken(string $refreshToken): TokenData + { + return $this->connector->refreshToken($refreshToken); + } +}