Первая фиксация изменений

This commit is contained in:
RedHood 2020-10-24 18:38:21 +10:00
commit 11ae0eda43
11 changed files with 5913 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
cookie.txt
/vendor

22
LICENSE Normal file
View File

@ -0,0 +1,22 @@
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Это свободная программа: вы можете перераспространять ее и/или изменять
ее на условиях Стандартной общественной лицензии GNU в том виде, в каком
она была опубликована Фондом свободного программного обеспечения; либо
версии 3 лицензии, либо (по вашему выбору) любой более поздней версии.
Эта программа распространяется в надежде, что она будет полезной,
но БЕЗО ВСЯКИХ ГАРАНТИЙ; даже без неявной гарантии ТОВАРНОГО ВИДА
или ПРИГОДНОСТИ ДЛЯ ОПРЕДЕЛЕННЫХ ЦЕЛЕЙ. Подробнее см. в Стандартной
общественной лицензии GNU <https://www.gnu.org/licenses/>.

20
README.md Normal file
View File

@ -0,0 +1,20 @@
# Колпачный менеджер аккаунтов
Инициализирует аккаунты для их использования в колпачных фреймворках и библеотеках
### Установка:
```sh
$ composer install hood/accounts
```
### Пример использования:
```php
use hood\accounts\vk;
// Подключение библеотек
require_once './vendor/autoload.php';
// Инициализация пользователя ВКонтакте
$account = (new vk($id))->auth('login', 'password')->key($project_id);
// Вывести сгенерированный ключ
echo $account->key;
```

55
composer.json Normal file
View File

@ -0,0 +1,55 @@
{
"name": "hood/accounts",
"type": "library",
"description": "\u0424\u0440\u0435\u0439\u043c\u0432\u043e\u0440\u043a \u0434\u043b\u044f \u0440\u0430\u0431\u043e\u0442\u044b \u0441 VK API",
"keywords": [
"vk",
"hood",
"accounts"
],
"homepage": "https://git.hood.su/hood/accounts",
"license": "AGPL-3.0-or-later",
"authors": [
{
"name": "Arsen Mirzaev",
"email": "red@hood.su",
"homepage": "https://hood.su/sex",
"role": "Developer"
}
],
"support": {
"docs": "https://git.hood.su/hood/accounts/manual",
"issues": "https://git.hood.su/hood/accounts/issues",
"chat": "https://vk.com/cyberhybrid"
},
"require": {
"php": ">=7.4.0",
"ext-dom": "20031129",
"ext-libxml": "^7.4",
"guzzlehttp/guzzle": "^7.2"
},
"require-dev": {
"phpdocumentor/phpdocumentor": ">=2.9",
"phpunit/phpunit": "^9"
},
"autoload": {
"psr-4": {
"hood\\accounts\\": "hood/accounts/system"
}
},
"autoload-dev": {
"psr-4": {
"hood\\accounts\\tests\\": "hood/accounts/tests"
}
},
"funding": [
{
"type": "Hood",
"url": "https://git.hood.su/hood/accounts/thanks"
},
{
"type": "Evil Alliance",
"url": "https://vk.com/darkweb228"
}
]
}

4986
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace hood\accounts;
use GuzzleHttp\Client as browser;
/**
* Аккаунт
*
* @package hood\accounts
* @author Arsen Mirzaev Tatyano-Muradovich <red@hood.su>
*/
class account
{
/**
* @var browser $browser Браузер
*/
protected browser $browser;
/**
* @var bool $ssl SSL-протокол
*/
protected bool $ssl = true;
/**
* @var string $path Директория пользователя
*/
protected string $path;
/**
* Конструктор
*
* @param int $id Идентификатор
* @param float|null $version Версия API
*
* @return self
*/
public function __construct(int $id, float $version = null)
{
// Идентификатор
$this->id = $id;
if (isset($version)) {
// Версия
$this->version = $version;
}
// Сессия
$this->session = count(($core = core::init())->get($id));
// Регистрация в ядре
$core->set($id, $this);
}
/**
* Установка свойства
*
* @param mixed $name Название
* @param mixed $value Значение
*
* @return void
*/
public function __set($name, $value): void
{
if ($name === 'id') {
if (!isset($this->id)) {
$this->id = $value;
} else {
throw new Exception('Запрещено переопределять идентификатор');
}
} else if ($name === 'session') {
if (!isset($this->session)) {
$this->session = $value;
} else {
throw new Exception('Запрещено переопределять сессию');
}
$this->session = $value;
}
}
/**
* Чтение свойства
*
* @param mixed $name Название
*
* @return mixed
*/
public function __get($name)
{
if ($name === 'id') {
return $this->id;
} else if ($name === 'browser') {
return $this->browser;
}
}
/**
* Проверка инициализированности свойства
*
* @param mixed $name Название
*
* @return mixed
*/
public function __isset($name)
{
if ($name === 'id') {
return isset($this->id);
} else if ($name === 'session') {
return isset($this->session);
}
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace hood\accounts\auth;
/**
* Базовая авторизация
*
*
*
*
*
*
*
*
*
*
*
*/
interface basic
{
public function auth(string $login, string $password): self;
public function deauth(): self;
}

396
hood/accounts/system/vk.php Normal file
View File

@ -0,0 +1,396 @@
<?php
declare(strict_types=1);
namespace hood\accounts;
use hood\accounts\auth\basic;
use GuzzleHttp\Client as browser,
GuzzleHttp\Cookie\FileCookieJar,
GuzzleHttp\TransferStats;
use DOMDocument,
DOMXPath;
use Exception;
/**
* Попка
*
* @todo
* 1. Вернуть внутреннее хранение cookies, а выгрузку в файл сделать отдельным методом: "dump();".
* $this->cookies - строка cookie, $this->root_path - корневая директория (которая сейчас $this->path), $this->cookies_path - путь до файла хранящего cookies
* 2. Сделать возможность авторизации без входного и пароля, указав место хранения файла cookies
* 4. Добавить возможность авторизации через сторонний браузер, который более походит на настоящий (низкий приоритет)
* 5. Создать debug-режим в котором будут сохранены обрабатываемые html страницы и действия будут записываться по PSR-7 в журнал (низкий приоритет)
*/
final class vk extends account implements basic
{
/**
* @var int $id Идентификатор
*/
private int $id;
/**
* @var string $login Входной [псевдоним]
*/
private string $login;
/**
* @var string $password Пароль
*/
private string $password;
/**
* @var string $key Ключ доступа
*/
private string $key;
/**
* Конструктор
*
* @param int $id Идентификатор
* @param string|null $path Корневой каталог аккаунтов
*
* @return self
*/
public function __construct(int $id, string $path = null)
{
// Идентификатор
$this->id = $id;
// Инициализация директории пользователя
if (isset($path)) {
// Если передан путь и он существует
$this->path = $path . DIRECTORY_SEPARATOR . $id;
} else {
// Иначе путь по умолчанию
$this->path = __DIR__ . DIRECTORY_SEPARATOR . 'accounts' . DIRECTORY_SEPARATOR . $id;
}
// Проверка и создание директории
if (!file_exists($this->path)) {
mkdir($this->path, 0775, true);
}
// Инициализация браузера
$this->browser();
}
/**
* Аутентификация
*
* @param string $login Входной
* @param string $password Пароль
* @param int $mode Режим
*
* @return self
*
* @todo
* 1. Добавить проверку требования двухэтапной аутентификации
* 2. Добавить проверку требования ввода капчи
* 3. Добавить проверку неудачного ввода пароля
* 4. Добавить аутентификацию через версию для ПК
* 5. Добавить идентификацию капчи, решение капчи и тесты с капчей
*/
public function auth(string $login, string $password, int $mode = 0): self
{
if (isset($this->login, $this->password)) {
throw new Exception('Повторная аутентификация запрещена');
}
// Инициализация свойств
$this->login = $login;
$this->password = $password;
// Переход на страницу аутентификации и обработка формы
if ($mode === 0) {
// Если установлен режим мобильной версии (по умолчанию)
// Запрос страницы с аутентификацией
$response = $this->browser->request('GET', 'https://m.vk.com');
// Проверка
$body = $this->check((string) $response->getBody());
if ($response->getStatusCode() === 200) {
// Инициализация DOM
$dom = new DOMDocument;
@$dom->loadHTML($body);
// Ссылка для отправки формы (аутентификация)
$action = $dom->getElementsByTagName('form')[0]->getAttribute('action');
} else {
throw new Exception('Не удалось получить страницу аутентификации: ' . $response->getReasonPhrase(), $response->getStatusCode());
}
} else if ($mode === 1) {
// Иначе, если установлен режим аутентификации через обычную версию
// $this->browser->post('http://login.vk.com/?act=login');
}
// Аутентификация
$response = $this->browser->request(
'POST',
$action,
[
'form_params' => [
'email' => $login,
'pass' => $password
]
]
);
// Поиск уведомления с ошибкой
$warning = $this->xpath((string) $response->getBody(), "//div[contains(@class, 'service_msg_box')]/div[contains(@class, 'service_msg service_msg_warning')]/text()");
if (!empty($warning[0]->textContent)) {
// Если аутентификация не прошла и появилось окно с ошибкой
throw new Exception('ВКонтакте: "' . trim($warning[0]->textContent) . '"');
}
return $this;
}
/**
* @todo Сделать
*/
public function deauth(): self
{
// Очистка cookie
if (file_exists($this->path . DIRECTORY_SEPARATOR . 'cookie.txt')) {
// Если сущестуют cookie, то удалить
unlink($this->path . DIRECTORY_SEPARATOR . 'cookie.txt');
// Ренициализация браузера
$this->browser();
}
return $this;
}
public function key(int $project_id = null, string ...$rights): string
{
if (!is_null($project_id)) {
// Если переданы параметры
// Инициализация
$uri = '';
// Запрос и обработка страницы подтверждения генерации ключа
$response = $this->browser->request(
'POST',
'https://oauth.vk.com/authorize?client_id=' . $project_id . '&redirect_uri=https://oauth.vk.com/blank.html&display=mobile&scope=' . (empty($rights) ? 140488159 : implode(',', $rights)) . '&response_type=token',
[
'http_errors' => false,
'on_stats' => function (TransferStats $stats) use (&$uri) {
$uri = $stats->getEffectiveUri();
}
]
);
// Проверка
$body = $this->check((string) $response->getBody());
if ($response->getStatusCode() === 200) {
// Поиск текста
$text = $this->xpath($body, "/html/body/text()|/html/body/b/text()");
// Инкрементация найденных строк в одну
for ($body = '', $i = 1; $i < count($text); $body .= $text[$i++]->textContent);
// Обрезка переносов строки и пробелов
$body = trim($body);
if ($body !== "Пожалуйста, не копируйте данные из адресной строки для сторонних сайтов. Таким образом Вы можете потерять доступ к Вашему аккаунту.") {
// Если показывает страницу подтверждения генерации токена (после генерации подтверждать не просит и сразу выдаёт токен)
// Инициализация DOM
$dom = new DOMDocument;
@$dom->loadHTML((string) $response->getBody());
// Ссылка для отправки формы (подтверждение выдачи ключа)
$action = $dom->getElementsByTagName('form')[0]->getAttribute('action');
// Запрос ключа
$response = $this->browser->request(
'POST',
$action,
[
'http_errors' => false,
'on_stats' => function (TransferStats $stats) use (&$uri) {
$uri = $stats->getEffectiveUri();
}
]
);
// Проверка ответа на наличие json с ошибкой
$this->check((string) $response->getBody());
}
// Извлечение ключа из URI
$parts = parse_url((string) $uri);
parse_str($parts['fragment'], $fragments);
// Запись ключа
$this->key = $fragments['access_token'];
} else {
throw new Exception('ВКонтакте: "' . json_decode((string) $response->getBody())->error_description . '"');
}
}
return $this->key;
}
private function browser(): browser
{
return $this->browser = new browser([
'verify' => $this->ssl,
'cookies' => (new FileCookieJar($this->path . DIRECTORY_SEPARATOR . 'cookie.txt'))
]);
}
private function xpath(string $html, string $query): ?object
{
// DOM
$dom = new DOMDocument;
@$dom->loadHTML($html);
// XPATH 1.0
$xpath = new DOMXPath($dom);
return $xpath->query($query);
}
private function check(string $response): ?string
{
$json = json_decode($response);
if (json_last_error() === JSON_ERROR_NONE) {
// Если это JSON
if (isset($json->error)) {
// Если есть ошибки
throw new Exception('ВКонтакте: "' . ($json->error['error_msg'] ?? $json->error_description) . '"', $json->error['error_code'] ?? 0);
}
}
return $response;
}
/**
* Магический метод: установить свойство
*
* @param mixed $name Название
* @param mixed $value Значение
*
* @return void
*/
public function __set($name, $value): void
{
if ($name === 'id') {
throw new Exception('Запрещено инициализировать идентификатор');
} else if ($name === 'login' || $name === 'name' || $name === 'email' || $name === 'phone' || $name === 'number' || $name === 'nick' || $name === 'nickname') {
throw new Exception('Запрещено инициализировать входной');
} else if ($name === 'password' || $name === 'pswd' || $name === 'pass') {
throw new Exception('Запрещено инициализировать пароль');
} else if ($name === 'key' || $name === 'token') {
$this->key = $value;
} else if ($name === 'browser') {
throw new Exception('Запрещено инициализировать браузер');
} else if ($name === 'path') {
$this->path = $value. DIRECTORY_SEPARATOR . $this->id;
// Проверка и создание директории
if (!file_exists($this->path)) {
mkdir($this->path, 0775, true);
}
// Реинициализация браузера с новым значением
$this->browser();
} else if ($name === 'ssl') {
$this->ssl = $value;
// Реинициализация браузера с новым значением
$this->browser();
}
}
/**
* Магический метод: получить свойство
*
* @param mixed $name Название
*
* @return mixed
*/
public function __get($name)
{
if ($name === 'id') {
return $this->id;
} else if ($name === 'login' || $name === 'name' || $name === 'email' || $name === 'phone' || $name === 'number' || $name === 'nick' || $name === 'nickname') {
return $this->login;
} else if ($name === 'password' || $name === 'pswd' || $name === 'pass') {
return $this->password;
} else if ($name === 'key') {
return $this->key;
} else if ($name === 'browser') {
return $this->browser;
} else if ($name === 'path') {
return $this->path;
} else if ($name === 'ssl') {
return $this->ssl ?? $this->ssl = true;
}
}
/**
* Магический метод: проверка на инициализированность
*
* @param mixed $name Название
*
* @return mixed
*/
public function __isset($name)
{
if ($name === 'id') {
return isset($this->id);
} else if ($name === 'login' || $name === 'name' || $name === 'email' || $name === 'phone' || $name === 'number' || $name === 'nick' || $name === 'nickname') {
return isset($this->login);
} else if ($name === 'password' || $name === 'pswd' || $name === 'pass') {
return isset($this->password);
} else if ($name === 'key') {
return isset($this->key);
} else if ($name === 'browser') {
return isset($this->browser);
} else if ($name === 'path') {
return isset($this->path);
} else if ($name === 'ssl') {
return isset($this->ssl);
}
}
/**
* Магический метод: удаление
*
* @param mixed $name Название
*
* @return mixed
*/
public function __unset($name)
{
if ($name === 'id') {
throw new Exception('Запрещено деинициализировать идентификатор');
} else if ($name === 'login' || $name === 'name' || $name === 'email' || $name === 'phone' || $name === 'number' || $name === 'nick' || $name === 'nickname') {
throw new Exception('Запрещено деинициализировать входной');
} else if ($name === 'password' || $name === 'pswd' || $name === 'pass') {
throw new Exception('Запрещено деинициализировать пароль');
} else if ($name === 'key') {
unset($this->key);
} else if ($name === 'browser') {
unset($this->browser);
} else if ($name === 'path') {
unset($this->path);
} else if ($name === 'ssl') {
unset($this->ssl);
}
}
}

1
hood/accounts/tests/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/settings.php

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace hood\accounts\tests;
trait settings
{
/**
* @var int $id Идентификатор
*/
protected int $id = 0;
/**
* @var string $login Входной [псевдоним]
*/
protected string $login = '';
/**
* @var string $password Пароль
*/
protected string $password = '';
/**
* @var int $project_id Идентификатор приложения
*/
protected int $project_id = 0;
/**
* @var string $project_key Ключ приложения
*/
protected string $project_key = '';
/**
* @var string $project_service_key Сервисный ключ приложения
*/
protected string $project_service_key = '';
/**
* @var bool $ssl SSL-протокол
*/
protected bool $ssl = true;
}

View File

@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
namespace hood\accounts\tests;
use PHPUnit\Framework\TestCase;
use hood\accounts\vk as account;
use GuzzleHttp\Client as browser;
/**
* @testdox ВКонтакте-аккаунт
*/
final class vkTest extends TestCase
{
use settings;
/**
* Аккаунт
*/
private account $account;
/**
* Инициализация теста
*/
public function setUp(): void
{
if (empty($this->id)) {
$this->markTestSkipped('Не установлен идентификатор');
}
// Инициализация аккаунта
$this->account = new account($this->id, __DIR__ . DIRECTORY_SEPARATOR . 'accounts');
// Инициализация свойства SSL-протокола
$this->account->ssl = $this->ssl;
// Проверка
$this->assertInstanceOf(account::class, $this->account, 'Не удалось создать аккаунт');
}
/**
* Деинициализация теста
*/
public function tearDown(): void
{
// Деаутентификация
$this->account->deauth();
$path = $this->account->path;
// Удаление аккаунта
unset($this->account);
// Проверка
$this->assertNull($this->account ?? null, 'Не удалось удалить аккаунт');
// Удаление файлов
unlink($path . DIRECTORY_SEPARATOR . 'cookie.txt');
rmdir($path);
// Проверка
$this->assertFileDoesNotExist($path, 'Не удалось удалить директорию для тестов');
}
/**
* @testdox Аутентификация (базовая) в мобильном режиме
*/
public function testVkAuthBasicModeMobile(): void
{
if (empty($this->login)) {
$this->markTestSkipped('Не установлен логин');
} else if (empty($this->password)) {
$this->markTestSkipped('Не установлен пароль');
}
// Подождать во избежание блокировки
sleep(3);
// Аутентификация
$this->account->auth($this->login, $this->password);
// Проверка
$this->assertStringNotContainsString('Страница удалена либо ещё не создана.', (string) $this->account->browser->request('GET', 'https://m.vk.com/id0')->getBody(), 'Не удалось аутентифицироваться');
}
/**
* @testdox Реаутентификация (базовая) в мобильном режиме
*/
public function testVkReauthBasicModeMobile(): void
{
if (empty($this->login)) {
$this->markTestSkipped('Не установлен логин');
} else if (empty($this->password)) {
$this->markTestSkipped('Не установлен пароль');
}
// Подождать во избежание блокировки
sleep(3);
// Аутентификация
$this->account->auth($this->login, $this->password);
// Проверка
$this->assertStringNotContainsString('Страница удалена либо ещё не создана.', (string) $this->account->browser->request('GET', 'https://m.vk.com/id0')->getBody(), 'Не удалось аутентифицироваться');
// Проверка выброса исключения
$this->expectExceptionMessage('Повторная аутентификация запрещена');
// Аутентификация
$this->account->auth($this->login, $this->password);
}
/**
* @testdox Аутентификация (базовая) в мобильном режиме с неправильным логином
*
* Тест может завериться неудачей, если ВКонтакте выдаст блокировку (надо повторить тест позже)
*/
public function testVkAuthBasicModeMobileWhenLoginIncorrect(): void
{
// Подождать во избежание блокировки
sleep(3);
// Проверка выброса исключения
$this->expectExceptionMessage('ВКонтакте: "Пожалуйста, проверьте правильность введённых данных."');
// Аутентификация
$this->account->auth('admin' . rand(0, 1000) . '@hood.su', (string) rand(0, 1000));
}
/**
* @testdox Аутентификация (базовая) в мобильном режиме c генерацией ключа
*/
public function testVkAuthBasicModeMobileWithKeyGeneration(): void
{
if (empty($this->login)) {
$this->markTestSkipped('Не установлен логин');
} else if (empty($this->password)) {
$this->markTestSkipped('Не установлен пароль');
}
// Подождать во избежание блокировки
sleep(3);
// Аутентификация
$this->account->auth($this->login, $this->password);
// Проверка
$this->assertStringNotContainsString('Страница удалена либо ещё не создана.', (string) $this->account->browser->request('GET', 'https://m.vk.com/id0')->getBody(), 'Не удалось аутентифицироваться');
// Генерация ключа
$this->account->key($this->project_id);
// Проверка
$this->assertNotNull($this->account->key(), 'Не удалось сгенерировать ключ');
}
/**
* @testdox Запись идентификатора
*/
public function testSetId(): void
{
// Проверка выброса исключения
$this->expectExceptionMessage('Запрещено инициализировать идентификатор');
// Запись
$this->account->id = rand(0, 100);
}
/**
* @testdox Запись входного
*/
public function testSetLogin(): void
{
if (empty($this->login)) {
$this->markTestSkipped('Не установлен логин');
}
// Проверка выброса исключения
$this->expectExceptionMessage('Запрещено инициализировать входной');
// Запись
$this->account->login = $this->login;
}
/**
* @testdox Запись пароля
*/
public function testSetPassword(): void
{
if (empty($this->password)) {
$this->markTestSkipped('Не установлен пароль');
}
// Проверка выброса исключения
$this->expectExceptionMessage('Запрещено инициализировать пароль');
// Запись
$this->account->password = $this->password;
}
/**
* @testdox Запись браузера
*/
public function testSetBrowser(): void
{
// Проверка выброса исключения
$this->expectExceptionMessage('Запрещено инициализировать браузер');
// Запись
$this->account->browser = new browser();
}
/**
* @testdox Запись корневой директории
*/
public function testSetPath(): void
{
// Изначальное значение
$old = $this->account->path;
// Запись
$this->account->path = __DIR__ . DIRECTORY_SEPARATOR . 'accounts' . DIRECTORY_SEPARATOR . 'test_directory';
// Проверка
$this->assertNotSame($this->account->path, $old, 'Не удалось записать корневую директорию');
}
/**
* @testdox Запись SSL
*/
public function testSetSsl(): void
{
// Изначальное значение
$old = $this->account->ssl;
// Запись
$this->account->ssl = !$old;
// Проверка
$this->assertNotSame($this->account->ssl, $old, 'Не удалось записать SSL');
}
}