С ИИ написал авторизацию. Нужно в VK ID бизнес зарегистрироваться. Бизнесменом быть необязательно, сказали в поддержке, просто не будет расширенных функций.
<!-- Place this code in your Joomla login template -->
<div class="auth-section" id="authSection">
<button class="auth-btn" id="authLoginBtn">
<svg class="auth-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
</svg>
Вход
</button>
<div id="authLoggedIn" style="display:none; align-items:center; gap:8px;">
<span id="authUserIcon" style="cursor:pointer; display:inline-flex; align-items:center;">
<svg class="auth-icon" width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
</svg>
</span>
<button class="auth-btn auth-btn-logout" id="authLogoutBtn">
<svg class="auth-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/>
</svg>
Выход
</button>
</div>
</div>
<script>
(function() {
'use strict';
var loginBtn = document.getElementById('authLoginBtn');
var logoutBtn = document.getElementById('authLogoutBtn');
// =============================================
// 1. МОДАЛЬНОЕ ОКНО
// =============================================
var overlay = document.createElement('div');
overlay.style.cssText = 'display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.6);z-index:2147483647;justify-content:center;align-items:center;font-family:Arial,sans-serif;';
var modal = document.createElement('div');
modal.style.cssText = 'background:#fff;border-radius:16px;padding:32px;max-width:400px;width:90%;position:relative;box-shadow:0 20px 60px rgba(0,0,0,0.3);text-align:center;';
var closeBtn = document.createElement('button');
closeBtn.innerHTML = '×';
closeBtn.style.cssText = 'position:absolute;top:12px;right:16px;font-size:28px;cursor:pointer;color:#999;background:none;border:none;line-height:1;padding:4px 8px;z-index:10;';
var title = document.createElement('div');
title.style.cssText = 'font-size:20px;font-weight:600;margin-bottom:20px;color:#333;';
title.textContent = 'Вход на сайт';
var vkContainer = document.createElement('div');
vkContainer.id = 'vkid-auth-box';
vkContainer.style.cssText = 'min-height:44px;';
var terms = document.createElement('div');
terms.style.cssText = 'margin-top:16px;font-size:12px;color:#999;line-height:1.4;';
terms.innerHTML = 'Нажимая «Войти», вы принимаете <a href="/privacy-policy" target="_blank" style="color:#4a76a8;text-decoration:underline;">условия использования сервиса</a>.';
modal.appendChild(closeBtn);
modal.appendChild(title);
modal.appendChild(vkContainer);
modal.appendChild(terms);
overlay.appendChild(modal);
document.body.appendChild(overlay);
// =============================================
// 2. ОТКРЫТИЕ / ЗАКРЫТИЕ
// =============================================
function openModal() {
overlay.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function closeModal() {
overlay.style.display = 'none';
document.body.style.overflow = '';
}
if (loginBtn) {
loginBtn.addEventListener('click', openModal);
}
closeBtn.addEventListener('click', function(e) {
e.stopPropagation();
closeModal();
});
overlay.addEventListener('click', function(e) {
if (e.target === overlay) closeModal();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && overlay.style.display === 'flex') closeModal();
});
// =============================================
// 3. ПРОВЕРКА АВТОРИЗАЦИИ + ВЫХОД + ТУЛТИП
// =============================================
var loggedInBlock = document.getElementById('authLoggedIn');
var isLoggedIn = (document.cookie.indexOf('joomla_user_state=logged_in') !== -1);
if (isLoggedIn) {
if (loginBtn) loginBtn.style.display = 'none';
if (loggedInBlock) loggedInBlock.style.display = 'flex';
var loginMatch = document.cookie.match(/joomla_user_login=([^;]+)/);
var userLogin = loginMatch ? decodeURIComponent(loginMatch[1]) : '';
var tip = document.createElement('div');
tip.style.cssText = 'display:none;position:fixed;background:#f0f0f0;color:#333;padding:10px 14px;border-radius:8px;font-size:13px;z-index:2147483647;box-shadow:0 2px 12px rgba(0,0,0,0.2);font-family:Arial,sans-serif;';
var tipLabel = document.createElement('div');
tipLabel.style.cssText = 'margin-bottom:6px;color:#666;font-size:12px;';
tipLabel.textContent = 'Ваш логин:';
var tipRow = document.createElement('div');
tipRow.style.cssText = 'display:flex;align-items:center;gap:8px;';
var tipLogin = document.createElement('span');
tipLogin.style.cssText = 'font-weight:600;font-size:14px;user-select:all;';
tipLogin.textContent = userLogin;
var tipCopy = document.createElement('button');
tipCopy.style.cssText = 'background:#fff;border:1px solid #ccc;border-radius:4px;padding:3px 8px;font-size:12px;cursor:pointer;color:#333;white-space:nowrap;';
tipCopy.textContent = 'Скопировать';
tipRow.appendChild(tipLogin);
tipRow.appendChild(tipCopy);
tip.appendChild(tipLabel);
tip.appendChild(tipRow);
document.body.appendChild(tip);
var tipOpen = false;
var userIcon = document.getElementById('authUserIcon');
function showTip() {
var rect = userIcon.getBoundingClientRect();
tip.style.display = 'block';
var tipW = tip.offsetWidth;
var left = rect.left + (rect.width / 2) - (tipW / 2);
if (left + tipW > window.innerWidth - 8) left = window.innerWidth - tipW - 8;
if (left < 8) left = 8;
tip.style.left = left + 'px';
tip.style.top = (rect.bottom + 8) + 'px';
tipOpen = true;
}
function hideTip() {
tip.style.display = 'none';
tipOpen = false;
tipCopy.textContent = 'Скопировать';
}
if (userIcon) {
userIcon.addEventListener('click', function(e) {
e.stopPropagation();
tipOpen ? hideTip() : showTip();
});
}
tipCopy.addEventListener('click', function(e) {
e.stopPropagation();
if (navigator.clipboard) {
navigator.clipboard.writeText(userLogin).then(function() {
tipCopy.textContent = '✓ Скопировано';
setTimeout(function() { tipCopy.textContent = 'Скопировать'; }, 2000);
});
} else {
var ta = document.createElement('textarea');
ta.value = userLogin;
ta.style.cssText = 'position:fixed;opacity:0;';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
tipCopy.textContent = '✓ Скопировано';
setTimeout(function() { tipCopy.textContent = 'Скопировать'; }, 2000);
}
});
tip.addEventListener('click', function(e) { e.stopPropagation(); });
document.addEventListener('click', function() { if (tipOpen) hideTip(); });
logoutBtn.addEventListener('click', function() {
fetch('/index.php?option=com_ajax&plugin=vkid&group=system&format=raw&task=logout', {
method: 'GET',
credentials: 'same-origin'
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
document.cookie = 'joomla_user_state=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
document.cookie = 'joomla_user_login=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
window.location.reload();
}
})
.catch(function() {
document.cookie = 'joomla_user_state=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
document.cookie = 'joomla_user_login=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
window.location.reload();
});
});
return;
}
// =============================================
// 4. VK ID SDK
// =============================================
var sdkScript = document.createElement('script');
sdkScript.src = 'https://unpkg.com/@vkid/sdk@<3.0.0/dist-sdk/umd/index.js';
sdkScript.onload = initVKID;
sdkScript.onerror = function() { console.error('VK ID SDK: failed to load'); };
document.head.appendChild(sdkScript);
function initVKID() {
if (!window.VKIDSDK) return;
var VKID = window.VKIDSDK;
var SITE_BASE = 'https://ваш-сайт.ru';
var APP_ID = ваш app_id;
fetch(SITE_BASE + '/index.php?option=com_ajax&plugin=vkid&group=system&format=raw&task=init', {
method: 'GET',
credentials: 'same-origin'
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
VKID.Config.init({
app: APP_ID,
redirectUrl: SITE_BASE + '/vkid-callback.php',
responseMode: VKID.ConfigResponseMode.Redirect,
source: VKID.ConfigSource.LOWCODE,
scope: '',
codeChallenge: data.code_challenge,
codeChallengeMethod: 'S256',
state: data.state,
});
var container = document.getElementById('vkid-auth-box');
if (!container) return;
var oneTap = new VKID.OneTap();
oneTap.render({
container: container,
showAlternativeLogin: true,
oauthList: [VKID.OAuthName.OK, VKID.OAuthName.MAIL],
scheme: VKID.Scheme.LIGHT,
lang: VKID.Languages.RUS,
styles: { borderRadius: 8 }
});
})
.catch(function(err) { console.error('VKID error:', err); });
}
})();
</script>
<?php
namespace Joomla\Plugin\System\Vkid\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Factory;
use Joomla\CMS\User\User;
use Joomla\CMS\User\UserHelper;
use Joomla\CMS\Http\HttpFactory;
use Joomla\Event\SubscriberInterface;
class Vkid extends CMSPlugin implements SubscriberInterface
{
private const VK_AUTH_URL = 'https://id.vk.ru/oauth2/auth';
private const VK_USERINFO_URL = 'https://id.vk.ru/oauth2/user_info';
private const SESSION_PREFIX = 'plg_vkid_';
private const ENCRYPT_KEY = 'your_secret_key_2026';
private const ENCRYPT_IV_SEED = 'your_iv_stable';
public static function getSubscribedEvents(): array
{
return [
'onAfterInitialise' => 'onAfterInitialise',
'onAjaxVkid' => 'onAjaxVkid',
];
}
public function onAfterInitialise(): void
{
try {
$app = $this->getApplication();
$code = $app->getInput()->cookie->getString('vkid_code', '');
if (empty($code)) {
return;
}
$state = $app->getInput()->cookie->getString('vkid_state', '');
$deviceId = $app->getInput()->cookie->getString('vkid_device_id', '');
setcookie('vkid_code', '', time() - 3600, '/');
setcookie('vkid_state', '', time() - 3600, '/');
setcookie('vkid_device_id', '', time() - 3600, '/');
if (empty($state)) {
return;
}
$this->processVkAuth($code, $state, $deviceId);
} catch (\Exception $e) {
$this->writeLog('ERROR: ' . $e->getMessage());
}
}
public function onAjaxVkid(): void
{
$task = $this->getApplication()->getInput()->getCmd('task', '');
try {
if ($task === 'init') {
$this->handleInit();
} elseif ($task === 'logout') {
$this->handleLogout();
} else {
$this->respondJson(['success' => false, 'error' => 'Invalid task']);
}
} catch (\Exception $e) {
$this->writeLog('ERROR: ' . $e->getMessage());
$this->respondJson(['success' => false, 'error' => $e->getMessage()]);
}
}
private function handleInit(): void
{
$session = Factory::getApplication()->getSession();
$codeVerifier = $this->generateRandomString(64);
$codeChallenge = $this->generateCodeChallenge($codeVerifier);
$state = $this->generateRandomString(43);
$session->set(self::SESSION_PREFIX . 'code_verifier', $codeVerifier);
$session->set(self::SESSION_PREFIX . 'state', $state);
$this->respondJson([
'success' => true,
'code_challenge' => $codeChallenge,
'state' => $state,
]);
}
private function handleLogout(): void
{
$app = $this->getApplication();
$user = $app->getIdentity();
if ($user && !$user->guest) {
$app->logout($user->id);
}
setcookie('joomla_user_state', '', time() - 3600, '/');
setcookie('joomla_user_login', '', time() - 3600, '/');
$this->respondJson(['success' => true]);
}
private function processVkAuth(string $code, string $state, string $deviceId): void
{
$app = $this->getApplication();
$session = $app->getSession();
$codeVerifier = $session->get(self::SESSION_PREFIX . 'code_verifier', '');
if (empty($codeVerifier)) {
return;
}
$session->clear(self::SESSION_PREFIX . 'code_verifier');
$session->clear(self::SESSION_PREFIX . 'state');
$tokenData = $this->exchangeCodeForToken($code, $deviceId, $codeVerifier, $state);
if (empty($tokenData['access_token'])) {
return;
}
$userInfo = $this->getUserInfo($tokenData['access_token']);
$vkUserId = $userInfo['user_id'] ?? '';
if (empty($vkUserId)) {
return;
}
$joomlaUser = $this->findOrCreateUser($vkUserId);
$this->loginUser($joomlaUser);
$cookieExpires = time() + 30 * 24 * 3600;
setcookie('joomla_user_state', 'logged_in', [
'expires' => $cookieExpires,
'path' => '/',
'secure' => true,
'httponly' => false,
'samesite' => 'Lax',
]);
setcookie('joomla_user_login', $joomlaUser->username, [
'expires' => $cookieExpires,
'path' => '/',
'secure' => true,
'httponly' => false,
'samesite' => 'Lax',
]);
$newSessionId = $app->getSession()->getId();
$cookieName = $this->getSessionCookieName();
if (!empty($cookieName)) {
setcookie($cookieName, $newSessionId, [
'expires' => time() + 30 * 24 * 3600,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
}
$app->getSession()->close();
header('Location: /');
exit;
}
private function getSessionCookieName(): string
{
foreach ($_COOKIE as $name => $value) {
if (preg_match('/^[a-f0-9]{32}$/', $name)) {
return $name;
}
}
return '';
}
private function exchangeCodeForToken(
string $code,
string $deviceId,
string $codeVerifier,
string $state
): array {
$clientId = $this->params->get('client_id', '');
$siteDomain = $this->params->get('site_domain', '');
$redirectUri = 'https://' . $siteDomain . '/vkid-callback.php';
$http = HttpFactory::getHttp();
$response = $http->post(
self::VK_AUTH_URL,
http_build_query([
'grant_type' => 'authorization_code',
'code' => $code,
'code_verifier' => $codeVerifier,
'client_id' => $clientId,
'device_id' => $deviceId,
'state' => $state,
'redirect_uri' => $redirectUri,
]),
['Content-Type' => 'application/x-www-form-urlencoded']
);
$data = json_decode($response->body, true);
return (!empty($data) && !isset($data['error'])) ? $data : [];
}
private function getUserInfo(string $accessToken): array
{
$http = HttpFactory::getHttp();
$response = $http->post(
self::VK_USERINFO_URL,
http_build_query([
'client_id' => $this->params->get('client_id', ''),
'access_token' => $accessToken,
]),
['Content-Type' => 'application/x-www-form-urlencoded']
);
$data = json_decode($response->body, true);
return $data['user'] ?? [];
}
private function encryptVkId(string $vkUserId): string
{
$key = hash('sha256', self::ENCRYPT_KEY, true);
$iv = substr(hash('sha256', self::ENCRYPT_IV_SEED, true), 0, 16);
$encrypted = openssl_encrypt($vkUserId, 'aes-256-cbc', $key, 0, $iv);
return rtrim(strtr($encrypted, '+/', '-_'), '=');
}
private function decryptVkId(string $encoded): string
{
$key = hash('sha256', self::ENCRYPT_KEY, true);
$iv = substr(hash('sha256', self::ENCRYPT_IV_SEED, true), 0, 16);
$len = strlen($encoded);
$padded = strtr($encoded, '-_', '+/') . str_repeat('=', (4 - $len % 4) % 4);
$result = openssl_decrypt($padded, 'aes-256-cbc', $key, 0, $iv);
return ($result !== false) ? $result : '';
}
private function findOrCreateUser(string $vkUserId): User
{
$hash = $this->encryptVkId($vkUserId);
$username = 'u_' . $hash;
$siteDomain = $this->params->get('site_domain', 'your.ru');
$userId = UserHelper::getUserId($username);
if ($userId) {
return User::getInstance($userId);
}
$db = Factory::getContainer()->get('DatabaseDriver');
$password = UserHelper::hashPassword(UserHelper::genRandomPassword(32));
$now = Factory::getDate()->toSql();
$query = $db->getQuery(true)
->insert($db->quoteName('#__users'))
->columns($db->quoteName(['name', 'username', 'email', 'password', 'block', 'registerDate', 'params']))
->values(implode(',', [
$db->quote('Пользователь'),
$db->quote($username),
$db->quote($username . '@' . $siteDomain),
$db->quote($password),
0,
$db->quote($now),
$db->quote('{}'),
]));
$db->setQuery($query);
$db->execute();
$newId = (int) $db->insertid();
$query = $db->getQuery(true)
->insert($db->quoteName('#__user_usergroup_map'))
->columns([$db->quoteName('user_id'), $db->quoteName('group_id')])
->values($newId . ', 2');
$db->setQuery($query);
$db->execute();
return User::getInstance($newId);
}
private function loginUser(User $user): void
{
$app = $this->getApplication();
$db = Factory::getContainer()->get('DatabaseDriver');
if ($user->block) {
return;
}
$tempPassword = bin2hex(random_bytes(16));
$hash = UserHelper::hashPassword($tempPassword);
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__users'))
->set($db->quoteName('password') . ' = ' . $db->quote($hash))
->where($db->quoteName('id') . ' = ' . (int) $user->id)
);
$db->execute();
$app->login(
['username' => $user->username, 'password' => $tempPassword],
['remember' => false]
);
$newHash = UserHelper::hashPassword(bin2hex(random_bytes(16)));
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__users'))
->set($db->quoteName('password') . ' = ' . $db->quote($newHash))
->where($db->quoteName('id') . ' = ' . (int) $user->id)
);
$db->execute();
}
private function generateCodeChallenge(string $v): string
{
return rtrim(strtr(base64_encode(hash('sha256', $v, true)), '+/', '-_'), '=');
}
private function generateRandomString(int $len = 64): string
{
$c = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
$m = strlen($c) - 1;
$r = '';
for ($i = 0; $i < $len; $i++) {
$r .= $c[random_int(0, $m)];
}
return $r;
}
private function respondJson(array $data): void
{
$app = $this->getApplication();
$app->setHeader('Content-Type', 'application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
$app->close();
}
private function writeLog(string $msg): void
{
$f = JPATH_ROOT . '/logs/vkid_debug.log';
@file_put_contents($f, '[' . date('Y-m-d H:i:s') . '] ' . $msg . "\n", FILE_APPEND);
}
}
<?php
/**
* VK ID OAuth2 Callback
* Сохраняет данные в cookie и редиректит на главную.
* Плагин подхватит при загрузке страницы.
*/
$code = $_GET['code'] ?? '';
$state = $_GET['state'] ?? '';
$deviceId = $_GET['device_id'] ?? '';
if (empty($code)) {
header('Location: /');
exit;
}
// Сохраняем в cookie (короткоживущие, httponly)
$opts = [
'expires' => time() + 120, // 2 минуты
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
];
// Cookie для JavaScript — чтобы знал что пользователь залогинен
setcookie('joomla_user_state', 'logged_in', [
'expires' => time() + 30 * 24 * 3600,
'path' => '/',
'secure' => true,
'httponly' => false, // JavaScript должен читать!
'samesite' => 'Lax',
]);
setcookie('vkid_code', $code, $opts);
setcookie('vkid_state', $state, $opts);
setcookie('vkid_device_id', $deviceId, $opts);
// Редирект на главную — плагин подхватит cookie
header('Location: /');
exit;
В приложении на сайте VK ID указываем Доверенный Redirect URL = https://ваш-сайт.ru/vkid-callback.php