Browse Source

OAuth (part 2 draft)

Visman 2 years ago
parent
commit
73af8bd435

+ 9 - 7
app/Models/Pages/Admin/Providers.php

@@ -23,7 +23,9 @@ class Providers extends Admin
      */
     protected function mDisabled(): void
     {
-        if (1 !== $this->c->config->b_oauth_allow) {
+        if (! \extension_loaded('curl')) {
+            $this->fIswev = ['e', 'cURL disabled'];
+        } elseif (1 !== $this->c->config->b_oauth_allow) {
             $this->fIswev = ['w', ['OAuth authorization disabled', $this->c->Router->link('AdminOptions', ['#' => 'id-fs-registration'])]];
         }
     }
@@ -60,7 +62,7 @@ class Providers extends Admin
 
         $this->nameTpl   = 'admin/form';
         $this->aIndex    = 'options';
-        $this->aCrumbs[] = [$this->c->providers->link(''), 'Providers'];
+        $this->aCrumbs[] = [$this->c->Router->link('AdminProviders'), 'Providers'];
         $this->form      = $this->formView();
         $this->classForm = ['providers', 'inline'];
         $this->titleForm = 'Providers';
@@ -74,7 +76,7 @@ class Providers extends Admin
     protected function formView(): array
     {
         $form = [
-            'action' => $this->c->providers->link(''),
+            'action' => $this->c->Router->link('AdminProviders'),
             'hidden' => [
                 'token' => $this->c->Csrf->create('AdminProviders'),
             ],
@@ -94,7 +96,7 @@ class Providers extends Admin
                 'type'    => 'btn',
                 'value'   => $provider->name,
                 'caption' => 'Provider label',
-                'link'    => $this->c->providers->link($provider->name),
+                'link'    => $this->c->Router->link('AdminProvider', ['name' => $provider->name]),
             ];
             $fields["form[{$provider->name}][pr_pos]"] = [
                 'class'   => ['position', 'provider'],
@@ -171,8 +173,8 @@ class Providers extends Admin
 
         $this->nameTpl   = 'admin/form';
         $this->aIndex    = 'options';
-        $this->aCrumbs[] = [$this->c->providers->link($provider->name), $provider->name];
-        $this->aCrumbs[] = [$this->c->providers->link(''), 'Providers'];
+        $this->aCrumbs[] = [$this->c->Router->link('AdminProvider', ['name' => $provider->name]), $provider->name];
+        $this->aCrumbs[] = [$this->c->Router->link('AdminProviders'), 'Providers'];
         $this->form      = $this->formEdit($provider);
         $this->classForm = ['provider'];
         $this->titleForm = $provider->name;
@@ -186,7 +188,7 @@ class Providers extends Admin
     protected function formEdit(Driver $provider): array
     {
         $form = [
-            'action' => $this->c->providers->link($provider->name),
+            'action' => $this->c->Router->link('AdminProvider', ['name' => $provider->name]),
             'hidden' => [
                 'token' => $this->c->Csrf->create('AdminProvider', ['name' => $provider->name]),
             ],

+ 4 - 4
app/Models/Pages/RegLog.php

@@ -67,16 +67,16 @@ class RegLog extends Page
 
         $provider = $this->c->providers->init()->get($args['name']);
 
-        if (true !== ($result = $provider->verifyAuth($_GET))) {
-            return $this->c->Message->message($result);
+        if (true !== $provider->verifyAuth($_GET)) {
+            return $this->c->Message->message($provider->error);
         }
 
         if (true !== $provider->reqAccessToken()) {
-            return $this->c->Message->message('Error token');
+            return $this->c->Message->message($provider->error);
         }
 
         if (true !== $provider->reqUserInfo()) {
-
+            return $this->c->Message->message($provider->error);
         }
     }
 }

+ 125 - 40
app/Models/Provider/Driver.php

@@ -16,14 +16,14 @@ use RuntimeException;
 
 abstract class Driver extends Model
 {
+    const JSON_OPTIONS  = \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR;
+
     /**
      * Ключ модели для контейнера
      */
     protected string $cKey = 'Provider';
 
-    protected string $code;
-
-    protected string $originalName;
+    protected string $origName;
     protected string $authURL;
     protected string $tokenURL;
     protected string $userURL;
@@ -32,38 +32,59 @@ abstract class Driver extends Model
     public function __construct(protected string $client_id, protected string $client_secret, Container $c)
     {
         parent::__construct($c);
+
+        $this->zDepend = [
+            'code'         => ['access_token', 'userInfo', 'userId', 'userName', 'userEmail', 'userEmailVerifed', 'userAvatar', 'userURL', 'userLocation', 'userGender'],
+            'access_token' => ['userInfo', 'userId', 'userName', 'userEmail', 'userEmailVerifed', 'userAvatar', 'userURL', 'userLocation', 'userGender'];
+            'userInfo'     => ['userId', 'userName', 'userEmail', 'userEmailVerifed', 'userAvatar', 'userURL', 'userLocation', 'userGender']
+        ];
     }
 
+    /**
+     * Проверяет и устанавливает имя провайдера
+     */
     protected function setname(string $name):void
     {
-        if ($this->originalName !== $name) {
+        if ($this->origName !== $name) {
             throw new RuntimeException("Invalid name: {$name}");
         }
 
         $this->setAttr('name', $name);
     }
 
+    /**
+     * Формирует ссылку переадресации
+     */
     protected function getlinkCallback(): string
     {
-        return $this->c->Router->link('RegLogCallback', ['name' => $this->name]);
+        return $this->c->Router->link('RegLogCallback', ['name' => $this->origName]);
     }
 
+    /**
+     * Возвращает client_id
+     */
     protected function getclient_id(): string
     {
         return $this->client_id;
     }
 
+    /**
+     * Возвращает client_secret
+     */
     protected function getclient_secret(): string
     {
         return $this->client_secret;
     }
 
+    /**
+     * Формирует ссылку авторизации на сервере провайдера
+     */
     protected function getlinkAuth(): string
     {
         $params = [
             'response_type' => 'code',
             'scope'         => $this->scope,
-            'state'         => $this->c->Csrf->createHash($this->originalName, ['ip' => $this->c->user->ip]),
+            'state'         => $this->c->Csrf->createHash($this->origName, ['ip' => $this->c->user->ip]),
             'client_id'     => $this->client_id,
             'redirect_uri'  => $this->linkCallback,
         ];
@@ -71,26 +92,41 @@ abstract class Driver extends Model
         return $this->authURL . '?' . \http_build_query($params);
     }
 
+    /**
+     * Проверяет правильность state
+     */
     protected function verifyState(string $state): bool
     {
-        return $this->c->Csrf->verify($state, $this->originalName, ['ip' => $this->c->user->ip]);
+        return $this->c->Csrf->verify($state, $this->origName, ['ip' => $this->c->user->ip]);
     }
 
-    public function verifyAuth(array $data): bool|string|array
+    /**
+     * Проверяет ответ сервера провайдера после авторизации пользователя
+     * Запоминает code
+     */
+    public function verifyAuth(array $data): bool
     {
+        $this->code = '';
+
         if (! \is_string($data['code'] ?? null)) {
-            if (\is_string($data['error'] ?? null)) {
-                return ['Provider response: %s', $data['error']];
-            } else {
-                return ['Provider response: %s', 'undefined'];
+            $error = $data['error_description'] ?? ($data['error'] ?? null);
+
+            if (! \is_string($error)) {
+                $error = 'undefined error';
             }
+
+            $this->error = ['Provider response error: %s', $error];
+
+            return false;
         }
 
         if (
             ! \is_string($data['state'] ?? null)
             || ! $this->verifyState($data['state'])
         ) {
-            return 'State error';
+            $this->error = 'State error';
+
+            return false;
         }
 
         $this->code = $data['code'];
@@ -98,8 +134,15 @@ abstract class Driver extends Model
         return true;
     }
 
+    /**
+     * Запрашивает access token на основе code
+     * Проверяет ответ
+     * Запоминает access token
+     */
     public function reqAccessToken(): bool
     {
+        $this->access_token = '';
+
         $params = [
             'grant_type'    => 'authorization_code',
             'client_id'     => $this->client_id,
@@ -108,7 +151,12 @@ abstract class Driver extends Model
             'redirect_uri'  => $this->linkCallback,
         ];
 
-        $ch = \curl_init($this->tokenURL);
+        if (empty($ch = \curl_init($this->tokenURL))) {
+            $this->error     = 'cURL error';
+            $this->curlError = \curl_error($ch);
+
+            return false;
+        }
 
         \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Accept: application/json']);
         \curl_setopt($ch, \CURLOPT_POST, true);
@@ -116,47 +164,84 @@ abstract class Driver extends Model
         \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, true);
         \curl_setopt($ch, \CURLOPT_HEADER, false);
 
-        $html = \curl_exec($ch);
-#        $type = \curl_getinfo($ch, \CURLINFO_CONTENT_TYPE);
+        $result = \curl_exec($ch);
 
         \curl_close($ch);
 
+        if (false === $result) {
+            $this->error     = 'cURL error';
+            $this->curlError = \curl_error($ch);
+
+            return false;
+        }
+
         if (
-            ! isset($html[1])
-            || '{' !== $html[0]
-            || '}' !== $html[-1]
-            || ! \is_array($json = \json_decode($html, true, 20, \JSON_BIGINT_AS_STRING & JSON_UNESCAPED_UNICODE & JSON_INVALID_UTF8_SUBSTITUTE))
-            || ! isset($json['access_token'])
+            ! isset($result[1])
+            || '{' !== $result[0]
+            || '}' !== $result[-1]
+            || ! \is_array($data = \json_decode($result, true, 20, self::JSON_OPTIONS))
+            || ! isset($data['access_token'])
         ) {
+            $error = $data['error_description'] ?? ($data['error'] ?? null);
+
+            if (! \is_string($error)) {
+                $error = 'undefined error';
+            }
+
+            $this->error = ['Token error: %s', $error];
+
             return false;
         }
 
-        $this->access_token = $json['access_token'];
+        $this->access_token = $data['access_token'];
 
         return true;
     }
 
-    public function reqUserInfo(): bool
-    {
-        $headers = [
-            'Accept: application/json',
-            "Authorization: Bearer {$this->access_token}",
-            'User-Agent: ForkBB (Client ID: {$this->client_id})',
-        ];
+    /**
+     * Запрашивает информацию о пользователе
+     * Проверяет ответ
+     * Запоминает данные пользователя
+     */
+    abstract public function reqUserInfo(): bool;
 
-        $ch = \curl_init($this->userURL);
+    /**
+     * Возвращает идентификатор пользователя (от провайдера)
+     */
+    abstract protected function getuserId(): string;
 
-        \curl_setopt($ch, \CURLOPT_HTTPHEADER, $headers);
-        \curl_setopt($ch, \CURLOPT_POST, false);
-        #\curl_setopt($ch, \CURLOPT_POSTFIELDS, \http_build_query($params));
-        \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, true);
-        \curl_setopt($ch, \CURLOPT_HEADER, false);
+    /**
+     * Возвращает имя пользователя (от провайдера)
+     */
+    abstract protected function getuserName(): string;
 
-        $html = \curl_exec($ch);
-#        $type = \curl_getinfo($ch, \CURLINFO_CONTENT_TYPE);
+    /**
+     * Возвращает email пользователя (от провайдера)
+     */
+    abstract protected function getuserEmail(): string;
 
-        \curl_close($ch);
+    /**
+     * Возвращает флаг подлинности email пользователя (от провайдера)
+     */
+    abstract protected function getuserEmailVerifed(): bool;
 
-        exit(var_dump("<pre>".$html));
-    }
+    /**
+     * Возвращает ссылку на аватарку пользователя (от провайдера)
+     */
+    abstract protected function getuserAvatar(): string;
+
+    /**
+     * Возвращает ссылку на профиль пользователя (от провайдера)
+     */
+    abstract protected function getuserURL(): string;
+
+    /**
+     * Возвращает местоположение пользователя (от провайдера)
+     */
+    abstract protected function getuserLocation(): string;
+
+    /**
+     * Возвращает пол пользователя (от провайдера)
+     */
+    abstract protected function getuserGender(): int;
 }

+ 123 - 5
app/Models/Provider/Driver/GitHub.php

@@ -15,10 +15,128 @@ use RuntimeException;
 
 class GitHub extends Driver
 {
-    protected string $originalName = 'github';
-    protected string $authURL      = 'https://github.com/login/oauth/authorize';
-    protected string $tokenURL     = 'https://github.com/login/oauth/access_token';
-    protected string $userURL      = 'https://api.github.com/user';
-    protected string $scope        = 'read:user';
+    protected string $origName = 'github';
+    protected string $authURL  = 'https://github.com/login/oauth/authorize';
+    protected string $tokenURL = 'https://github.com/login/oauth/access_token';
+    protected string $userURL  = 'https://api.github.com/user';
+    protected string $scope    = 'read:user';
 
+    /**
+     * Запрашивает информацию о пользователе
+     * Проверяет ответ
+     * Запоминает данные пользователя
+     */
+    public function reqUserInfo(): bool
+    {
+        $this->userInfo = [];
+
+        $headers = [
+            'Accept: application/json',
+            "Authorization: Bearer {$this->access_token}",
+            "User-Agent: ForkBB (Client ID: {$this->client_id})",
+        ];
+
+        if (empty($ch = \curl_init($this->userURL))) {
+            $this->error     = 'cURL error';
+            $this->curlError = \curl_error($ch);
+
+            return false;
+        }
+
+        \curl_setopt($ch, \CURLOPT_HTTPHEADER, $headers);
+        \curl_setopt($ch, \CURLOPT_POST, false);
+        \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, true);
+        \curl_setopt($ch, \CURLOPT_HEADER, false);
+
+        $result = \curl_exec($ch);
+
+        \curl_close($ch);
+
+        if (false === $result) {
+            $this->error     = 'cURL error';
+            $this->curlError = \curl_error($ch);
+
+            return false;
+        }
+
+        if (
+            ! isset($result[1])
+            || '{' !== $result[0]
+            || '}' !== $result[-1]
+            || ! \is_array($userInfo = \json_decode($result, true, 20, self::JSON_OPTIONS))
+            || empty($userInfo['id'])
+        ) {
+            $this->error = 'User error';
+
+            return false;
+        }
+
+        $this->userInfo = $userInfo;
+
+        return true;
+    }
+
+    /**
+     * Возвращает идентификатор пользователя (от провайдера)
+     */
+    protected function getuserId(): string
+    {
+        return (string) ($this->userInfo['id'] ?? '');
+    }
+
+    /**
+     * Возвращает имя пользователя (от провайдера)
+     */
+    protected function getuserName(): string
+    {
+        return (string) ($this->userInfo['name'] ?? ($this->userInfo['login'] ?? ''));
+    }
+
+    /**
+     * Возвращает email пользователя (от провайдера)
+     */
+    protected function getuserEmail(): string
+    {
+        return $this->c->Mail->valid($this->userInfo['email'] ?? null) ?: "{$this->origName}-{$this->userId}@localhost";
+    }
+
+    /**
+     * Возвращает флаг подлинности email пользователя (от провайдера)
+     */
+    protected function getuserEmailVerifed(): bool
+    {
+        return false;
+    }
+
+    /**
+     * Возвращает ссылку на аватарку пользователя (от провайдера)
+     */
+    protected function getuserAvatar(): string
+    {
+        return (string) ($this->userInfo['avatar_url'] ?? '');
+    }
+
+    /**
+     * Возвращает ссылку на профиль пользователя (от провайдера)
+     */
+    protected function getuserURL(): string
+    {
+        return $this->userInfo['html_url'];
+    }
+
+    /**
+     * Возвращает местоположение пользователя (от провайдера)
+     */
+    protected function getuserLocation(): string
+    {
+        return (string) ($this->userInfo['location'] ?? '');
+    }
+
+    /**
+     * Возвращает пол пользователя (от провайдера)
+     */
+    protected function getuserGender(): int
+    {
+        return 0;
+    }
 }

+ 36 - 10
app/Models/Provider/Providers.php

@@ -24,7 +24,14 @@ class Providers extends Manager
      */
     protected string $cKey = 'Providers';
 
+    /**
+     * Кэш таблицы providers
+     */
     protected ?array $cache = null;
+
+    /**
+     * Флаг готовности репозитория моделей
+     */
     protected bool $ready = false;
 
     public function __construct(protected array $drivers, Container $c)
@@ -32,6 +39,9 @@ class Providers extends Manager
         parent::__construct($c);
     }
 
+    /**
+     * Заполняет репозиторий провайдерами
+     */
     public function init(): self
     {
         if (! $this->ready) {
@@ -45,13 +55,9 @@ class Providers extends Manager
         return $this;
     }
 
-    public function link(string $name): string
-    {
-        return '' === $name
-            ? $this->c->Router->link('AdminProviders')
-            : $this->c->Router->link('AdminProvider', ['name' => $name]);
-    }
-
+    /**
+     * Создает провайдера
+     */
     public function create(array $attrs = []): Driver
     {
         if (! isset($attrs['pr_name'])) {
@@ -67,7 +73,7 @@ class Providers extends Manager
         }
 
         $class         = $this->drivers[$attrs['pr_name']];
-        $driver        =  new $class($attrs['pr_cl_id'], $attrs['pr_cl_sec'], $this->c);
+        $driver        = new $class($attrs['pr_cl_id'], $attrs['pr_cl_sec'], $this->c);
         $driver->name  = $attrs['pr_name'];
         $driver->pos   = $attrs['pr_pos'];
         $driver->allow = $attrs['pr_allow'];
@@ -77,13 +83,23 @@ class Providers extends Manager
         return $driver;
     }
 
+    /**
+     * Возращает список созданных провайдеров
+     */
     public function list(): array
     {
         return $this->repository;
     }
 
+    /**
+     * Возращает список имён активных провайдеров
+     */
     public function active(): array
     {
+        if (! \extension_loaded('curl')) {
+            return [];
+        }
+
         $result = [];
 
         foreach ($this->cache() as $cur) {
@@ -99,6 +115,9 @@ class Providers extends Manager
         return $result;
     }
 
+    /**
+     * Возращает/создает кэш таблицы providers
+     */
     protected function cache(): array
     {
         if (! \is_array($this->cache)) {
@@ -124,6 +143,9 @@ class Providers extends Manager
         return $this->cache;
     }
 
+    /**
+     * Проверяет поле на пустоту
+     */
     protected function emptyField(string $key, array $sets, array $cache): bool
     {
         if (isset($sets[$key])) {
@@ -133,6 +155,10 @@ class Providers extends Manager
         }
     }
 
+    /**
+     * Обновляет таблицу providers на основе данных полученных из формы
+     * Удаляет кэш
+     */
     public function update(array $form): self
     {
         $cache  = $this->cache();
@@ -143,8 +169,6 @@ class Providers extends Manager
                 continue;
             }
 
-            $set = $vars = [];
-
             if (
                 (
                     ! empty($sets['pr_allow'])
@@ -158,6 +182,8 @@ class Providers extends Manager
                 $sets['pr_allow'] = 0;
             }
 
+            $set = $vars = [];
+
             foreach ($sets as $name => $value) {
                 if (
                     ! isset($fields[$name])

+ 13 - 4
app/lang/en/admin_providers.po

@@ -60,11 +60,20 @@ msgstr "Enable this checkbox if you want to change the data"
 msgid "Sign in with %s"
 msgstr "Sign in with %s"
 
-msgid "Provider response: %s"
-msgstr "Provider response: %s."
+msgid "Provider response error: %s"
+msgstr "Failed.<br>Provider response: %s."
 
 msgid "State error"
 msgstr "Error in state parameter."
 
-msgid "Error token"
-msgstr "Error request access token"
+msgid "Token error: %s"
+msgstr "Access token request failed: %s."
+
+msgid "cURL disabled"
+msgstr "The cURL extension is disabled in PHP."
+
+msgid "cURL error"
+msgstr "cURL error."
+
+msgid "User error"
+msgstr "User data request failed."

+ 13 - 4
app/lang/ru/admin_providers.po

@@ -60,11 +60,20 @@ msgstr "Установите флажок, если хотите изменит
 msgid "Sign in with %s"
 msgstr "Войти с помощью %s"
 
-msgid "Provider response: %s"
-msgstr "Ответ провайдера: %s."
+msgid "Provider response error: %s"
+msgstr "Ошибка.<br>Ответ провайдера: %s."
 
 msgid "State error"
 msgstr "Ошибка в параметре state."
 
-msgid "Error token"
-msgstr "Ошибка запроса токена доступа"
+msgid "Token error: %s"
+msgstr "Ошибка запроса токена доступа: %s."
+
+msgid "cURL disabled"
+msgstr "Расширение cURL отключено в PHP."
+
+msgid "cURL error"
+msgstr "Ошибка cURL."
+
+msgid "User error"
+msgstr "Ошибка запроса данных пользователя."

+ 2 - 1
composer.json

@@ -40,6 +40,7 @@
     "suggest": {
         "ext-openssl": "Needed to send email via smtp server using SSL/TLS.",
         "ext-imagick": "(or ext-gd) Needed for to upload avatars (and images).",
-        "ext-gd": "(or ext-imagick) Needed for to upload avatars (and images)."
+        "ext-gd": "(or ext-imagick) Needed for to upload avatars (and images).",
+        "ext-curl": "Needed for OAuth."
     }
 }

+ 1 - 1
readme.md

@@ -14,7 +14,7 @@ No: plugins/extensions system, ...
 
 * PHP 8.0+
 * PHP extensions: pdo, intl, json, mbstring, fileinfo
-* PHP extensions (suggests): imagick or gd (for upload avatars and other images), openssl (for send email via smtp server using SSL/TLS)
+* PHP extensions (suggests): imagick or gd (for upload avatars and other images), openssl (for send email via smtp server using SSL/TLS), curl (for OAuth)
 * A database such as MySQL 5.5.3+ (an extension using the mysqlnd driver must be enabled), SQLite 3.25+, PostgreSQL 10+
 
 ## Install