ソースを参照

OAuth (part 1 draft)

Visman 2 年 前
コミット
8b72a4a859

+ 30 - 0
app/Controllers/Routing.php

@@ -94,6 +94,24 @@ class Routing
                 'Redirect:toIndex'
             );
         }
+        // OAuth
+        if (
+            $user->isAdmin
+            || 1 === $config->b_oauth_allow
+        ) {
+            $r->add(
+                $r::GET,
+                '/reglog/callback/{name}',
+                'RegLog:callback',
+                'RegLogCallback'
+            );
+            $r->add(
+                $r::PST,
+                '/reglog/redirect',
+                'RegLog:redirect',
+                'RegLogRedirect'
+            );
+        }
         // просмотр разрешен
         if (1 === $user->g_read_board) {
             // главная
@@ -557,6 +575,18 @@ class Routing
                 'AdminOptions:edit',
                 'AdminOptions'
             );
+            $r->add(
+                $r::DUO,
+                '/admin/options/providers',
+                'AdminProviders:view',
+                'AdminProviders'
+            );
+            $r->add(
+                $r::DUO,
+                '/admin/options/providers/{name}',
+                'AdminProviders:edit',
+                'AdminProvider'
+            );
             $r->add(
                 $r::DUO,
                 '/admin/parser',

+ 14 - 0
app/Models/Pages/Admin/Options.php

@@ -110,6 +110,7 @@ class Options extends Admin
                     'i_poll_term'             => 'required|integer|min:0|max:99',
                     'b_poll_guest'            => 'required|integer|in:0,1',
                     'b_pm'                    => 'required|integer|in:0,1',
+                    'b_oauth_allow'           => 'required|integer|in:0,1',
                 ])->addAliases([
                 ])->addArguments([
                 ])->addMessages([
@@ -610,6 +611,19 @@ class Options extends Admin
                     'caption' => 'Report new label',
                     'help'    => 'Report new help',
                 ],
+                'b_oauth_allow' => [
+                    'type'    => 'radio',
+                    'value'   => $config->b_oauth_allow,
+                    'values'  => $yn,
+                    'caption' => 'Allow oauth label',
+                    'help'    => 'Allow oauth help',
+                ],
+                'configure_providers' => [
+                    'type'  => 'link',
+                    'value' => __('Configure providers'),
+                    'href'  => $this->c->Router->link('AdminProviders'),
+                    'title' => __('Configure providers'),
+                ],
                 'b_rules' => [
                     'type'    => 'radio',
                     'value'   => $config->b_rules,

+ 234 - 0
app/Models/Pages/Admin/Providers.php

@@ -0,0 +1,234 @@
+<?php
+/**
+ * This file is part of the ForkBB <https://github.com/forkbb>.
+ *
+ * @copyright (c) Visman <mio.visman@yandex.ru, https://github.com/MioVisman>
+ * @license   The MIT License (MIT)
+ */
+
+declare(strict_types=1);
+
+namespace ForkBB\Models\Pages\Admin;
+
+use ForkBB\Core\Container;
+use ForkBB\Models\Page;
+use ForkBB\Models\Pages\Admin;
+use ForkBB\Models\Provider\Driver;
+use function \ForkBB\__;
+
+class Providers extends Admin
+{
+    /**
+     * Выводит сообщение
+     */
+    protected function mDisabled(): void
+    {
+        if (1 !== $this->c->config->b_oauth_allow) {
+            $this->fIswev = ['w', ['OAuth authorization disabled', $this->c->Router->link('AdminOptions', ['#' => 'id-fs-registration'])]];
+        }
+    }
+
+    /**
+     * Просмотр, редактирвоание и добавление категорий
+     */
+    public function view(array $args, string $method): Page
+    {
+        $this->c->Lang->load('validator');
+        $this->c->Lang->load('admin_providers');
+
+        if ('POST' === $method) {
+            $v = $this->c->Validator->reset()
+                ->addRules([
+                    'token'           => 'token:AdminProviders',
+                    'form.*.pr_pos'   => 'required|integer|min:0|max:9999999999',
+                    'form.*.pr_allow' => 'checkbox',
+                ])->addAliases([
+                ])->addArguments([
+                ])->addMessages([
+                ]);
+
+            if ($v->validation($_POST)) {
+                $this->c->providers->update($v->form);
+
+                return $this->c->Redirect->page('AdminProviders')->message('Providers updated redirect');
+            }
+
+            $this->fIswev  = $v->getErrors();
+        }
+
+        $this->mDisabled();
+
+        $this->nameTpl   = 'admin/form';
+        $this->aIndex    = 'options';
+        $this->aCrumbs[] = [$this->c->providers->link(''), 'Providers'];
+        $this->form      = $this->formView();
+        $this->classForm = ['providers', 'inline'];
+        $this->titleForm = 'Providers';
+
+        return $this;
+    }
+
+    /**
+     * Подготавливает массив данных для формы
+     */
+    protected function formView(): array
+    {
+        $form = [
+            'action' => $this->c->providers->link(''),
+            'hidden' => [
+                'token' => $this->c->Csrf->create('AdminProviders'),
+            ],
+            'sets'   => [],
+            'btns'   => [
+                'save' => [
+                    'type'  => 'submit',
+                    'value' => __('Save changes'),
+                ],
+            ],
+        ];
+        $fields = [];
+
+        foreach ($this->c->providers->init()->list() as $provider) {
+            $fields["name-{$provider->name}"] = [
+                'class'   => ['name', 'provider'],
+                'type'    => 'btn',
+                'value'   => $provider->name,
+                'caption' => 'Provider label',
+                'link'    => $this->c->providers->link($provider->name),
+            ];
+            $fields["form[{$provider->name}][pr_pos]"] = [
+                'class'   => ['position', 'provider'],
+                'type'    => 'number',
+                'min'     => '0',
+                'max'     => '9999999999',
+                'value'   => $provider->pos,
+                'caption' => 'Position label',
+            ];
+            $fields["form[{$provider->name}][pr_allow]"] = [
+                'class'   => ['allow', 'provider'],
+                'type'    => 'checkbox',
+                'checked' => $provider->allow,
+                'caption' => 'Allow label',
+            ];
+            $form['sets']["provider-{$provider->name}"] = [
+                'class'  => ['provider', 'inline'],
+                'legend' => $provider->name,
+                'fields' => $fields,
+            ];
+        }
+
+        return $form;
+    }
+
+    /**
+     * Просмотр, редактирвоание и добавление категорий
+     */
+    public function edit(array $args, string $method): Page
+    {
+        $provider = $this->c->providers->init()->get($args['name']);
+
+        if (! $provider instanceof Driver) {
+            return $this->c->Message->message('Bad request');
+        }
+
+        $this->c->Lang->load('validator');
+        $this->c->Lang->load('admin_providers');
+
+        if ('POST' === $method) {
+            $v = $this->c->Validator->reset()
+                ->addRules([
+                    'token'         => 'token:AdminProvider',
+                    'client_id'     => 'exist|string:trim|max:255',
+                    'client_secret' => 'exist|string:trim|max:255',
+                    'changeData'    => 'checkbox',
+                ])->addAliases([
+                ])->addArguments([
+                    'token' => $args,
+                ])->addMessages([
+                ]);
+
+            if ($v->validation($_POST)) {
+                if ($v->changeData) {
+                    $this->c->providers->update([
+                        $provider->name => [
+                            'pr_cl_id'  => $v->client_id,
+                            'pr_cl_sec' => $v->client_secret,
+                         ],
+                    ]);
+
+                    $message = 'Provider updated redirect';
+                } else {
+                    $message = 'No confirm redirect';
+                }
+
+                return $this->c->Redirect->page('AdminProvider', $args)->message($message);
+            }
+
+            $this->fIswev  = $v->getErrors();
+        }
+
+        $this->mDisabled();
+
+        $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->form      = $this->formEdit($provider);
+        $this->classForm = ['provider'];
+        $this->titleForm = $provider->name;
+
+        return $this;
+    }
+
+    /**
+     * Подготавливает массив данных для формы
+     */
+    protected function formEdit(Driver $provider): array
+    {
+        $form = [
+            'action' => $this->c->providers->link($provider->name),
+            'hidden' => [
+                'token' => $this->c->Csrf->create('AdminProvider', ['name' => $provider->name]),
+            ],
+            'sets'   => [
+                'provider' => [
+                    'fields' => [
+                        'callback'      => [
+                            'type'      => 'str',
+                            'value'     => $provider->linkCallback,
+                            'caption'   => 'Callback label',
+                            'help'      => 'Callback help',
+                        ],
+                        'client_id'     => [
+                            'type'      => 'text',
+                            'maxlength' => '255',
+                            'value'     => $provider->client_id,
+                            'caption'   => 'ID label',
+                            'help'      => 'ID help',
+                        ],
+                        'client_secret' => [
+                            'type'      => 'text',
+                            'maxlength' => '255',
+                            'value'     => '' == $provider->client_secret ? '' : '********',
+                            'caption'   => 'Secret label',
+                            'help'      => 'Secret help',
+                        ],
+                        'changeData'    => [
+                            'type'      => 'checkbox',
+                            'caption'   => '',
+                            'label'     => 'Change data help',
+                        ],
+                    ],
+                ]
+            ],
+            'btns'   => [
+                'save' => [
+                    'type'  => 'submit',
+                    'value' => __('Save changes'),
+                ],
+            ],
+        ];
+
+        return $form;
+    }
+}

+ 95 - 1
app/Models/Pages/Admin/Update.php

@@ -25,7 +25,7 @@ class Update extends Admin
 {
     const PHP_MIN                    = '8.0.0';
     const REV_MIN_FOR_UPDATE         = 53;
-    const LATEST_REV_WITH_DB_CHANGES = 53;
+    const LATEST_REV_WITH_DB_CHANGES = 55;
     const LOCK_NAME                  = 'lock_update';
     const LOCK_TTL                   = 1800;
     const JSON_OPTIONS               = \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR;
@@ -410,4 +410,98 @@ class Update extends Admin
 #
 #        return null;
 #    }
+
+    /**
+     * rev.54 to rev.55
+     */
+    protected function stageNumber54(array $args): ?int
+    {
+        $config = $this->c->config;
+
+        $config->b_oauth_allow = 0;
+
+        $config->save();
+
+        $schema = [
+            'FIELDS' => [
+                'pr_name'     => ['VARCHAR(25)', false],
+                'pr_allow'    => ['TINYINT(1)', false, 0],
+                'pr_pos'      => ['INT(10) UNSIGNED', false, 0],
+                'pr_cl_id'    => ['VARCHAR(255)', false, ''],
+                'pr_cl_sec'   => ['VARCHAR(255)', false, ''],
+            ],
+            'UNIQUE KEYS' => [
+                'pr_name_idx' => ['pr_name'],
+            ],
+        ];
+        $this->c->DB->createTable('::providers', $schema);
+
+        $schema = [
+            'FIELDS' => [
+                'uid'               => ['INT(10) UNSIGNED', false],
+                'pr_name'           => ['VARCHAR(25)', false],
+                'pu_uid'            => ['VARCHAR(165)', false],
+                'pu_email'          => ['VARCHAR(190)', false, ''],
+                'pu_email_normal'   => ['VARCHAR(190)', false, ''],
+                'pu_email_verified' => ['TINYINT(1)', false, 0],
+            ],
+            'UNIQUE KEYS' => [
+                'pr_name_pu_uid_idx' => ['pr_name', 'pu_uid'],
+            ],
+            'INDEXES' => [
+                'uid_idx'             => ['uid'],
+                'pu_email_normal_idx' => ['pu_email_normal'],
+            ],
+        ];
+        $this->c->DB->createTable('::providers_users', $schema);
+
+        $providers = [
+            'github',
+        ];
+
+        $query = 'INSERT INTO ::providers (pr_name, pr_pos)
+            SELECT tmp.*
+            FROM (SELECT ?s:name AS f1, ?i:pos AS f2) AS tmp
+            WHERE NOT EXISTS (
+                SELECT 1
+                FROM ::providers
+                WHERE pr_name=?s:name
+            )';
+
+        foreach ($providers as $pos => $name) {
+            $vars = [
+                ':name' => $name,
+                ':pos'  => $pos,
+            ];
+
+            $this->c->DB->exec($query, $vars);
+        }
+
+        $coreConfig = new CoreConfig($this->configFile);
+
+        $coreConfig->add(
+            'multiple=>AdminProviders',
+            '\\ForkBB\\Models\\Pages\\Admin\\Providers::class',
+            'AdminOptions'
+        );
+        $coreConfig->add(
+            'shared=>providers',
+            [
+                'class'   => '\\ForkBB\\Models\\Provider\\Providers::class',
+                'drivers' => [
+                    'github' => '\\ForkBB\\Models\\Provider\\Driver\\GitHub::class'
+                ],
+            ],
+            'pms'
+        );
+        $coreConfig->add(
+            'multiple=>RegLog',
+            '\\ForkBB\\Models\\Pages\\RegLog::class',
+            'Register'
+        );
+
+        $coreConfig->save();
+
+        return null;
+    }
 }

+ 4 - 0
app/Models/Pages/Auth.php

@@ -13,12 +13,15 @@ namespace ForkBB\Models\Pages;
 use ForkBB\Core\Validator;
 use ForkBB\Core\Exceptions\MailException;
 use ForkBB\Models\Page;
+use ForkBB\Models\Pages\RegLogTrait;
 use ForkBB\Models\User\User;
 use SensitiveParameter;
 use function \ForkBB\__;
 
 class Auth extends Page
 {
+    use RegLogTrait;
+
     /**
      * Выход пользователя
      */
@@ -105,6 +108,7 @@ class Auth extends Page
         $save               = $v->save ?? true;
         $redirect           = $v->redirect ?? $this->c->Router->validate($ref, 'Index');
         $this->form         = $this->formLogin($username, (bool) $save, $redirect);
+        $this->formOAuth    = $this->reglogForm();
 
         return $this;
     }

+ 82 - 0
app/Models/Pages/RegLog.php

@@ -0,0 +1,82 @@
+<?php
+/**
+ * This file is part of the ForkBB <https://github.com/forkbb>.
+ *
+ * @copyright (c) Visman <mio.visman@yandex.ru, https://github.com/MioVisman>
+ * @license   The MIT License (MIT)
+ */
+
+declare(strict_types=1);
+
+namespace ForkBB\Models\Pages;
+
+use ForkBB\Core\Validator;
+use ForkBB\Core\Exceptions\MailException;
+use ForkBB\Models\Page;
+use ForkBB\Models\User\User;
+use function \ForkBB\__;
+
+class RegLog extends Page
+{
+    /**
+     * Обрабатывает нажатие одной из кнопок провайдеров
+     */
+    public function redirect(): Page
+    {
+        if (
+            1 !== $this->c->config->b_oauth_allow
+            || empty($list = $this->c->providers->active())
+        ) {
+            return $this->c->Message->message('Bad request');
+        }
+
+        $rules = [
+            'token' => 'token:RegLogRedirect',
+        ];
+
+        foreach ($list as $name) {
+            $rules[$name] = 'string';
+        }
+
+        $v = $this->c->Validator->reset()->addRules($rules);
+
+        if (
+            ! $v->validation($_POST)
+            || 1 !== \count($form = $v->getData(false, ['token']))
+        ) {
+            return $this->c->Message->message('Bad request');
+        }
+
+        return $this->c->Redirect->url($this->c->providers->init()->get(\array_key_first($form))->linkAuth);
+    }
+
+    /**
+     * Обрабатывает ответ сервера
+     */
+    public function callback(array $args): Page
+    {
+        if (
+            1 !== $this->c->config->b_oauth_allow
+            || empty($list = $this->c->providers->active())
+            || empty($list[$args['name']])
+        ) {
+            return $this->c->Message->message('Bad request');
+        }
+
+        $this->c->Lang->load('admin_providers');
+
+        $provider = $this->c->providers->init()->get($args['name']);
+
+        if (true !== ($result = $provider->verifyAuth($_GET))) {
+            return $this->c->Message->message($result);
+        }
+
+        if (true !== $provider->reqAccessToken()) {
+            return $this->c->Message->message('Error token');
+        }
+
+        if (true !== $provider->reqUserInfo()) {
+
+        }
+    }
+}

+ 47 - 0
app/Models/Pages/RegLogTrait.php

@@ -0,0 +1,47 @@
+<?php
+/**
+ * This file is part of the ForkBB <https://github.com/forkbb>.
+ *
+ * @copyright (c) Visman <mio.visman@yandex.ru, https://github.com/MioVisman>
+ * @license   The MIT License (MIT)
+ */
+
+declare(strict_types=1);
+
+namespace ForkBB\Models\Pages;
+
+use ForkBB\Models\Model;
+use function \ForkBB\__;
+
+trait RegLogTrait
+{
+    protected function reglogForm(): array
+    {
+        if (
+            1 !== $this->c->config->b_oauth_allow
+            || empty($list = $this->c->providers->active())
+        ) {
+            return [];
+        }
+
+        $this->c->Lang->load('admin_providers');
+
+        $btns = [];
+
+        foreach ($list as $name) {
+            $btns[$name] = [
+                'type'  => 'submit',
+                'value' => __(['Sign in with %s', __($name)]),
+            ];
+        }
+
+        return [
+            'action' => $this->c->Router->link('RegLogRedirect'),
+            'hidden' => [
+                'token' => $this->c->Csrf->create('RegLogRedirect'),
+            ],
+            'sets'   => [],
+            'btns'   => $btns,
+        ];
+    }
+}

+ 4 - 0
app/Models/Pages/Register.php

@@ -13,11 +13,14 @@ namespace ForkBB\Models\Pages;
 use ForkBB\Core\Validator;
 use ForkBB\Core\Exceptions\MailException;
 use ForkBB\Models\Page;
+use ForkBB\Models\Pages\RegLogTrait;
 use ForkBB\Models\User\User;
 use function \ForkBB\__;
 
 class Register extends Page
 {
+    use RegLogTrait;
+
     /**
      * Регистрация
      */
@@ -85,6 +88,7 @@ class Register extends Page
         $this->titles       = 'Register';
         $this->robots       = 'noindex';
         $this->form         = $this->formReg($v);
+        $this->formOAuth    = $this->reglogForm();
 
         return $this;
     }

+ 162 - 0
app/Models/Provider/Driver.php

@@ -0,0 +1,162 @@
+<?php
+/**
+ * This file is part of the ForkBB <https://github.com/forkbb>.
+ *
+ * @copyright (c) Visman <mio.visman@yandex.ru, https://github.com/MioVisman>
+ * @license   The MIT License (MIT)
+ */
+
+declare(strict_types=1);
+
+namespace ForkBB\Models\Provider;
+
+use ForkBB\Core\Container;
+use ForkBB\Models\Model;
+use RuntimeException;
+
+abstract class Driver extends Model
+{
+    /**
+     * Ключ модели для контейнера
+     */
+    protected string $cKey = 'Provider';
+
+    protected string $code;
+
+    protected string $originalName;
+    protected string $authURL;
+    protected string $tokenURL;
+    protected string $userURL;
+    protected string $scope;
+
+    public function __construct(protected string $client_id, protected string $client_secret, Container $c)
+    {
+        parent::__construct($c);
+    }
+
+    protected function setname(string $name):void
+    {
+        if ($this->originalName !== $name) {
+            throw new RuntimeException("Invalid name: {$name}");
+        }
+
+        $this->setAttr('name', $name);
+    }
+
+    protected function getlinkCallback(): string
+    {
+        return $this->c->Router->link('RegLogCallback', ['name' => $this->name]);
+    }
+
+    protected function getclient_id(): string
+    {
+        return $this->client_id;
+    }
+
+    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]),
+            'client_id'     => $this->client_id,
+            'redirect_uri'  => $this->linkCallback,
+        ];
+
+        return $this->authURL . '?' . \http_build_query($params);
+    }
+
+    protected function verifyState(string $state): bool
+    {
+        return $this->c->Csrf->verify($state, $this->originalName, ['ip' => $this->c->user->ip]);
+    }
+
+    public function verifyAuth(array $data): bool|string|array
+    {
+        if (! \is_string($data['code'] ?? null)) {
+            if (\is_string($data['error'] ?? null)) {
+                return ['Provider response: %s', $data['error']];
+            } else {
+                return ['Provider response: %s', 'undefined'];
+            }
+        }
+
+        if (
+            ! \is_string($data['state'] ?? null)
+            || ! $this->verifyState($data['state'])
+        ) {
+            return 'State error';
+        }
+
+        $this->code = $data['code'];
+
+        return true;
+    }
+
+    public function reqAccessToken(): bool
+    {
+        $params = [
+            'grant_type'    => 'authorization_code',
+            'client_id'     => $this->client_id,
+            'client_secret' => $this->client_secret,
+            'code'          => $this->code,
+            'redirect_uri'  => $this->linkCallback,
+        ];
+
+        $ch = \curl_init($this->tokenURL);
+
+        \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Accept: application/json']);
+        \curl_setopt($ch, \CURLOPT_POST, true);
+        \curl_setopt($ch, \CURLOPT_POSTFIELDS, \http_build_query($params));
+        \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, true);
+        \curl_setopt($ch, \CURLOPT_HEADER, false);
+
+        $html = \curl_exec($ch);
+#        $type = \curl_getinfo($ch, \CURLINFO_CONTENT_TYPE);
+
+        \curl_close($ch);
+
+        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'])
+        ) {
+            return false;
+        }
+
+        $this->access_token = $json['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})',
+        ];
+
+        $ch = \curl_init($this->userURL);
+
+        \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);
+
+        $html = \curl_exec($ch);
+#        $type = \curl_getinfo($ch, \CURLINFO_CONTENT_TYPE);
+
+        \curl_close($ch);
+
+        exit(var_dump("<pre>".$html));
+    }
+}

+ 24 - 0
app/Models/Provider/Driver/GitHub.php

@@ -0,0 +1,24 @@
+<?php
+/**
+ * This file is part of the ForkBB <https://github.com/forkbb>.
+ *
+ * @copyright (c) Visman <mio.visman@yandex.ru, https://github.com/MioVisman>
+ * @license   The MIT License (MIT)
+ */
+
+declare(strict_types=1);
+
+namespace ForkBB\Models\Provider\Driver;
+
+use ForkBB\Models\Provider\Driver;
+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';
+
+}

+ 192 - 0
app/Models/Provider/Providers.php

@@ -0,0 +1,192 @@
+<?php
+/**
+ * This file is part of the ForkBB <https://github.com/forkbb>.
+ *
+ * @copyright (c) Visman <mio.visman@yandex.ru, https://github.com/MioVisman>
+ * @license   The MIT License (MIT)
+ */
+
+declare(strict_types=1);
+
+namespace ForkBB\Models\Provider;
+
+use ForkBB\Core\Container;
+use ForkBB\Models\Manager;
+use ForkBB\Models\Provider\Driver;
+use RuntimeException;
+
+class Providers extends Manager
+{
+    const CACHE_KEY = 'providers';
+
+    /**
+     * Ключ модели для контейнера
+     */
+    protected string $cKey = 'Providers';
+
+    protected ?array $cache = null;
+    protected bool $ready = false;
+
+    public function __construct(protected array $drivers, Container $c)
+    {
+        parent::__construct($c);
+    }
+
+    public function init(): self
+    {
+        if (! $this->ready) {
+            foreach ($this->cache() as $cur) {
+                $this->create($cur);
+            }
+
+            $this->ready = true;
+        }
+
+        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'])) {
+            throw new RuntimeException('Provider name missing');
+        }
+
+        if (! isset($this->drivers[$attrs['pr_name']])) {
+            throw new RuntimeException("No driver for '{$attrs['pr_name']}' provider");
+        }
+
+        if ($this->isset($attrs['pr_name'])) {
+            throw new RuntimeException("Driver '{$attrs['pr_name']}' already exists");
+        }
+
+        $class         = $this->drivers[$attrs['pr_name']];
+        $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'];
+
+        $this->set($driver->name, $driver);
+
+        return $driver;
+    }
+
+    public function list(): array
+    {
+        return $this->repository;
+    }
+
+    public function active(): array
+    {
+        $result = [];
+
+        foreach ($this->cache() as $cur) {
+            if (
+                $cur['pr_allow']
+                && '' != $cur['pr_cl_id']
+                && '' != $cur['pr_cl_sec']
+            ) {
+                $result[$cur['pr_name']] = $cur['pr_name'];
+            }
+        }
+
+        return $result;
+    }
+
+    protected function cache(): array
+    {
+        if (! \is_array($this->cache)) {
+            $this->cache = $this->c->Cache->get(self::CACHE_KEY);
+        }
+
+        if (! \is_array($this->cache)) {
+            $query = 'SELECT pr_name, pr_allow, pr_pos, pr_cl_id, pr_cl_sec
+                FROM ::providers
+                ORDER BY pr_pos';
+
+            $stmt = $this->c->DB->query($query);
+
+            while ($cur = $stmt->fetch()) {
+                $this->cache[$cur['pr_name']] = $cur;
+            }
+
+            if (true !== $this->c->Cache->set(self::CACHE_KEY, $this->cache)) {
+                throw new RuntimeException('Unable to write value to cache - ' . self::CACHE_KEY);
+            }
+        }
+
+        return $this->cache;
+    }
+
+    protected function emptyField(string $key, array $sets, array $cache): bool
+    {
+        if (isset($sets[$key])) {
+            return '' == $sets[$key];
+        } else {
+            return '' == $cache[$key];
+        }
+    }
+
+    public function update(array $form): self
+    {
+        $cache  = $this->cache();
+        $fields = $this->c->dbMap->providers;
+
+        foreach ($form as $driver => $sets) {
+            if (! isset($this->drivers[$driver], $cache[$driver])) {
+                continue;
+            }
+
+            $set = $vars = [];
+
+            if (
+                (
+                    ! empty($sets['pr_allow'])
+                    || ! empty($cache[$driver]['pr_allow'])
+                )
+                && (
+                    $this->emptyField('pr_cl_id', $sets, $cache[$driver])
+                    || $this->emptyField('pr_cl_sec', $sets, $cache[$driver])
+                )
+            ) {
+                $sets['pr_allow'] = 0;
+            }
+
+            foreach ($sets as $name => $value) {
+                if (
+                    ! isset($fields[$name])
+                    || $cache[$driver][$name] == $value
+                ) {
+                    continue;
+                }
+
+                $vars[] = $value;
+                $set[]  = $name . '=?' . $fields[$name];
+            }
+
+            if (empty($set)) {
+                continue;
+            }
+
+            $vars[] = $driver;
+            $set    = \implode(', ', $set);
+            $query  = "UPDATE ::providers
+                SET {$set}
+                WHERE pr_name=?s";
+
+            $this->c->DB->exec($query, $vars);
+        }
+
+        if (true !== $this->c->Cache->delete(self::CACHE_KEY)) {
+            throw new RuntimeException('Unable to delete cache - ' . self::CACHE_KEY);
+        }
+
+        return $this;
+    }
+}

+ 8 - 0
app/config/main.dist.php

@@ -163,6 +163,12 @@ return [
         'subscriptions' => \ForkBB\Models\Subscription\Subscription::class,
         'bbcode'        => '@BBCodeListModel:init',
         'pms'           => \ForkBB\Models\PM\PM::class,
+        'providers'     => [
+            'class'   => \ForkBB\Models\Provider\Providers::class,
+            'drivers' => [
+                'github' => \ForkBB\Models\Provider\Driver\GitHub::class,
+            ],
+        ],
 
         'Csrf' => [
             'class'  => \ForkBB\Core\Csrf::class,
@@ -333,6 +339,7 @@ return [
         'Userlist'           => \ForkBB\Models\Pages\Userlist::class,
         'Search'             => \ForkBB\Models\Pages\Search::class,
         'Register'           => \ForkBB\Models\Pages\Register::class,
+        'RegLog'             => \ForkBB\Models\Pages\RegLog::class,
         'Redirect'           => \ForkBB\Models\Pages\Redirect::class,
         'Maintenance'        => \ForkBB\Models\Pages\Maintenance::class,
         'Ban'                => \ForkBB\Models\Pages\Ban::class,
@@ -360,6 +367,7 @@ return [
         'AdminIndex'         => \ForkBB\Models\Pages\Admin\Index::class,
         'AdminStatistics'    => \ForkBB\Models\Pages\Admin\Statistics::class,
         'AdminOptions'       => \ForkBB\Models\Pages\Admin\Options::class,
+        'AdminProviders'     => \ForkBB\Models\Pages\Admin\Providers::class,
         'AdminCategories'    => \ForkBB\Models\Pages\Admin\Categories::class,
         'AdminForums'        => \ForkBB\Models\Pages\Admin\Forums::class,
         'AdminGroups'        => \ForkBB\Models\Pages\Admin\Groups::class,

+ 9 - 0
app/lang/en/admin_options.po

@@ -425,3 +425,12 @@ msgstr "Allow private messages"
 
 msgid "Allow PM help"
 msgstr "Settings for individual groups are available in <a href="%2$s">%1$s</a>."
+
+msgid "Allow oauth label"
+msgstr "OAuth-authorization"
+
+msgid "Allow oauth help"
+msgstr "Allow users to login/register on this bulletin board using accounts on third-party resources."
+
+msgid "Configure providers"
+msgstr "Configure providers"

+ 70 - 0
app/lang/en/admin_providers.po

@@ -0,0 +1,70 @@
+#
+msgid ""
+msgstr ""
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+"Project-Id-Version: ForkBB\n"
+"POT-Creation-Date: \n"
+"PO-Revision-Date: \n"
+"Last-Translator: \n"
+"Language-Team: ForkBB <mio.visman@yandex.ru>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: en\n"
+
+msgid "OAuth authorization disabled"
+msgstr "OAuth authorization <a href=\"%s\">disabled</a>."
+
+msgid "Providers"
+msgstr "Providers"
+
+msgid "Provider label"
+msgstr "Provider"
+
+msgid "Position label"
+msgstr "Position"
+
+msgid "Allow label"
+msgstr "Allow"
+
+msgid "Providers updated redirect"
+msgstr "Provider table updated."
+
+msgid "Provider updated redirect"
+msgstr "Provider data updated."
+
+msgid "github"
+msgstr "GitHub"
+
+msgid "Callback label"
+msgstr "Callback URI"
+
+msgid "Callback help"
+msgstr "The URI that the user is returned to after they have granted or denied access to the application (corresponds to the redirect_uri of the OAuth protocol). It must be specified when registering your application on the provider's server."
+
+msgid "ID label"
+msgstr "Client ID"
+
+msgid "ID help"
+msgstr "Identifier that you can get when registering your application on the provider's server."
+
+msgid "Secret label"
+msgstr "Client secret"
+
+msgid "Secret help"
+msgstr "The secret (password) that you can get when registering your application on the provider's server."
+
+msgid "Change data help"
+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 "State error"
+msgstr "Error in state parameter."
+
+msgid "Error token"
+msgstr "Error request access token"

+ 10 - 1
app/lang/ru/admin_options.po

@@ -280,7 +280,7 @@ msgid "SMTP password label"
 msgstr "SMTP password"
 
 msgid "SMTP change password help"
-msgstr "Установите галку, если хотите поменять или удалить пароль"
+msgstr "Установите флажок, если хотите поменять или удалить пароль"
 
 msgid "SMTP password help"
 msgstr "Пароль для сервера SMTP. Заполняйте только если сервер этого требует."
@@ -425,3 +425,12 @@ msgstr "Разрешить личные сообщения"
 
 msgid "Allow PM help"
 msgstr "Настройки для отдельных групп доступны в <a href="%2$s">%1$s</a>."
+
+msgid "Allow oauth label"
+msgstr "OAuth-авторизация"
+
+msgid "Allow oauth help"
+msgstr "Разрешить пользователям вход/регистрацию на форуме с помощью аккаунтов на сторонних ресурсах."
+
+msgid "Configure providers"
+msgstr "Настроить провайдеров"

+ 70 - 0
app/lang/ru/admin_providers.po

@@ -0,0 +1,70 @@
+#
+msgid ""
+msgstr ""
+"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
+"Project-Id-Version: ForkBB\n"
+"POT-Creation-Date: \n"
+"PO-Revision-Date: \n"
+"Last-Translator: \n"
+"Language-Team: ForkBB <mio.visman@yandex.ru>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: ru\n"
+
+msgid "OAuth authorization disabled"
+msgstr "OAuth-авторизация <a href=\"%s\">выключена</a>."
+
+msgid "Providers"
+msgstr "Провайдеры"
+
+msgid "Provider label"
+msgstr "Провайдер"
+
+msgid "Position label"
+msgstr "Позиция"
+
+msgid "Allow label"
+msgstr "Вкл."
+
+msgid "Providers updated redirect"
+msgstr "Таблица провайдеров обновлена."
+
+msgid "Provider updated redirect"
+msgstr "Данные провайдера обновлены."
+
+msgid "github"
+msgstr "GitHub"
+
+msgid "Callback label"
+msgstr "URI перенаправления"
+
+msgid "Callback help"
+msgstr "Адрес, на который пользователь возращается после того, как он разрешил или отказал приложению в доступе (соответствует redirect_uri протокола OAuth). Его нужно указать при регистрации своего приложения на сервере провайдера."
+
+msgid "ID label"
+msgstr "Идентификатор клиента"
+
+msgid "ID help"
+msgstr "Идентификатор (ID), который можно получить при регистрации своего приложения на сервере провайдера."
+
+msgid "Secret label"
+msgstr "Секрет клиента"
+
+msgid "Secret help"
+msgstr "Секрет (пароль), который можно получить при регистрации своего приложения на сервере провайдера."
+
+msgid "Change data help"
+msgstr "Установите флажок, если хотите изменить данные"
+
+msgid "Sign in with %s"
+msgstr "Войти с помощью %s"
+
+msgid "Provider response: %s"
+msgstr "Ответ провайдера: %s."
+
+msgid "State error"
+msgstr "Ошибка в параметре state."
+
+msgid "Error token"
+msgstr "Ошибка запроса токена доступа"

+ 7 - 0
app/templates/login.forkbb.php

@@ -14,3 +14,10 @@
     @endif
     </section>
 @endif
+@if ($form = $p->formOAuth)
+    <div id="fork-oauth" class="f-main">
+      <div class="f-fdiv f-lrdiv">
+    @include ('layouts/form')
+      </div>
+    </div>
+@endif

+ 7 - 0
app/templates/register.forkbb.php

@@ -7,3 +7,10 @@
       </div>
     </section>
 @endif
+@if ($form = $p->formOAuth)
+    <div id="fork-oauth" class="f-main">
+      <div class="f-fdiv f-lrdiv">
+    @include ('layouts/form')
+      </div>
+    </div>
+@endif

+ 33 - 0
public/style/ForkBB/admin.css

@@ -208,7 +208,9 @@
 }
 
 #forka .f-fs-inline dl {
+  display: flex;
   flex-direction: column;
+  justify-content: space-between;
 }
 
 #forka .f-fs-inline dt {
@@ -1026,3 +1028,34 @@
     flex-wrap: wrap;
   }
 }
+
+/****************************************/
+/* Админка/Провайдеры                   */
+/****************************************/
+#forka .f-field-provider.f-field-name {
+  width: calc(100% - 8rem);
+}
+
+#forka .f-field-provider.f-field-position {
+  width: 5rem;
+}
+
+#forka .f-field-provider.f-field-allow {
+  width: 3rem;
+  overflow: hidden;
+  text-align: center;
+}
+
+#forka .f-field-provider.f-field-name .f-ybtn {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+#forka .f-field-provider.f-field-allow .f-ychk {
+  margin: 0;
+}
+
+#forka .f-field-provider.f-field-allow .f-flblch {
+  height: 2rem;
+}