Initial commit: Auth Server Base

This commit is contained in:
Bitrix user
2026-03-06 19:26:11 +03:00
commit 97bb196b29
39 changed files with 2272 additions and 0 deletions

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
cat << 'EOF' > .gitignore
# 1. Игнорируем ВСЁ в корне сайта
/*
# 2. Разрешаем (белый список) только нужные корневые папки
!/api/
!/auth/
!/personal/
!/register/
!/local/
!/.gitignore
# 3. Заходим внутрь local, но игнорируем там всё лишнее
/local/*
!/local/components/
!/local/modules/
# 4. Внутри modules оставляем ТОЛЬКО conmed.authserver
/local/modules/*
!/local/modules/conmed.authserver/
# 5. Внутри components оставляем ТОЛЬКО conmed
/local/components/*
!/local/components/conmed/
EOF

3
api/oauth/authorize.php Normal file
View File

@@ -0,0 +1,3 @@
<?php
require($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
if(\Bitrix\Main\Loader::includeModule('conmed.authserver')) { \Conmed\Authserver\Api::authorizeAction(); }

View File

@@ -0,0 +1,3 @@
<?php
require($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
if(\Bitrix\Main\Loader::includeModule('conmed.authserver')) { \Conmed\Authserver\Api::passwordAction(); }

2
api/oauth/config.php Normal file
View File

@@ -0,0 +1,2 @@
<?php
// Data moved to conmed.authserver module settings

View File

@@ -0,0 +1,3 @@
<?php
require($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
if(\Bitrix\Main\Loader::includeModule('conmed.authserver')) { \Conmed\Authserver\Api::dictionariesAction(); }

3
api/oauth/groups.php Normal file
View File

@@ -0,0 +1,3 @@
<?php
require($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
if(\Bitrix\Main\Loader::includeModule('conmed.authserver')) { \Conmed\Authserver\Api::groupsAction(); }

3
api/oauth/refresh.php Normal file
View File

@@ -0,0 +1,3 @@
<?php
require($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
if(\Bitrix\Main\Loader::includeModule('conmed.authserver')) { \Conmed\Authserver\Api::refreshAction(); }

3
api/oauth/register.php Normal file
View File

@@ -0,0 +1,3 @@
<?php
require($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
if(\Bitrix\Main\Loader::includeModule('conmed.authserver')) { \Conmed\Authserver\Api::registerAction(); }

3
api/oauth/token.php Normal file
View File

@@ -0,0 +1,3 @@
<?php
require($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
if(\Bitrix\Main\Loader::includeModule('conmed.authserver')) { \Conmed\Authserver\Api::tokenAction(); }

View File

@@ -0,0 +1,3 @@
<?php
require($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
if(\Bitrix\Main\Loader::includeModule('conmed.authserver')) { \Conmed\Authserver\Api::updateAction(); }

3
api/oauth/user.php Normal file
View File

@@ -0,0 +1,3 @@
<?php
require($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
if(\Bitrix\Main\Loader::includeModule('conmed.authserver')) { \Conmed\Authserver\Api::userAction(); }

5
api/oauth/verify.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
require($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
if(\Bitrix\Main\Loader::includeModule('conmed.authserver')) {
\Conmed\Authserver\Api::verifyAction();
}

152
auth/index.php Normal file
View File

@@ -0,0 +1,152 @@
<?
require($_SERVER["DOCUMENT_ROOT"]."/bitrix/header.php");
// Если пользователь уже вошел
if ($USER->IsAuthorized() && !empty($_REQUEST["backurl"])) {
LocalRedirect($_REQUEST["backurl"]);
}
?>
<!-- Стили специально для страницы авторизации, чтобы перебить стили сайта -->
<style>
/* Красим фон страницы в серый (как на референсе) */
body, .main-wrapper, .page-content {
background-color: #f0f2f5 !important;
}
/* Обертка для центрирования */
.conmed-auth-wrapper {
min-height: calc(100vh - 200px); /* Высота экрана минус хедер/футер */
display: flex;
align-items: center;
justify-content: center;
padding: 40px 15px;
background-color: #f0f2f5;
}
/* Сама карточка */
.conmed-auth-card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 15px rgba(0,0,0,0.08);
padding: 40px;
width: 100%;
max-width: 400px; /* Ширина как на референсе */
text-align: center;
}
/* Логотип */
.conmed-auth-logo {
max-width: 180px;
margin-bottom: 25px;
display: inline-block;
}
/* Заголовки */
.conmed-auth-title {
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 30px;
}
/* Инпуты */
.conmed-form-control {
height: 48px;
border-radius: 6px;
border: 1px solid #dfe1e5;
font-size: 15px;
padding-left: 15px;
margin-bottom: 20px;
width: 100%;
display: block;
box-sizing: border-box;
transition: border-color 0.2s;
}
.conmed-form-control:focus {
border-color: #1a73e8; /* Синий цвет при клике */
outline: none;
}
/* Кнопка */
.conmed-btn {
width: 100%;
height: 48px;
background-color: #1a73e8;
color: #fff;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.conmed-btn:hover {
background-color: #1557b0;
}
/* Ссылки */
.conmed-link {
color: #1a73e8;
text-decoration: none;
font-size: 14px;
margin-top: 15px;
display: inline-block;
}
.conmed-link:hover {
text-decoration: underline;
}
.error-msg {
color: #d93025;
font-size: 13px;
margin-bottom: 15px;
text-align: left;
background: #fce8e6;
padding: 10px;
border-radius: 4px;
}
</style>
<div class="conmed-auth-wrapper">
<?
// ---------------------------------------------------------
// 1. ВОССТАНОВЛЕНИЕ ПАРОЛЯ
// ---------------------------------------------------------
if ($_REQUEST["forgot_password"] == "yes") {
$APPLICATION->SetTitle("Восстановление пароля");
$APPLICATION->IncludeComponent("bitrix:system.auth.forgotpasswd", "bootstrap", Array());
}
// ---------------------------------------------------------
// 2. РЕГИСТРАЦИЯ
// ---------------------------------------------------------
elseif ($_REQUEST["register"] == "yes" || $_REQUEST["register_submit_button"] == "Зарегистрироваться") {
$APPLICATION->SetTitle("Регистрация");
$APPLICATION->IncludeComponent("bitrix:main.register", "bootstrap", Array(
"SHOW_FIELDS" => array("EMAIL", "NAME", "LAST_NAME", "PASSWORD", "CONFIRM_PASSWORD"),
"REQUIRED_FIELDS" => array("EMAIL", "NAME", "PASSWORD", "CONFIRM_PASSWORD"),
"AUTH" => "Y",
"USE_BACKURL" => "Y",
"SUCCESS_PAGE" => "/personal/",
"SET_TITLE" => "Y",
"USER_PROPERTY" => array(),
));
}
// ---------------------------------------------------------
// 3. АВТОРИЗАЦИЯ (ВХОД)
// ---------------------------------------------------------
else {
$APPLICATION->SetTitle("Вход");
$APPLICATION->IncludeComponent("bitrix:system.auth.authorize", "bootstrap", Array(
"AUTH_RESULT" => $APPLICATION->arAuthResult,
"REGISTER_URL" => "/register/", // Явно указываем ссылку
"FORGOT_PASSWORD_URL" => "?forgot_password=yes", // Явно указываем ссылку
"PROFILE_URL" => "/personal/",
));
}
?>
</div>
<?require($_SERVER["DOCUMENT_ROOT"]."/bitrix/footer.php");?>

View File

@@ -0,0 +1,103 @@
<?php
if(!defined("B_PROLOG_INCLUDED") || B_PROLOG_INCLUDED!==true) die();
use Bitrix\Main\Loader;
use Bitrix\Main\Context;
use Bitrix\Main\Config\Option;
use Bitrix\Main\Type\DateTime;
use Conmed\Authserver\Api;
class ConmedSsoRegister extends CBitrixComponent {
public function executeComponent() {
if (!Loader::includeModule("conmed.authserver")) {
ShowError("Модуль SSO не найден");
return;
}
$request = Context::getCurrent()->getRequest();
// 1. Обработка AJAX регистрации
if ($request->isPost() && $request->get('ajax_reg') == 'y') {
$this->handleRegister($request);
return;
}
// 2. Подготовка данных для формы через InternalDataTrait
$geo = Api::getGeoForComponent();
$this->arResult['COUNTRIES'] = $geo['countries'];
$this->arResult['CITIES'] = $geo['cities'];
$this->arResult['SPECIALTIES'] = Api::getSpecialtiesForComponent();
$this->includeComponentTemplate();
}
private function handleRegister($req) {
$GLOBALS['APPLICATION']->RestartBuffer();
header('Content-Type: application/json');
if (!check_bitrix_sessid()) {
echo json_encode(['status' => 'error', 'message' => 'Сессия истекла']); die();
}
$email = trim($req->getPost("email"));
if(!check_email($email)) die(json_encode(['status'=>'error','message'=>'Некорректный Email']));
$by = "ID"; $order = "ASC";
if(\CUser::GetList($by, $order, ["=EMAIL" => $email])->Fetch()) {
die(json_encode(['status'=>'error','message'=>'Email уже занят']));
}
$pass = $req->getPost("password");
// Теперь этот вызов сработает, так как метод PUBLIC
$v = Api::validatePassword($pass);
if($v !== true) die(json_encode(['status'=>'error','message'=>$v]));
$arGroups = [2, 3, 4];
$specCode = $req->getPost("specialty");
if (!empty($specCode)) {
$rsGroup = \Bitrix\Main\GroupTable::getList(['filter' => ['=STRING_ID' => $specCode, '=ACTIVE' => 'Y'], 'select' => ['ID']])->fetch();
if ($rsGroup) $arGroups[] = $rsGroup['ID'];
}
$user = new \CUser;
$uid = $user->Add([
"LOGIN" => $email,
"EMAIL" => $email,
"NAME" => $req->getPost("name"),
"LAST_NAME" => $req->getPost("last_name"),
"SECOND_NAME" => $req->getPost("second_name"),
"PERSONAL_PHONE" => $req->getPost("phone"),
"PERSONAL_CITY" => $req->getPost("city"),
"PERSONAL_COUNTRY" => $req->getPost("country"),
"PASSWORD" => $pass,
"CONFIRM_PASSWORD" => $pass,
"ACTIVE" => "Y",
"GROUP_ID" => $arGroups
]);
if($uid) {
$needConfirm = Option::get("main", "new_user_registration_email_confirmation", "N");
if ($needConfirm !== "Y") {
global $USER;
$USER->Authorize($uid);
}
$code = bin2hex(random_bytes(16));
// Теперь этот вызов сработает, так как метод PUBLIC
$dcCodes = Api::getHlEntity('sso_codes');
$dcCodes::add([
'UF_CODE' => $code,
'UF_CLIENT_ID' => 'app_id_site', // Используем ID основного сайта по умолчанию
'UF_USER_ID' => $uid,
'UF_EXPIRES' => DateTime::createFromTimestamp(time() + 60)
]);
Api::audit("USER_REGISTERED", "app_id_site", $uid, "Email: ".$email);
echo json_encode(['status' => 'success', 'code' => $code]);
} else {
echo json_encode(['status' => 'error', 'message' => strip_tags($user->LAST_ERROR)]);
}
die();
}
}

View File

@@ -0,0 +1,105 @@
<?php
if(!defined("B_PROLOG_INCLUDED") || B_PROLOG_INCLUDED!==true) die();
use Bitrix\Main\Loader;
use Bitrix\Main\Context;
use Bitrix\Main\Config\Option;
use Bitrix\Main\Type\DateTime;
use Conmed\Authserver\Api;
class ConmedSsoRegister extends CBitrixComponent {
public function executeComponent() {
if (!Loader::includeModule("conmed.authserver")) {
ShowError("Модуль SSO не найден");
return;
}
$request = Context::getCurrent()->getRequest();
// 1. Обработка AJAX регистрации
if ($request->isPost() && $request->get('ajax_reg') == 'y') {
$this->handleRegister($request);
return;
}
// 2. Подготовка данных для формы через InternalDataTrait
$geo = Api::getGeoForComponent();
$this->arResult['COUNTRIES'] = $geo['countries'];
$this->arResult['CITIES'] = $geo['cities'];
$this->arResult['SPECIALTIES'] = Api::getSpecialtiesForComponent();
// Передаем публичный ID во фронтенд для формирования ссылки редиректа
$this->arResult['CLIENT_ID'] = Api::getDefaultClientId();
$this->includeComponentTemplate();
}
private function handleRegister($req) {
$GLOBALS['APPLICATION']->RestartBuffer();
header('Content-Type: application/json');
if (!check_bitrix_sessid()) {
echo json_encode(['status' => 'error', 'message' => 'Сессия истекла']); die();
}
$email = trim($req->getPost("email"));
if(!check_email($email)) die(json_encode(['status'=>'error','message'=>'Некорректный Email']));
$by = "ID"; $order = "ASC";
if(\CUser::GetList($by, $order, ["=EMAIL" => $email])->Fetch()) {
die(json_encode(['status'=>'error','message'=>'Email уже занят']));
}
$pass = $req->getPost("password");
$v = Api::validatePassword($pass);
if($v !== true) die(json_encode(['status'=>'error','message'=>$v]));
$arGroups = [2, 3, 4];
$specCode = $req->getPost("specialty");
if (!empty($specCode)) {
$rsGroup = \Bitrix\Main\GroupTable::getList(['filter' =>['=STRING_ID' => $specCode, '=ACTIVE' => 'Y'], 'select' => ['ID']])->fetch();
if ($rsGroup) $arGroups[] = $rsGroup['ID'];
}
$user = new \CUser;
$uid = $user->Add([
"LOGIN" => $email,
"EMAIL" => $email,
"NAME" => $req->getPost("name"),
"LAST_NAME" => $req->getPost("last_name"),
"SECOND_NAME" => $req->getPost("second_name"),
"PERSONAL_PHONE" => $req->getPost("phone"),
"PERSONAL_CITY" => $req->getPost("city"),
"PERSONAL_COUNTRY" => $req->getPost("country"),
"PASSWORD" => $pass,
"CONFIRM_PASSWORD" => $pass,
"ACTIVE" => "Y",
"GROUP_ID" => $arGroups
]);
if($uid) {
$needConfirm = Option::get("main", "new_user_registration_email_confirmation", "N");
if ($needConfirm !== "Y") {
global $USER;
$USER->Authorize($uid);
}
$code = bin2hex(random_bytes(16));
$defaultClientId = Api::getDefaultClientId(); // Получаем безопасно из трейта
$dcCodes = Api::getHlEntity('sso_codes');
$dcCodes::add([
'UF_CODE' => $code,
'UF_CLIENT_ID' => $defaultClientId,
'UF_USER_ID' => $uid,
'UF_EXPIRES' => DateTime::createFromTimestamp(time() + 60)
]);
Api::audit("USER_REGISTERED", $defaultClientId, $uid, "Email: ".$email);
echo json_encode(['status' => 'success', 'code' => $code]);
} else {
echo json_encode(['status' => 'error', 'message' => strip_tags($user->LAST_ERROR)]);
}
die();
}
}

View File

@@ -0,0 +1,210 @@
<?php if(!defined("B_PROLOG_INCLUDED") || B_PROLOG_INCLUDED!==true)die();
/** @var array $arResult */
?>
<script>
// Передаем данные справочников напрямую в JS
var cmGeoData = {
countries: <?=json_encode($arResult['COUNTRIES'])?>,
cities: <?=json_encode($arResult['CITIES'])?>
};
var cmSessid = '<?=bitrix_sessid()?>';
</script>
<div class="cm-auth-card">
<div class="cm-auth-header">
<h2>Регистрация специалиста</h2>
<p>Создайте единый аккаунт для всех ресурсов Con-Med</p>
</div>
<div class="cm-auth-body">
<form id="cm-reg-page-form">
<?=bitrix_sessid_post()?>
<div class="cm-row">
<div class="cm-col-4">
<label class="cm-label">Фамилия*</label>
<input type="text" name="last_name" class="cm-input" required>
</div>
<div class="cm-col-4">
<label class="cm-label">Имя*</label>
<input type="text" name="name" class="cm-input" required>
</div>
<div class="cm-col-4">
<label class="cm-label">Отчество</label>
<input type="text" name="second_name" class="cm-input">
</div>
</div>
<div class="cm-row">
<div class="cm-col-6">
<label class="cm-label">Email (Логин)*</label>
<input type="email" name="email" class="cm-input" required>
</div>
<div class="cm-col-6">
<label class="cm-label">Телефон*</label>
<input type="text" name="phone" id="f-phone" class="cm-input" placeholder="+7 (___) ___-__-__" required>
</div>
</div>
<div class="cm-row">
<div class="cm-col-6">
<label class="cm-label">Страна*</label>
<div class="cm-geo-wrap">
<input type="hidden" name="country" id="f-country-val">
<input type="text" id="f-country-search" class="cm-input" placeholder="Поиск страны..." autocomplete="off" required>
<div id="f-country-list" class="cm-dropdown"></div>
</div>
</div>
<div class="cm-col-6">
<label class="cm-label">Город*</label>
<div class="cm-geo-wrap">
<input type="text" name="city" id="f-city" class="cm-input" placeholder="Начните вводить..." autocomplete="off" required>
<div id="f-city-list" class="cm-dropdown"></div>
</div>
</div>
</div>
<div class="cm-row">
<div class="cm-col-6">
<label class="cm-label">Пароль*</label>
<div style="position:relative">
<input type="password" name="password" id="cmP" class="cm-input" required minlength="6" placeholder="Буквы и цифры">
<div class="cm-pass-tools">
<span class="cm-pass-btn" onclick="togglePassVisibility()" title="Показать/скрыть">👁️</span>
<span class="cm-pass-btn" onclick="generateSsoPassword()" title="Сгенерировать">🎲 ГЕН</span>
</div>
</div>
</div>
<div class="cm-col-6">
<label class="cm-label">Специальность*</label>
<select name="specialty" class="cm-input" required>
<option value="">Выберите специальность</option>
<?foreach($arResult['SPECIALTIES'] as $spec):?>
<option value="<?=$spec['code']?>"><?=$spec['name']?></option>
<?endforeach;?>
</select>
</div>
</div>
<div class="cm-check-group">
<label><input type="checkbox" required checked> Я специалист здравоохранения</label>
<label><input type="checkbox" required checked> Согласен на обработку персональных данных</label>
</div>
<div id="reg-msg" class="cm-alert" style="display:none"></div>
<button type="submit" class="cm-btn-submit">Зарегистрироваться</button>
<div style="text-align:center; margin-top: 20px;">
<a href="/auth/" class="cm-link">Уже есть аккаунт? Войти</a>
</div>
</form>
</div>
</div>
<style>
.cm-auth-card { background:#fff; border-radius:12px; box-shadow:0 10px 40px rgba(0,0,0,0.08); border:1px solid #eee; max-width:700px; margin:0 auto; font-family:sans-serif; overflow:hidden; }
.cm-auth-header { background:#f8f9fa; padding:30px; border-bottom:1px solid #eee; text-align:center; }
.cm-auth-header h2 { margin:0; color:#2c3e50; }
.cm-auth-body { padding:40px; }
.cm-row { display:flex; gap:20px; margin-bottom:15px; }
.cm-col-4 { width:33.33%; } .cm-col-6 { width:50%; }
.cm-label { display:block; margin-bottom:5px; font-size:12px; color:#999; font-weight:bold; text-transform:uppercase; }
.cm-input { width:100%; padding:12px; border:1px solid #ddd; border-radius:6px; box-sizing:border-box; font-size:14px; outline:none; }
.cm-input:focus { border-color: #2c3e50; }
.cm-btn-submit { width:100%; padding:16px; border:0; border-radius:6px; font-weight:bold; cursor:pointer; color:#fff; background:#28a745; font-size:18px; margin-top:20px; }
.cm-geo-wrap { position:relative; }
.cm-dropdown { display:none; position:absolute; top:100%; left:0; right:0; background:#fff; border:1px solid #ddd; z-index:100; max-height:150px; overflow-y:auto; border-radius:0 0 6px 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.cm-dropdown div { padding:10px; cursor:pointer; font-size:13px; border-bottom:1px solid #f9f9f9; text-align: left; }
.cm-dropdown div:hover { background:#f0f3f5; }
.cm-check-group label { display:block; font-size:13px; color:#666; margin-bottom:5px; cursor:pointer; }
.cm-link { color:#2c3e50; text-decoration:none; font-size:14px; }
.cm-alert { padding:15px; border-radius:6px; margin-bottom:20px; text-align:center; font-size:14px; }
.cm-error { background:#fff1f0; color:#e74c3c; border:1px solid #ffa39e; }
.cm-success { background:#e8f5e9; color:#2e7d32; border:1px solid #c8e6c9; }
/* Стили для пароля */
.cm-pass-tools { position: absolute; right: 5px; top: 5px; display: flex; gap: 5px; }
.cm-pass-btn { background: #f0f0f0; border-radius: 4px; padding: 4px 8px; font-size: 10px; cursor: pointer; color: #666; border: 1px solid #ddd; user-select: none; }
.cm-pass-btn:hover { background: #e5e5e5; }
@media (max-width:600px) { .cm-row { flex-direction:column; gap:10px; } .cm-col-4, .cm-col-6 { width:100%; } }
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Инициализация поиска сразу, т.к. данные уже в cmGeoData
initAutocomplete('f-country-search', 'f-country-list', 'countries', 'f-country-val');
initAutocomplete('f-city', 'f-city-list', 'cities');
});
function initAutocomplete(inputId, listId, type, hiddenId = false) {
const input = document.getElementById(inputId), list = document.getElementById(listId);
if(!input || !list) return;
const render = (val) => {
list.innerHTML = '';
let filtered = cmGeoData[type].filter(item => (typeof item === 'string' ? item : item.name).toLowerCase().includes(val.toLowerCase())).slice(0, 50);
if(filtered.length > 0) {
filtered.forEach(item => {
let name = (typeof item === 'string') ? item : item.name;
let id = (typeof item === 'string') ? item : item.id;
let div = document.createElement('div'); div.innerText = name;
div.onclick = () => { input.value = name; if(hiddenId) document.getElementById(hiddenId).value = id; list.style.display = 'none'; };
list.appendChild(div);
});
list.style.display = 'block';
} else list.style.display = 'none';
};
input.onfocus = () => render(input.value);
input.oninput = (e) => render(e.target.value);
document.addEventListener('click', (e) => { if(!e.target.closest('.cm-geo-wrap')) list.style.display = 'none'; });
}
function generateSsoPassword() {
var charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
var pass = "";
for (var i = 0; i < 12; i++) pass += charset.charAt(Math.floor(Math.random() * charset.length));
var input = document.getElementById('cmP');
input.value = pass;
input.type = "text";
}
function togglePassVisibility() {
var input = document.getElementById('cmP');
input.type = (input.type === 'password') ? 'text' : 'password';
}
document.getElementById('cm-reg-page-form').onsubmit = function(e) {
e.preventDefault();
let btn = this.querySelector('button'); btn.disabled = true; btn.innerText = 'Секунду...';
let msg = document.getElementById('reg-msg'); msg.style.display = 'none';
let fd = new FormData(this);
fd.append('action', 'register');
// Используем ключи сайта id для регистрации с сервера auth
fd.append('client_id', 'app_id_site');
fd.append('client_secret', 'secret_key_for_id_site_9988');
fetch('?ajax_reg=y&action=register', {method:'POST', body:fd}).then(r=>r.json()).then(res => {
if(res.status == 'success') {
msg.innerText = 'Регистрация успешна! Перенаправление...';
msg.className = 'cm-alert cm-success'; msg.style.display = 'block';
let backurl = new URLSearchParams(window.location.search).get('backurl');
if(backurl) {
window.location.href = '/api/oauth/authorize.php?client_id=app_id_site&redirect_uri=' + encodeURIComponent(backurl);
} else {
window.location.href = '/personal/';
}
} else {
msg.innerText = res.message; msg.className = 'cm-alert cm-error'; msg.style.display = 'block';
btn.disabled = false; btn.innerText = 'Зарегистрироваться';
}
}).catch(e => {
msg.innerText = 'Ошибка на сервере'; msg.className = 'cm-alert cm-error'; msg.style.display = 'block';
btn.disabled = false; btn.innerText = 'Зарегистрироваться';
});
};
</script>

View File

@@ -0,0 +1,220 @@
<?php if(!defined("B_PROLOG_INCLUDED") || B_PROLOG_INCLUDED!==true)die();
/** @var array $arResult */
?>
<script>
// Передаем данные справочников напрямую в JS
var cmGeoData = {
countries: <?=json_encode($arResult['COUNTRIES'])?>,
cities: <?=json_encode($arResult['CITIES'])?>
};
var cmSessid = '<?=bitrix_sessid()?>';
var cmDefaultClientId = '<?=$arResult['CLIENT_ID']?>'; // Публичный ID клиента для редиректов
</script>
<div class="cm-auth-card">
<div class="cm-auth-header">
<h2>Регистрация специалиста</h2>
<p>Создайте единый аккаунт для всех ресурсов Con-Med</p>
</div>
<div class="cm-auth-body">
<form id="cm-reg-page-form">
<?=bitrix_sessid_post()?>
<div class="cm-row">
<div class="cm-col-4">
<label class="cm-label">Фамилия*</label>
<input type="text" name="last_name" class="cm-input" required>
</div>
<div class="cm-col-4">
<label class="cm-label">Имя*</label>
<input type="text" name="name" class="cm-input" required>
</div>
<div class="cm-col-4">
<label class="cm-label">Отчество</label>
<input type="text" name="second_name" class="cm-input">
</div>
</div>
<div class="cm-row">
<div class="cm-col-6">
<label class="cm-label">Email (Логин)*</label>
<input type="email" name="email" class="cm-input" required>
</div>
<div class="cm-col-6">
<label class="cm-label">Телефон*</label>
<input type="text" name="phone" id="f-phone" class="cm-input" placeholder="+7 (___) ___-__-__" required>
</div>
</div>
<div class="cm-row">
<div class="cm-col-6">
<label class="cm-label">Страна*</label>
<div class="cm-geo-wrap">
<input type="hidden" name="country" id="f-country-val">
<input type="text" id="f-country-search" class="cm-input" placeholder="Поиск страны..." autocomplete="off" required>
<div id="f-country-list" class="cm-dropdown"></div>
</div>
</div>
<div class="cm-col-6">
<label class="cm-label">Город*</label>
<div class="cm-geo-wrap">
<input type="text" name="city" id="f-city" class="cm-input" placeholder="Начните вводить..." autocomplete="off" required>
<div id="f-city-list" class="cm-dropdown"></div>
</div>
</div>
</div>
<div class="cm-row">
<div class="cm-col-6">
<label class="cm-label">Пароль*</label>
<div style="position:relative">
<input type="password" name="password" id="cmP" class="cm-input" required minlength="6" placeholder="Буквы и цифры">
<div class="cm-pass-tools">
<span class="cm-pass-btn" onclick="togglePassVisibility()" title="Показать/скрыть">👁️</span>
<span class="cm-pass-btn" onclick="generateSsoPassword()" title="Сгенерировать">🎲 ГЕН</span>
</div>
</div>
</div>
<div class="cm-col-6">
<label class="cm-label">Специальность*</label>
<select name="specialty" class="cm-input" required>
<option value="">Выберите специальность</option>
<?foreach($arResult['SPECIALTIES'] as $spec):?>
<option value="<?=$spec['code']?>"><?=$spec['name']?></option>
<?endforeach;?>
</select>
</div>
</div>
<div class="cm-check-group">
<label><input type="checkbox" required checked> Я специалист здравоохранения</label>
<label><input type="checkbox" required checked> Согласен на обработку персональных данных</label>
</div>
<div id="reg-msg" class="cm-alert" style="display:none"></div>
<button type="submit" class="cm-btn-submit">Зарегистрироваться</button>
<div style="text-align:center; margin-top: 20px;">
<a href="/auth/" class="cm-link">Уже есть аккаунт? Войти</a>
</div>
</form>
</div>
</div>
<style>
.cm-auth-card { background:#fff; border-radius:12px; box-shadow:0 10px 40px rgba(0,0,0,0.08); border:1px solid #eee; max-width:700px; margin:0 auto; font-family:sans-serif; overflow:hidden; }
.cm-auth-header { background:#f8f9fa; padding:30px; border-bottom:1px solid #eee; text-align:center; }
.cm-auth-header h2 { margin:0; color:#2c3e50; }
.cm-auth-body { padding:40px; }
.cm-row { display:flex; gap:20px; margin-bottom:15px; }
.cm-col-4 { width:33.33%; } .cm-col-6 { width:50%; }
.cm-label { display:block; margin-bottom:5px; font-size:12px; color:#999; font-weight:bold; text-transform:uppercase; }
.cm-input { width:100%; padding:12px; border:1px solid #ddd; border-radius:6px; box-sizing:border-box; font-size:14px; outline:none; }
.cm-input:focus { border-color: #2c3e50; }
.cm-btn-submit { width:100%; padding:16px; border:0; border-radius:6px; font-weight:bold; cursor:pointer; color:#fff; background:#28a745; font-size:18px; margin-top:20px; }
.cm-geo-wrap { position:relative; }
.cm-dropdown { display:none; position:absolute; top:100%; left:0; right:0; background:#fff; border:1px solid #ddd; z-index:100; max-height:150px; overflow-y:auto; border-radius:0 0 6px 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.cm-dropdown div { padding:10px; cursor:pointer; font-size:13px; border-bottom:1px solid #f9f9f9; text-align: left; }
.cm-dropdown div:hover { background:#f0f3f5; }
.cm-check-group label { display:block; font-size:13px; color:#666; margin-bottom:5px; cursor:pointer; }
.cm-link { color:#2c3e50; text-decoration:none; font-size:14px; }
.cm-alert { padding:15px; border-radius:6px; margin-bottom:20px; text-align:center; font-size:14px; }
.cm-error { background:#fff1f0; color:#e74c3c; border:1px solid #ffa39e; }
.cm-success { background:#e8f5e9; color:#2e7d32; border:1px solid #c8e6c9; }
/* Стили для пароля */
.cm-pass-tools { position: absolute; right: 5px; top: 5px; display: flex; gap: 5px; }
.cm-pass-btn { background: #f0f0f0; border-radius: 4px; padding: 4px 8px; font-size: 10px; cursor: pointer; color: #666; border: 1px solid #ddd; user-select: none; }
.cm-pass-btn:hover { background: #e5e5e5; }
@media (max-width:600px) { .cm-row { flex-direction:column; gap:10px; } .cm-col-4, .cm-col-6 { width:100%; } }
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
initAutocomplete('f-country-search', 'f-country-list', 'countries', 'f-country-val');
initAutocomplete('f-city', 'f-city-list', 'cities');
});
function initAutocomplete(inputId, listId, type, hiddenId = false) {
const input = document.getElementById(inputId), list = document.getElementById(listId);
if(!input || !list) return;
const render = (val) => {
list.innerHTML = '';
let filtered = cmGeoData[type].filter(item => (typeof item === 'string' ? item : item.name).toLowerCase().includes(val.toLowerCase())).slice(0, 50);
if(filtered.length > 0) {
filtered.forEach(item => {
let name = (typeof item === 'string') ? item : item.name;
let id = (typeof item === 'string') ? item : item.id;
let div = document.createElement('div'); div.innerText = name;
div.onclick = () => { input.value = name; if(hiddenId) document.getElementById(hiddenId).value = id; list.style.display = 'none'; };
list.appendChild(div);
});
list.style.display = 'block';
} else list.style.display = 'none';
};
input.onfocus = () => render(input.value);
input.oninput = (e) => render(e.target.value);
document.addEventListener('click', (e) => { if(!e.target.closest('.cm-geo-wrap')) list.style.display = 'none'; });
}
function generateSsoPassword() {
var charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
var pass = "";
// Заменен Math.random на криптографически стойкий генератор, если он поддерживается
if (window.crypto && window.crypto.getRandomValues) {
var values = new Uint32Array(12);
window.crypto.getRandomValues(values);
for (var i = 0; i < 12; i++) {
pass += charset[values[i] % charset.length];
}
} else {
for (var i = 0; i < 12; i++) pass += charset.charAt(Math.floor(Math.random() * charset.length));
}
var input = document.getElementById('cmP');
input.value = pass;
input.type = "text";
}
function togglePassVisibility() {
var input = document.getElementById('cmP');
input.type = (input.type === 'password') ? 'text' : 'password';
}
document.getElementById('cm-reg-page-form').onsubmit = function(e) {
e.preventDefault();
let btn = this.querySelector('button'); btn.disabled = true; btn.innerText = 'Секунду...';
let msg = document.getElementById('reg-msg'); msg.style.display = 'none';
let fd = new FormData(this);
fd.append('action', 'register');
// Секретные ключи из FormData удалены!
// Бэкенд сам знает, от имени кого он работает.
fetch('?ajax_reg=y&action=register', {method:'POST', body:fd}).then(r=>r.json()).then(res => {
if(res.status == 'success') {
msg.innerText = 'Регистрация успешна! Перенаправление...';
msg.className = 'cm-alert cm-success'; msg.style.display = 'block';
let backurl = new URLSearchParams(window.location.search).get('backurl');
if(backurl) {
// Используем публичный ID клиента, полученный из бэкенда
window.location.href = '/api/oauth/authorize.php?client_id=' + cmDefaultClientId + '&redirect_uri=' + encodeURIComponent(backurl);
} else {
window.location.href = '/personal/';
}
} else {
msg.innerText = res.message; msg.className = 'cm-alert cm-error'; msg.style.display = 'block';
btn.disabled = false; btn.innerText = 'Зарегистрироваться';
}
}).catch(e => {
msg.innerText = 'Ошибка на сервере'; msg.className = 'cm-alert cm-error'; msg.style.display = 'block';
btn.disabled = false; btn.innerText = 'Зарегистрироваться';
});
};
</script>

View File

@@ -0,0 +1,92 @@
<?php
/**
* REINSTALL LOG CLEANUP AGENT
* Target: auth.con-med.ru
*/
require($_SERVER["DOCUMENT_ROOT"]."/bitrix/modules/main/include/prolog_before.php");
if (!$USER->IsAdmin()) die("Доступ запрещен");
$log = [];
$apiFile = $_SERVER["DOCUMENT_ROOT"]."/local/modules/conmed.authserver/lib/api.php";
// 1. ОБНОВЛЯЕМ ФАЙЛ API.PHP (чтобы метод точно был в классе)
$apiCode = <<<'PHP'
<?php
namespace Conmed\Authserver;
/**
* Класс-фасад API сервера авторизации
* Собирает функционал из трейтов и содержит системные методы (агенты)
*/
class Api {
use SecurityTrait, AuthTokenTrait, RegistrationTrait, ProfileTrait, GroupsTrait;
/**
* Агент автоматической очистки логов
* Выполняется раз в сутки
*/
public static function cleanLogsAgent() {
if (!\Bitrix\Main\Loader::includeModule("highloadblock")) return "";
try {
// Получаем классы таблиц через трейт безопасности
$dcSec = self::getHlEntity('sso_security_log');
$dcAud = self::getHlEntity('sso_audit');
$dcCodes = self::getHlEntity('sso_codes');
// 1. Очистка логов безопасности (храним 7 дней)
$date7 = \Bitrix\Main\Type\DateTime::createFromTimestamp(time() - 86400 * 7);
$rs = $dcSec::getList(['filter' => ['<UF_DATE' => $date7], 'select' => ['ID']]);
while ($el = $rs->fetch()) {
$dcSec::delete($el['ID']);
}
// 2. Очистка аудита (храним 60 дней)
$date60 = \Bitrix\Main\Type\DateTime::createFromTimestamp(time() - 86400 * 60);
$rs = $dcAud::getList(['filter' => ['<UF_DATE' => $date60], 'select' => ['ID']]);
while ($el = $rs->fetch()) {
$dcAud::delete($el['ID']);
}
// 3. Очистка протухших кодов авторизации (храним 1 час для истории, потом удаляем)
$date1h = \Bitrix\Main\Type\DateTime::createFromTimestamp(time() - 3600);
$rs = $dcCodes::getList(['filter' => ['<UF_EXPIRES' => $date1h], 'select' => ['ID']]);
while ($el = $rs->fetch()) {
$dcCodes::delete($el['ID']);
}
} catch (\Exception $e) {
// Ошибки можно писать в системный лог php если нужно
}
return "\Conmed\Authserver\Api::cleanLogsAgent();";
}
}
PHP;
if (file_put_contents($apiFile, $apiCode)) {
$log[] = "Файл lib/api.php обновлен (метод cleanLogsAgent добавлен).";
}
// 2. РЕГИСТРАЦИЯ АГЕНТА В БИТРИКСЕ
// Сначала удаляем старый, если он завис
\CAgent::RemoveAgent("\Conmed\Authserver\Api::cleanLogsAgent();", "conmed.authserver");
// Добавляем новый
// Параметры: Метод, Модуль, Периодичность (N=раз в интервал), Интервал (86400 = 24 часа)
\CAgent::AddAgent(
"\Conmed\Authserver\Api::cleanLogsAgent();",
"conmed.authserver",
"N",
86400,
"",
"Y",
\Bitrix\Main\Type\DateTime::createFromTimestamp(time() + 60) // Первый запуск через минуту
);
$log[] = "Агент очистки логов успешно зарегистрирован в системе.";
echo "<h2>Результат установки:</h2>";
echo "<ul><li>" . implode("</li><li>", $log) . "</li></ul>";
echo "<p>Проверить работу агента можно в админке: <b>Настройки -> Настройки продукта -> Агенты</b></p>";

View File

@@ -0,0 +1,44 @@
<?php
use Bitrix\Main\ModuleManager;
use Bitrix\Main\EventManager;
class conmed_authserver extends CModule {
var $MODULE_ID = "conmed.authserver";
var $MODULE_NAME = "Con-Med: Сервер авторизации (Module Edition)";
var $MODULE_VERSION = "1.2.0"; // Подняли версию, так как добавили события
var $MODULE_DESCRIPTION = "Централизованное управление SSO и API";
function DoInstall() {
ModuleManager::registerModule($this->MODULE_ID);
$this->InstallEvents(); // Вызываем регистрацию событий
}
function DoUninstall() {
$this->UnInstallEvents(); // Вызываем удаление событий
ModuleManager::unRegisterModule($this->MODULE_ID);
}
function InstallEvents() {
$eventManager = EventManager::getInstance();
// Регистрация обработчика удаления пользователя
$eventManager->registerEventHandler(
"main",
"OnAfterUserDelete",
$this->MODULE_ID,
"Conmed\\Authserver\\Api",
"onAfterUserDeleteHandler"
);
}
function UnInstallEvents() {
$eventManager = EventManager::getInstance();
// Удаление обработчика при деинсталляции модуля
$eventManager->unRegisterEventHandler(
"main",
"OnAfterUserDelete",
$this->MODULE_ID,
"Conmed\\Authserver\\Api",
"onAfterUserDeleteHandler"
);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Conmed\Authserver;
use Bitrix\Main\Config\Option;
use Bitrix\Main\Context;
use Bitrix\Main\Type\DateTime;
use Bitrix\Highloadblock\HighloadBlockTable;
use Bitrix\Main\Loader;
trait GroupsTrait {
public static function groupsAction() {
header('Content-Type: application/json'); $req = Context::getCurrent()->getRequest();
if(!self::checkClient($req->get("client_id"), $req->get("client_secret"))) die(json_encode(['error'=>'forbidden']));
$rs = \Bitrix\Main\GroupTable::getList(['filter'=>['=ACTIVE'=>'Y','=C_SORT'=>555],'select'=>['ID','NAME','STRING_ID'],'order'=>['NAME'=>'ASC']]);
$res = []; while($g = $rs->fetch()) if($g['STRING_ID']) $res[] = ['id'=>$g['ID'], 'name'=>$g['NAME'], 'code'=>$g['STRING_ID']];
echo json_encode($res);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace Conmed\Authserver;
use Bitrix\Main\Loader;
trait InternalDataTrait {
/**
* Возвращает список специальностей для PHP-компонента
*/
public static function getSpecialtiesForComponent() {
$rs = \Bitrix\Main\GroupTable::getList([
'filter' => ['=ACTIVE' => 'Y', '=C_SORT' => 555],
'select' => ['ID', 'NAME', 'STRING_ID'],
'order' => ['NAME' => 'ASC']
]);
$res = [];
while($g = $rs->fetch()) {
if($g['STRING_ID']) {
$res[] = ['id' => $g['ID'], 'name' => $g['NAME'], 'code' => $g['STRING_ID']];
}
}
return $res;
}
/**
* Возвращает справочники стран и городов для PHP-компонента
*/
public static function getGeoForComponent() {
// 1. Страны (из ядра Битрикс)
$countries = [];
$arCountries = GetCountryArray();
if (is_array($arCountries['reference_id'])) {
foreach ($arCountries['reference_id'] as $k => $id) {
$countries[] = [
'id' => $id,
'name' => $arCountries['reference'][$k]
];
}
}
// 2. Города (из модуля веб-аналитики)
$cities = [];
if (Loader::includeModule('statistic')) {
$rs = \CCity::GetList(['CITY_NAME' => 'ASC'], []);
while ($el = $rs->Fetch()) {
if (!empty($el['CITY_NAME'])) {
$cities[] = $el['CITY_NAME'];
}
}
$cities = array_values(array_unique($cities));
}
return [
'countries' => $countries,
'cities' => $cities
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Conmed\Authserver;
use Bitrix\Main\Config\Option;
use Bitrix\Main\Context;
use Bitrix\Main\Type\DateTime;
use Bitrix\Highloadblock\HighloadBlockTable;
use Bitrix\Main\Loader;
trait RegistrationTrait {
public static function registerAction() {
header('Content-Type: application/json'); $req = Context::getCurrent()->getRequest();
if(!self::checkClient($req->get("client_id"), $req->get("client_secret"))) die(json_encode(['error'=>'forbidden']));
$email = trim($req->getPost("email"));
if(!check_email($email)) die(json_encode(['status'=>'error','message'=>'Некорректный Email']));
if(\CUser::GetList($b,$o,["=EMAIL"=>$email])->Fetch()) die(json_encode(['status'=>'error','message'=>'Email существует']));
$pass = $req->getPost("password");
$v = self::validatePassword($pass); if($v !== true) die(json_encode(['status'=>'error','message'=>$v]));
$uid = (new \CUser)->Add(["LOGIN"=>$email,"EMAIL"=>$email,"NAME"=>$req->getPost("name"),"LAST_NAME"=>$req->getPost("last_name"),"PASSWORD"=>$pass,"CONFIRM_PASSWORD"=>$pass,"ACTIVE"=>"Y","GROUP_ID"=>[2,3,4]]);
if($uid) {
$code = bin2hex(random_bytes(16));
self::getHlEntity('sso_codes')::add(['UF_CODE'=>$code, 'UF_CLIENT_ID'=>$req->get("client_id"), 'UF_USER_ID'=>$uid, 'UF_EXPIRES'=>DateTime::createFromTimestamp(time()+60)]);
self::audit("USER_REGISTERED", $req->get("client_id"), $uid);
echo json_encode(['status'=>'success','code'=>$code]);
}
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Conmed\Authserver;
/**
* Класс-фасад API сервера авторизации
* Собирает функционал из трейтов и содержит системные методы (агенты)
*/
class Api {
// ВНИМАТЕЛЬНО: Подключаем все 7 трейтов
use SecurityTrait, AuthTokenTrait, RegistrationTrait, ProfileTrait, GroupsTrait, DictionariesTrait, CredentialsTrait, InternalDataTrait;
public static function onAfterUserDeleteHandler($userId) {
Webhook::sendDelete($userId);
}
public static function cleanLogsAgent() {
if (!\Bitrix\Main\Loader::includeModule("highloadblock")) return "";
try {
$dcSec = self::getHlEntity('sso_security_log');
$dcAud = self::getHlEntity('sso_audit');
$dcCodes = self::getHlEntity('sso_codes');
$date7 = \Bitrix\Main\Type\DateTime::createFromTimestamp(time() - 86400 * 7);
$rs = $dcSec::getList(['filter' => ['<UF_DATE' => $date7], 'select' => ['ID']]);
while ($el = $rs->fetch()) $dcSec::delete($el['ID']);
$date60 = \Bitrix\Main\Type\DateTime::createFromTimestamp(time() - 86400 * 60);
$rs = $dcAud::getList(['filter' => ['<UF_DATE' => $date60], 'select' => ['ID']]);
while ($el = $rs->fetch()) $dcAud::delete($el['ID']);
$date1h = \Bitrix\Main\Type\DateTime::createFromTimestamp(time() - 3600);
$rs = $dcCodes::getList(['filter' => ['<UF_EXPIRES' => $date1h], 'select' => ['ID']]);
while ($el = $rs->fetch()) $dcCodes::delete($el['ID']);
} catch (\Exception $e) {}
return "\Conmed\Authserver\Api::cleanLogsAgent();";
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Conmed\Authserver;
use Bitrix\Main\Config\Option;
use Bitrix\Main\Context;
use Bitrix\Main\Type\DateTime;
use Bitrix\Highloadblock\HighloadBlockTable;
use Bitrix\Main\Loader;
trait AuthTokenTrait {
public static function authorizeAction() {
global $USER; $req = Context::getCurrent()->getRequest();
$cid = $req->get("client_id"); $uri = $req->get("redirect_uri");
if(!self::checkClient($cid, false, $uri)) die("Access Denied");
if(!$USER->IsAuthorized()) { LocalRedirect("/auth/?backurl=".urlencode(Context::getCurrent()->getServer()->getRequestUri())); die(); }
$code = bin2hex(random_bytes(16));
$dc = self::getHlEntity('sso_codes');
$dc::add(['UF_CODE'=>$code, 'UF_CLIENT_ID'=>$cid, 'UF_USER_ID'=>$USER->GetID(), 'UF_EXPIRES'=>DateTime::createFromTimestamp(time()+60)]);
$url = $uri . (strpos($uri, '?') === false ? '?' : '&') . 'code=' . $code . '&authservice=conmedauth';
if($req->get("state")) $url .= '&state=' . urlencode($req->get("state"));
LocalRedirect($url);
}
public static function tokenAction() {
header('Content-Type: application/json');
$req = Context::getCurrent()->getRequest(); $cid = $req->get("client_id");
if(!self::checkClient($cid, $req->get("client_secret"))) { self::registerAttempt(); die(json_encode(['error'=>'forbidden'])); }
$dc = self::getHlEntity('sso_codes');
if($c = $dc::getList(['filter'=>['=UF_CODE'=>$req->get("code"),'=UF_CLIENT_ID'=>$cid,'>UF_EXPIRES'=>DateTime::createFromTimestamp(time())]])->fetch()) {
$dc::delete($c['ID']);
$acc = bin2hex(random_bytes(32)); $ref = bin2hex(random_bytes(32));
$dt = self::getHlEntity('sso_tokens');
$dt::add(['UF_TOKEN'=>$acc,'UF_REFRESH_TOKEN'=>$ref,'UF_USER_ID'=>$c['UF_USER_ID'],'UF_CLIENT_ID'=>$cid,'UF_EXPIRES'=>DateTime::createFromTimestamp(time()+3600),'UF_REFRESH_EXPIRES'=>DateTime::createFromTimestamp(time()+2592000)]);
echo json_encode(['access_token'=>$acc, 'refresh_token'=>$ref]);
} else { self::registerAttempt(); echo json_encode(['error'=>'invalid_code']); }
}
public static function refreshAction() {
header('Content-Type: application/json');
$req = Context::getCurrent()->getRequest(); $cid = $req->get("client_id");
if(!self::checkClient($cid, $req->get("client_secret"))) die(json_encode(['error'=>'forbidden']));
$dt = self::getHlEntity('sso_tokens');
if($t = $dt::getList(['filter'=>['=UF_REFRESH_TOKEN'=>$req->get("refresh_token"),'=UF_CLIENT_ID'=>$cid,'>UF_REFRESH_EXPIRES'=>DateTime::createFromTimestamp(time())]])->fetch()) {
$acc = bin2hex(random_bytes(32)); $ref = bin2hex(random_bytes(32));
$dt::update($t['ID'], ['UF_TOKEN'=>$acc, 'UF_REFRESH_TOKEN'=>$ref, 'UF_EXPIRES'=>DateTime::createFromTimestamp(time()+3600)]);
echo json_encode(['access_token'=>$acc, 'refresh_token'=>$ref]);
} else echo json_encode(['error'=>'invalid_refresh']);
}
private static function getUidByToken($token) {
if(!$token) return false;
$dt = self::getHlEntity('sso_tokens');
$t = $dt::getList(['filter'=>['=UF_TOKEN'=>$token, '>UF_EXPIRES'=>DateTime::createFromTimestamp(time())]])->fetch();
return $t ? $t['UF_USER_ID'] : false;
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace Conmed\Authserver;
use Bitrix\Main\Context;
use Bitrix\Main\UserTable;
use Bitrix\Main\Security\Password;
trait CredentialsTrait {
/**
* Прямая проверка логина и пароля без создания сессий
* action: /api/oauth/verify.php
*/
public static function verifyAction() {
header('Content-Type: application/json');
$req = Context::getCurrent()->getRequest();
$clientId = $req->getPost("client_id");
$clientSecret = $req->getPost("client_secret");
$login = trim($req->getPost("login"));
$password = $req->getPost("password");
// 1. Проверка клиента (метод из SecurityTrait)
if(!self::checkClient($clientId, $clientSecret)) {
self::audit("VERIFY_REJECTED", $clientId, 0, "Invalid client secret");
die(json_encode(['status' => 'error', 'message' => 'Forbidden']));
}
if (empty($login) || empty($password)) {
die(json_encode(['status' => 'error', 'message' => 'Empty credentials']));
}
// 2. Ищем пользователя через объект Query (чтобы разрешить PASSWORD)
$query = UserTable::query();
$query->setSelect(['ID', 'PASSWORD', 'ACTIVE']);
$query->enablePrivateFields(); // РАЗРЕШАЕМ ДОСТУП К ПАРОЛЮ
$query->setFilter([
'LOGIC' => 'OR',
['=LOGIN' => $login],
['=EMAIL' => $login]
]);
$user = $query->exec()->fetch();
if ($user && $user['ACTIVE'] === 'Y') {
// 3. Проверяем пароль
if (Password::equals($user['PASSWORD'], $password)) {
self::audit("VERIFY_SUCCESS", $clientId, $user['ID'], "Login: $login");
echo json_encode([
'status' => 'success',
'user_id' => $user['ID']
]);
return;
}
}
self::audit("VERIFY_FAILED", $clientId, 0, "Login: $login");
echo json_encode(['status' => 'error', 'message' => 'Invalid login or password']);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Conmed\Authserver;
use Bitrix\Main\Context;
use Bitrix\Main\Loader;
trait DictionariesTrait {
public static function dictionariesAction() {
header('Content-Type: application/json');
$req = Context::getCurrent()->getRequest();
// Проверка прав клиента
if(!self::checkClient($req->getPost("client_id"), $req->getPost("client_secret"))) {
die(json_encode(['error'=>'forbidden']));
}
// 1. Страны (Стандартный список Битрикс)
$countries = [];
$arCountries = GetCountryArray();
if (is_array($arCountries['reference_id'])) {
foreach ($arCountries['reference_id'] as $k => $id) {
$countries[] = [
'id' => $id,
'name' => $arCountries['reference'][$k]
];
}
}
// 2. Города (Из модуля statistic)
$cities = [];
if (Loader::includeModule('statistic')) {
$rs = \CCity::GetList(['CITY_NAME' => 'ASC'], []);
while ($el = $rs->Fetch()) {
if (!empty($el['CITY_NAME'])) {
$cities[] = $el['CITY_NAME'];
}
}
$cities = array_unique($cities);
$cities = array_values($cities);
}
echo json_encode([
'countries' => $countries,
'cities' => $cities
]);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Conmed\Authserver;
use Bitrix\Main\Config\Option;
use Bitrix\Main\Context;
use Bitrix\Main\Type\DateTime;
use Bitrix\Highloadblock\HighloadBlockTable;
use Bitrix\Main\Loader;
trait GroupsTrait {
public static function groupsAction() {
header('Content-Type: application/json'); $req = Context::getCurrent()->getRequest();
if(!self::checkClient($req->get("client_id"), $req->get("client_secret"))) die(json_encode(['error'=>'forbidden']));
$rs = \Bitrix\Main\GroupTable::getList(['filter'=>['=ACTIVE'=>'Y','=C_SORT'=>555],'select'=>['ID','NAME','STRING_ID'],'order'=>['NAME'=>'ASC']]);
$res = []; while($g = $rs->fetch()) if($g['STRING_ID']) $res[] = ['id'=>$g['ID'], 'name'=>$g['NAME'], 'code'=>$g['STRING_ID']];
echo json_encode($res);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace Conmed\Authserver;
use Bitrix\Main\Loader;
trait InternalDataTrait {
/**
* Возвращает Client ID по умолчанию для локальной регистрации и системных нужд
* @return string
*/
public static function getDefaultClientId() {
return 'app_id_site';
}
/**
* Возвращает список специальностей для PHP-компонента
*/
public static function getSpecialtiesForComponent() {
$rs = \Bitrix\Main\GroupTable::getList([
'filter' =>['=ACTIVE' => 'Y', '=C_SORT' => 555],
'select' =>['ID', 'NAME', 'STRING_ID'],
'order' => ['NAME' => 'ASC']
]);
$res =[];
while($g = $rs->fetch()) {
if($g['STRING_ID']) {
$res[] = ['id' => $g['ID'], 'name' => $g['NAME'], 'code' => $g['STRING_ID']];
}
}
return $res;
}
/**
* Возвращает справочники стран и городов для PHP-компонента
*/
public static function getGeoForComponent() {
// 1. Страны (из ядра Битрикс)
$countries =[];
$arCountries = GetCountryArray();
if (is_array($arCountries['reference_id'])) {
foreach ($arCountries['reference_id'] as $k => $id) {
$countries[] =[
'id' => $id,
'name' => $arCountries['reference'][$k]
];
}
}
// 2. Города (из модуля веб-аналитики)
$cities =[];
if (Loader::includeModule('statistic')) {
$rs = \CCity::GetList(['CITY_NAME' => 'ASC'],[]);
while ($el = $rs->Fetch()) {
if (!empty($el['CITY_NAME'])) {
$cities[] = $el['CITY_NAME'];
}
}
$cities = array_values(array_unique($cities));
}
return[
'countries' => $countries,
'cities' => $cities
];
}
}

View File

@@ -0,0 +1,155 @@
<?php
namespace Conmed\Authserver;
use Bitrix\Main\Context;
use Bitrix\Highloadblock\HighloadBlockTable;
use Bitrix\Main\Loader;
trait ProfileTrait {
// 1. ПОЛУЧЕНИЕ ДАННЫХ (Вызывается при входе)
public static function userAction() {
header('Content-Type: application/json');
$req = Context::getCurrent()->getRequest();
$auth = $req->getHeader('Authorization');
$token = (preg_match('/Bearer\s+(.*)$/i', $auth, $m)) ? trim($m[1]) : $req->get("access_token");
$uid = self::getUidByToken($token);
if($uid) {
$u = \CUser::GetByID($uid)->Fetch();
// --- ИСПРАВЛЕННАЯ ЛОГИКА ГРУПП ---
$specNames = []; // Названия (только 555)
$specCodes = []; // Коды (только 555) - для чекбоксов
$allCodes = []; // Все коды вообще - для прав доступа
$rs = \Bitrix\Main\GroupTable::getList([
'filter' => ['ID' => \CUser::GetUserGroup($u['ID']), '=ACTIVE' => 'Y'],
'select' => ['NAME', 'STRING_ID', 'C_SORT']
]);
while($g = $rs->fetch()) {
// 1. В общий список прав добавляем все, у чего есть код
if($g['STRING_ID']) {
$allCodes[] = $g['STRING_ID'];
}
// 2. В списки СПЕЦИАЛЬНОСТЕЙ - только с сортировкой 555
if($g['C_SORT'] == 555) {
$specNames[] = $g['NAME'];
if($g['STRING_ID']) {
$specCodes[] = $g['STRING_ID'];
}
}
}
// ---------------------------------
echo json_encode([
'id' => $u['ID'],
'login' => $u['LOGIN'],
'email' => $u['EMAIL'],
'name' => $u['NAME'],
'last_name' => $u['LAST_NAME'],
'second_name' => $u['SECOND_NAME'],
'city' => $u['PERSONAL_CITY'],
'phone' => $u['PERSONAL_MOBILE'], // Ваша правка
'country' => $u['PERSONAL_COUNTRY'],
'specialties' => $specNames,
'specialties_code' => $specCodes, // Теперь тут только специальности!
'groups_code' => $allCodes
]);
} else {
header('HTTP/1.0 401 Unauthorized');
}
}
// 2. ОБНОВЛЕНИЕ ДАННЫХ
public static function updateAction() {
header('Content-Type: application/json');
$req = Context::getCurrent()->getRequest();
if(!self::checkClient($req->getPost("client_id"), $req->getPost("client_secret"))) {
die(json_encode(['error'=>'forbidden']));
}
$uid = self::getUidByToken($req->getPost("access_token"));
if(!$uid) die(json_encode(['error'=>'invalid_token']));
$fields = [
"NAME" => $req->getPost("name"),
"LAST_NAME" => $req->getPost("last_name"),
"SECOND_NAME" => $req->getPost("second_name"),
"PERSONAL_MOBILE" => $req->getPost("phone"), // Ваша правка
"PERSONAL_CITY" => $req->getPost("city"),
"PERSONAL_COUNTRY" => $req->getPost("country")
];
$newSpecs = $req->getPost("specialties"); // Приходит массив кодов
$resNames = [];
$resSpecCodes = [];
if(is_array($newSpecs)) {
$curG = \CUser::GetUserGroup($uid);
$finalG = [];
$allSpecGIds = [];
// Получаем ID всех групп-специальностей (555)
$rs = \Bitrix\Main\GroupTable::getList(['filter'=>['=C_SORT'=>555],'select'=>['ID']]);
while($g = $rs->fetch()) $allSpecGIds[] = $g['ID'];
// Оставляем у юзера только НЕ специальности
foreach($curG as $gid) {
if(!in_array($gid, $allSpecGIds)) $finalG[] = $gid;
}
// Добавляем новые выбранные
$rs = \Bitrix\Main\GroupTable::getList(['filter'=>['=STRING_ID'=>$newSpecs, '=C_SORT'=>555]]);
while($g = $rs->fetch()) {
$finalG[] = $g['ID'];
$resNames[] = $g['NAME'];
$resSpecCodes[] = $g['STRING_ID'];
}
$fields["GROUP_ID"] = $finalG;
}
$user = new \CUser;
if($user->Update($uid, $fields)) {
self::audit("PROFILE_UPDATED", $req->getPost("client_id"), $uid);
echo json_encode([
'status' => 'success',
'new_specialties' => $resNames,
'new_specialties_code' => $resSpecCodes
]);
} else {
echo json_encode(['status' => 'error', 'message' => strip_tags($user->LAST_ERROR)]);
}
}
public static function passwordAction() {
header('Content-Type: application/json');
$req = Context::getCurrent()->getRequest();
if(!self::checkClient($req->getPost("client_id"), $req->getPost("client_secret"))) {
die(json_encode(['error'=>'forbidden']));
}
$uid = self::getUidByToken($req->getPost("access_token"));
if(!$uid) die(json_encode(['error'=>'invalid_token']));
$np = $req->getPost("new_password");
$v = self::validatePassword($np);
if($v !== true) die(json_encode(['status'=>'error', 'message'=>$v]));
if((new \CUser)->Update($uid, ["PASSWORD"=>$np, "CONFIRM_PASSWORD"=>$np])) {
self::audit("PASS_CHANGED", $req->getPost("client_id"), $uid);
echo json_encode(['status'=>'success']);
} else {
echo json_encode(['status'=>'error', 'message'=>strip_tags((new \CUser)->LAST_ERROR)]);
}
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace Conmed\Authserver;
use Bitrix\Main\Config\Option;
use Bitrix\Main\Context;
use Bitrix\Main\Type\DateTime;
use Bitrix\Highloadblock\HighloadBlockTable;
use Bitrix\Main\Loader;
trait ProfileTrait {
public static function userAction() {
header('Content-Type: application/json');
$req = Context::getCurrent()->getRequest();
$auth = $req->getHeader('Authorization');
$token = (preg_match('/Bearer\s+(.*)$/i', $auth, $m)) ? trim($m[1]) : $req->get("access_token");
$uid = self::getUidByToken($token);
if($uid) {
$u = \CUser::GetByID($uid)->Fetch();
$gn = []; $gc = []; $rs = \Bitrix\Main\GroupTable::getList(['filter'=>['ID'=>\CUser::GetUserGroup($u['ID']),'=ACTIVE'=>'Y'],'select'=>['NAME','STRING_ID','C_SORT']]);
while($g = $rs->fetch()) {
if($g['C_SORT']==555) $gn[]=$g['NAME'];
if($g['STRING_ID']) $gc[]=$g['STRING_ID'];
}
echo json_encode([
'id'=>$u['ID'], 'login'=>$u['LOGIN'], 'email'=>$u['EMAIL'], 'name'=>$u['NAME'],
'last_name'=>$u['LAST_NAME'], 'second_name'=>$u['SECOND_NAME'],
'specialties'=>$gn,
'city'=>$u['PERSONAL_CITY'],
//'phone'=>$u['PERSONAL_PHONE'],
'phone'=>$u['PERSONAL_MOBILE'],
'country'=>$u['PERSONAL_COUNTRY'], // ДОБАВЛЕНО
'specialties_code'=>$gc, 'groups_code'=>$gc
]);
} else { header('HTTP/1.0 401 Unauthorized'); }
}
public static function updateAction() {
header('Content-Type: application/json'); $req = Context::getCurrent()->getRequest();
if(!self::checkClient($req->getPost("client_id"), $req->getPost("client_secret"))) die(json_encode(['error'=>'forbidden']));
$uid = self::getUidByToken($req->getPost("access_token"));
if(!$uid) die(json_encode(['error'=>'invalid_token']));
$fields = [
"NAME" => $req->getPost("name"),
"LAST_NAME" => $req->getPost("last_name"),
"SECOND_NAME" => $req->getPost("second_name"),
//"PERSONAL_PHONE" => $req->getPost("phone"),
"PERSONAL_MOBILE" => $req->getPost("phone"),
"PERSONAL_CITY" => $req->getPost("city"),
"PERSONAL_COUNTRY" => $req->getPost("country") // ДОБАВЛЕНО
];
$newSpecs = $req->getPost("specialties");
$newNames = [];
$newCodes = [];
if(is_array($newSpecs)) {
$curG = \CUser::GetUserGroup($uid);
$finalG = [];
$allSpecG = [];
$rs = \Bitrix\Main\GroupTable::getList(['filter'=>['=C_SORT'=>555],'select'=>['ID']]);
while($g = $rs->fetch()) $allSpecG[] = $g['ID'];
foreach($curG as $gid) if(!in_array($gid, $allSpecG)) $finalG[] = $gid;
$rs = \Bitrix\Main\GroupTable::getList(['filter'=>['=STRING_ID'=>$newSpecs, '=C_SORT'=>555]]);
while($g = $rs->fetch()) {
$finalG[] = $g['ID'];
$newNames[] = $g['NAME'];
$newCodes[] = $g['STRING_ID'];
}
$fields["GROUP_ID"] = $finalG;
}
if((new \CUser)->Update($uid, $fields)) {
self::audit("PROFILE_UPDATED", $req->getPost("client_id"), $uid);
echo json_encode([
'status' => 'success',
'new_specialties' => $newNames,
'new_specialties_code' => $newCodes
]);
} else {
echo json_encode(['status'=>'error', 'message'=>'Update failed']);
}
}
public static function passwordAction() {
header('Content-Type: application/json'); $req = Context::getCurrent()->getRequest();
if(!self::checkClient($req->getPost("client_id"), $req->getPost("client_secret"))) die(json_encode(['error'=>'forbidden']));
$uid = self::getUidByToken($req->getPost("access_token"));
if(!$uid) die(json_encode(['error'=>'invalid_token']));
$np = $req->getPost("new_password");
$v = self::validatePassword($np);
if($v !== true) die(json_encode(['status'=>'error', 'message'=>$v]));
if((new \CUser)->Update($uid, ["PASSWORD"=>$np, "CONFIRM_PASSWORD"=>$np])) {
self::audit("PASS_CHANGED", $req->getPost("client_id"), $uid);
echo json_encode(['status'=>'success']);
}
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace Conmed\Authserver;
use Bitrix\Main\Config\Option;
use Bitrix\Main\Context;
use Bitrix\Main\Type\DateTime;
use Bitrix\Highloadblock\HighloadBlockTable;
use Bitrix\Main\Loader;
trait RegistrationTrait {
public static function registerAction() {
header('Content-Type: application/json');
$req = Context::getCurrent()->getRequest();
// 1. Проверка прав клиента
if(!self::checkClient($req->get("client_id"), $req->get("client_secret"))) {
die(json_encode(['error'=>'forbidden']));
}
$email = trim($req->getPost("email"));
// 2. Валидация входных данных
if(!check_email($email)) {
die(json_encode(['status'=>'error','message'=>'Некорректный Email']));
}
// Проверка на существование
$by = "ID"; $order = "ASC";
if(\CUser::GetList($by, $order, ["=EMAIL" => $email])->Fetch()) {
die(json_encode(['status'=>'error','message'=>'Пользователь с таким Email уже существует']));
}
$pass = $req->getPost("password");
$v = self::validatePassword($pass);
if($v !== true) {
die(json_encode(['status'=>'error','message'=>$v]));
}
// 3. Подготовка групп (специальности)
$arGroups = [2, 3, 4]; // Базовые группы
$specCode = $req->getPost("specialty");
if (!empty($specCode)) {
$rsGroup = \Bitrix\Main\GroupTable::getList([
'filter' => ['=STRING_ID' => $specCode, '=ACTIVE' => 'Y'],
'select' => ['ID']
])->fetch();
if ($rsGroup) {
$arGroups[] = $rsGroup['ID'];
}
}
// 4. Создание пользователя
$user = new \CUser;
$arFields = [
"LOGIN" => $email,
"EMAIL" => $email,
"NAME" => $req->getPost("name"),
"LAST_NAME" => $req->getPost("last_name"),
"SECOND_NAME" => $req->getPost("second_name"),
//"PERSONAL_PHONE" => $req->getPost("phone"), // Записываем телефон
"PERSONAL_MOBILE" => $req->getPost("phone"), // Записываем телефон
"PERSONAL_CITY" => $req->getPost("city"), // Записываем город
"PERSONAL_COUNTRY" => $req->getPost("country"), // ФИКС: Добавлена страна
"PASSWORD" => $pass,
"CONFIRM_PASSWORD" => $pass,
"ACTIVE" => "Y",
"GROUP_ID" => $arGroups
];
$uid = $user->Add($arFields);
if($uid) {
// Генерируем код для мгновенного входа после регистрации
$code = bin2hex(random_bytes(16));
$dcCodes = self::getHlEntity('sso_codes');
$dcCodes::add([
'UF_CODE' => $code,
'UF_CLIENT_ID' => $req->get("client_id"),
'UF_USER_ID' => $uid,
'UF_EXPIRES' => DateTime::createFromTimestamp(time() + 60)
]);
self::audit("USER_REGISTERED", $req->get("client_id"), $uid, "Email: ".$email);
//echo json_encode(['status' => 'success', 'code' => $code]);
echo json_encode(['status' => 'success', 'code' => $code, 'user_id' => $uid]);
} else {
echo json_encode(['status' => 'error', 'message' => strip_tags($user->LAST_ERROR)]);
}
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Conmed\Authserver;
use Bitrix\Main\Config\Option;
use Bitrix\Main\Context;
use Bitrix\Main\Type\DateTime;
use Bitrix\Highloadblock\HighloadBlockTable;
use Bitrix\Main\Loader;
trait SecurityTrait {
private static $allowedTables = [
'sso_codes' => 'SsoCodes', 'sso_tokens' => 'SsoTokens',
'sso_audit' => 'SsoAudit', 'sso_security_log' => 'SsoSecurityLog'
];
public static function checkClient($clientId, $clientSecret = false, $redirectUri = false) {
$rawList = Option::get("conmed.authserver", "client_list");
foreach(explode("\n", str_replace("\r", "", $rawList)) as $line) {
$pair = explode(":", trim($line), 3);
if($pair[0] === $clientId) {
if($clientSecret !== false && !password_verify($clientSecret, $pair[1])) return false;
if($redirectUri !== false) {
$u = parse_url($redirectUri);
if(!$u || empty($u['host'])) return false;
$domain = str_replace(['https://','http://'], '', strtolower(trim($pair[2] ?? "")));
if(strtolower($u['host']) !== strtolower(trim($domain, " /"))) return false;
}
return true;
}
}
return false;
}
public static function checkRateLimit($action = 'AUTH') {
$ip = Context::getCurrent()->getRequest()->getRemoteAddress();
$dc = self::getHlEntity('sso_security_log');
$limit = DateTime::createFromTimestamp(time() - 300);
$count = $dc::getCount(['=UF_IP' => $ip, '>UF_DATE' => $limit]);
if ($count >= 20) {
header('HTTP/1.1 429 Too Many Requests');
die(json_encode(['error' => 'Rate limit exceeded']));
}
}
public static function registerAttempt() {
try {
$dc = self::getHlEntity('sso_security_log');
$dc::add(['UF_IP' => Context::getCurrent()->getRequest()->getRemoteAddress(), 'UF_DATE' => new DateTime()]);
} catch (\Exception $e) {}
}
public static function audit($event, $clientId, $userId = 0, $details = "") {
try {
$dc = self::getHlEntity('sso_audit');
$dc::add([
'UF_EVENT' => $event, 'UF_CLIENT_ID' => $clientId, 'UF_USER_ID' => (int)$userId,
'UF_IP' => Context::getCurrent()->getRequest()->getRemoteAddress(),
'UF_DATE' => new DateTime(), 'UF_DETAILS' => substr($details, 0, 255)
]);
} catch (\Exception $e) {}
}
public static function validatePassword($pass) {
if (strlen($pass) < 6) return "Минимум 6 символов";
if (!preg_match("/[a-zA-Z]/", $pass) || !preg_match("/[0-9]/", $pass)) return "Пароль должен содержать буквы и цифры";
return true;
}
public static function getHlEntity($tableName) {
if (!isset(self::$allowedTables[$tableName])) die("Access denied");
Loader::includeModule("highloadblock");
$hl = HighloadBlockTable::getList(['filter'=>['=TABLE_NAME'=>$tableName]])->fetch();
return HighloadBlockTable::compileEntity($hl)->getDataClass();
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Conmed\Authserver;
use Bitrix\Main\Config\Option;
use Bitrix\Main\Web\HttpClient;
class Webhook {
public static function sendDelete($userId) {
$rawList = Option::get("conmed.authserver", "client_list");
$lines = explode("\n", str_replace("\r", "", $rawList));
foreach($lines as $line) {
$pair = explode(":", trim($line), 3);
if(count($pair) < 3) continue;
$clientId = trim($pair[0]);
$secret = trim($pair[1]); // Здесь хеш
$domain = trim($pair[2], " /");
// Формируем URL контроллера на удаленном сайте
$url = $domain . "/bitrix/services/main/ajax.php?action=conmed:sso.api.webhook.delete";
// Подписываем запрос (используем часть хеша секрета как ключ)
$signature = hash_hmac('sha256', $userId, $secret);
//$http = new HttpClient(['sslVerify' => false, 'socketTimeout' => 3]);
$isSslVerify = \Bitrix\Main\Config\Option::get("conmed.authserver", "ssl_verify", "Y") === "Y";
$http = new HttpClient(['sslVerify' => $isSslVerify, 'socketTimeout' => 3]);
$http->setHeader('X-SSO-Signature', $signature);
// Отправляем асинхронно (короткий таймаут), чтобы не вешать админку
$http->post($url, ['user_id' => $userId, 'client_id' => $clientId]);
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Conmed\Authserver;
use Bitrix\Main\Config\Option;
use Bitrix\Main\Web\HttpClient;
class Webhook {
public static function sendDelete($userId) {
$rawList = Option::get("conmed.authserver", "client_list");
$lines = explode("\n", str_replace("\r", "", $rawList));
foreach($lines as $line) {
$pair = explode(":", trim($line), 3);
if(count($pair) < 3) continue;
$clientId = trim($pair[0]);
$secret = trim($pair[1]); // Здесь хеш
$domain = trim($pair[2], " /");
// Формируем URL контроллера на удаленном сайте
$url = $domain . "/bitrix/services/main/ajax.php?action=conmed:sso.api.webhook.delete";
// Подписываем запрос (используем часть хеша секрета как ключ)
$signature = hash_hmac('sha256', $userId, $secret);
$http = new HttpClient(['sslVerify' => false, 'socketTimeout' => 3]);
$http->setHeader('X-SSO-Signature', $signature);
// Отправляем асинхронно (короткий таймаут), чтобы не вешать админку
$http->post($url, ['user_id' => $userId, 'client_id' => $clientId]);
}
}
}

View File

@@ -0,0 +1,62 @@
Эта документация описывает архитектуру созданной системы Single Sign-On (SSO) для сети сайтов con-med.ru. Система построена по принципу Identity Provider (IdP) и Service Provider (SP).
1. Общая архитектура
Мастер-сервер (auth.con-med.ru): «Паспортный стол». Здесь хранятся все учетные записи, пароли и основные группы (специальности).
Личный кабинет (id.con-med.ru): Основной интерфейс пользователя. Здесь происходит регистрация и редактирование профиля (данные транзитом улетают на мастер-сервер).
Сайты-потребители (vebinar, content): Проверяют авторизацию через мастер-сервер.
Сервер статистики (stat): Собирает логи действий пользователей со всех площадок.
2. Модуль сервера: conmed.authserver
Где лежит: auth.con-med.ru → /local/modules/conmed.authserver/
Файловая структура:
lib/api.php: Фасад системы, собирающий функционал из трейтов.
lib/securitytrait.php: Валидация паролей, проверка ключей сайтов (хеши), Rate Limiting.
lib/authtokentrait.php: Логика OAuth 2.0 (выдача кодов, токенов, Refresh токенов).
lib/registrationtrait.php: Регистрация новых пользователей.
lib/profiletrait.php: Обновление ФИО, города, телефона и мульти-специальностей.
lib/groupstrait.php: Отдача списка специальностей (группы с C_SORT = 555).
/api/oauth/: Точки входа для внешних запросов (user.php, token.php и т.д.).
Ключевые особенности:
Безопасность: Секретные ключи сайтов хранятся в виде BCRYPT-хешей.
IDOR Protection: Пользователь может менять только свой профиль (проверка по Access Token).
Агент: Раз в сутки запускается Api::cleanLogsAgent() для очистки старых логов.
3. Модуль клиента: conmed.sso
Где лежит: Все сайты, кроме auth → /local/modules/conmed.sso/
Файловая структура:
lib/auth.php: Основной класс. Реализует метод Authorize() (вход по коду) и AuthorizeByToken().
lib/interceptor.php: «Тихий страж». На событии OnProlog проверяет наличие куки CONMED_REFRESH. Если сессия Битрикса истекла, он незаметно обновляет её через сервер.
lib/helper.php: Синхронизация групп (сопоставление по STRING_ID).
/ajax/sso_handler.php: Единственный прокси-файл для всех AJAX-запросов фронтенда. Использует локальное кеширование специальностей (24ч).
Компоненты:
conmed:sso.auth: Модальное окно входа и регистрации.
conmed:sso.profile: Личный кабинет с табами, мульти-выбором специальностей, поиском и списком дипломов.
4. Инструкции по эксплуатации
Как добавить новый сайт в систему
На сервере auth в настройках модуля conmed.authserver добавьте строку:
ID_САЙТА : ЛЮБОЙ_ПАРОЛЬ : https://domain.ru
Нажмите «Сохранить» (пароль превратится в хеш).
Скопируйте папку модуля conmed.sso на новый сайт.
Установите модуль в админке нового сайта.
В настройках модуля введите ID_САЙТА и тот самый ПАРОЛЬ.
Подключите компонент conmed:sso.auth в футере шаблона.
Как добавить новую специальность
На сервере auth создайте группу пользователей.
Установите ей Сортировку = 555.
Обязательно задайте Символьный идентификатор (латиницей).
Список на всех сайтах обновится автоматически (в течение 24 часов из-за кеша или мгновенно после его очистки).
Как работает связка пользователей
Система ищет совпадения в следующем порядке:
XML_ID (равен ID на сервере auth).
Если не нашли — LOGIN (для склейки старых записей).
Если не нашли — EMAIL.
Это гарантирует отсутствие дублей даже при переносе старой базы 200к+ пользователей.
5. Технический журнал (Где смотреть логи)
Мастер-сервер: HL-блок SsoAudit (успешные действия) и SsoSecurityLog (ошибки/атаки).
Сайт ID: Файл /sso_final.log (отладка входа) и /sso_client_debug.log (отладка прокси).
Дипломы: HL-блок UserDiplomas на сайте id.con-med.ru.
6. TODO (План развития)
Сайт статистики: Разместить на stat.con-med.ru HL-блок и файл-приемщик для функции sendStatToHub.
Email-уведомления: Настроить на сервере auth почтовые события, чтобы при смене пароля или регистрации через API пользователю уходило письмо.
Webhook удаления: Добавить логику: если пользователь удаляется на auth, отправлять запрос на id для блокировки локальной записи.
Синхронизация Аватаров: Добавить в API передачу PERSONAL_PHOTO через base64 или прямую ссылку.
Массовая миграция: Запустить /local/tools/diplom_import.php для переноса оставшихся 200 000 дипломов (рекомендуется делать пачками по ночам).
Документация актуальна на февраль 2026 года.

View File

@@ -0,0 +1,43 @@
<?php
$mid = "conmed.authserver";
if($_POST['Update'] && check_bitrix_sessid()){
$rawList = $_POST['client_list'];
$lines = explode("\n", str_replace("\r", "", $rawList));
$processedLines = [];
foreach($lines as $line) {
$line = trim($line);
if(empty($line)) continue;
// Лимит 3 позволяет корректно обрабатывать https:// в третьем поле
$pair = explode(":", $line, 3);
$clientId = trim($pair[0]);
$secret = trim($pair[1]);
$domain = isset($pair[2]) ? trim($pair[2], " /") : "";
if (strpos($secret, '$2y$') !== 0) {
$secret = password_hash($secret, PASSWORD_BCRYPT);
}
$processedLines[] = $clientId . ":" . $secret . ":" . $domain;
}
\Bitrix\Main\Config\Option::set($mid, "client_list", implode("\n", $processedLines));
\Bitrix\Main\Config\Option::set($mid, "ssl_verify", isset($_POST['ssl_verify']) ? "Y" : "N");
}
$clientList = \Bitrix\Main\Config\Option::get($mid, "client_list");
$tabControl = new CAdminTabControl("tabControl", [["DIV"=>"edit1","TAB"=>"Настройки","TITLE"=>"Доверенные сайты"]]);
$tabControl->Begin();
?>
<form method="post">
<?=bitrix_sessid_post()?>
<?$tabControl->BeginNextTab()?>
<tr>
<td width="40%" valign="top">Список клиентов (ID:SECRET:DOMAIN):</td>
<td width="60%">
<textarea name="client_list" rows="10" cols="90" style="font-family:monospace;"><?=htmlspecialcharsbx($clientList)?></textarea>
<br>
<small>Формат: <b>ID : SECRET : https://id.con-med.ru</b> (с протоколом, без слеша в конце)</small>
</td>
</tr>
<tr><td>Строгая проверка SSL S2S:</td><td><input type="checkbox" name="ssl_verify" value="Y" <?=\Bitrix\Main\Config\Option::get($mid, "ssl_verify", "Y") == "Y" ? "checked" : ""?>></td></tr>
<?$tabControl->Buttons()?><input type="submit" name="Update" value="Сохранить" class="adm-btn-save"><?$tabControl->End()?></form>

View File

@@ -0,0 +1,40 @@
<?php
$mid = "conmed.authserver";
if($_POST['Update'] && check_bitrix_sessid()){
$rawList = $_POST['client_list'];
$lines = explode("\n", str_replace("\r", "", $rawList));
$processedLines = [];
foreach($lines as $line) {
$line = trim($line);
if(empty($line)) continue;
// Лимит 3 позволяет корректно обрабатывать https:// в третьем поле
$pair = explode(":", $line, 3);
$clientId = trim($pair[0]);
$secret = trim($pair[1]);
$domain = isset($pair[2]) ? trim($pair[2], " /") : "";
if (strpos($secret, '$2y$') !== 0) {
$secret = password_hash($secret, PASSWORD_BCRYPT);
}
$processedLines[] = $clientId . ":" . $secret . ":" . $domain;
}
\Bitrix\Main\Config\Option::set($mid, "client_list", implode("\n", $processedLines));
}
$clientList = \Bitrix\Main\Config\Option::get($mid, "client_list");
$tabControl = new CAdminTabControl("tabControl", [["DIV"=>"edit1","TAB"=>"Настройки","TITLE"=>"Доверенные сайты"]]);
$tabControl->Begin();
?>
<form method="post">
<?=bitrix_sessid_post()?>
<?$tabControl->BeginNextTab()?>
<tr>
<td width="40%" valign="top">Список клиентов (ID:SECRET:DOMAIN):</td>
<td width="60%">
<textarea name="client_list" rows="10" cols="90" style="font-family:monospace;"><?=htmlspecialcharsbx($clientList)?></textarea>
<br>
<small>Формат: <b>ID : SECRET : https://id.con-med.ru</b> (с протоколом, без слеша в конце)</small>
</td>
</tr>
<?$tabControl->Buttons()?><input type="submit" name="Update" value="Сохранить" class="adm-btn-save"><?$tabControl->End()?></form>

229
personal/index.php Normal file
View File

@@ -0,0 +1,229 @@
<?
require($_SERVER["DOCUMENT_ROOT"]."/bitrix/header.php");
$APPLICATION->SetTitle("Личный кабинет");
global $USER;
// Обработка выхода
if ($_GET["logout"] == "yes" && $USER->IsAuthorized()) {
$USER->Logout();
LocalRedirect("/auth/?login=yes");
}
// Проверяем авторизацию
$isAuth = $USER->IsAuthorized();
// Если авторизован, получаем имя
$userName = "";
if ($isAuth) {
$userName = $USER->GetFirstName();
if (!$userName) $userName = $USER->GetLogin();
}
?>
<style>
/* --- ОСНОВНЫЕ СТИЛИ (ТЕ ЖЕ) --- */
body, .main-wrapper, .page-content {
background-color: #f0f2f5 !important;
}
.conmed-personal-wrapper {
min-height: calc(100vh - 160px);
display: flex;
align-items: center;
justify-content: center;
padding: 40px 15px;
background-color: #f0f2f5;
}
.conmed-personal-card {
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.05);
padding: 40px;
width: 100%;
max-width: 600px;
text-align: center;
}
.conmed-logo {
max-width: 160px;
margin-bottom: 20px;
display: inline-block;
}
.user-greeting {
font-size: 22px;
font-weight: 600;
color: #202124;
margin-bottom: 10px;
}
.user-subtitle {
color: #5f6368;
font-size: 14px;
margin-bottom: 30px;
line-height: 1.5;
}
/* --- СПИСОК ССЫЛОК --- */
.services-list {
display: flex;
flex-direction: column;
gap: 15px;
margin-bottom: 30px;
}
.service-item {
display: block;
text-align: left;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
text-decoration: none;
transition: all 0.2s ease;
position: relative;
}
.service-item:hover {
border-color: #1a73e8;
background-color: #f8fbff;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(26, 115, 232, 0.1);
text-decoration: none;
}
.service-title {
font-size: 16px;
font-weight: 600;
color: #1a73e8;
margin-bottom: 6px;
display: block;
}
.service-desc {
font-size: 13px;
color: #5f6368;
line-height: 1.4;
display: block;
}
/* --- НОВЫЕ СТИЛИ ДЛЯ КНОПОК --- */
.auth-buttons-group {
display: flex;
justify-content: center;
gap: 15px;
flex-wrap: wrap;
}
/* Общий стиль кнопок */
.c-btn {
display: inline-block;
padding: 12px 24px;
border-radius: 6px;
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
min-width: 140px;
cursor: pointer;
}
.c-btn:hover { text-decoration: none; }
/* Кнопка "Войти" (синяя) */
.c-btn-primary {
background-color: #1a73e8;
color: #fff;
border: 1px solid #1a73e8;
}
.c-btn-primary:hover {
background-color: #1557b0;
border-color: #1557b0;
color: #fff;
}
/* Кнопка "Регистрация" (белая с синим) */
.c-btn-outline {
background-color: #fff;
color: #1a73e8;
border: 1px solid #1a73e8;
}
.c-btn-outline:hover {
background-color: #f6fafe;
color: #1557b0;
}
/* Кнопка "Выйти" (красноватая) */
.c-btn-logout {
color: #d93025;
background: #fce8e6;
border: 1px solid #fad2cf;
}
.c-btn-logout:hover {
background: #fad2cf;
color: #b3261e;
}
</style>
<div class="conmed-personal-wrapper">
<div class="conmed-personal-card">
<!-- Логотип -->
<a href="/">
<img src="https://auth.con-med.ru/local/templates/webinars/img/logo.png" alt="Con-Med" class="conmed-logo">
</a>
<!-- ЛОГИКА ПРИВЕТСТВИЯ -->
<?if ($isAuth):?>
<!-- ДЛЯ АВТОРИЗОВАННЫХ -->
<div class="user-greeting">Здравствуйте, <?=$userName?>!</div>
<div class="user-subtitle">Ваш единый аккаунт для доступа к сервисам Con-Med</div>
<?else:?>
<!-- ДЛЯ ГОСТЕЙ -->
<div class="user-greeting">Добро пожаловать!</div>
<div class="user-subtitle">Это единый портал сервисов Con-Med. Выберите ресурс или войдите в аккаунт.</div>
<?endif?>
<!-- Список сервисов (ПОКАЗЫВАЕМ ВСЕГДА) -->
<div class="services-list">
<a href="https://con-med.ru/" target="_blank" class="service-item">
<span class="service-title">CON-MED.RU</span>
<span class="service-desc">Профессиональный информационный ресурс для специалистов в области здравоохранения. Статьи, новости, клинические рекомендации.</span>
</a>
<a href="https://id.con-med.ru/" target="_blank" class="service-item">
<span class="service-title">Личный кабинет ID.CON-MED</span>
<span class="service-desc">Управление единым профилем, настройка подписок и безопасности аккаунта.</span>
</a>
<a href="#" class="service-item">
<span class="service-title">Мероприятия и Вебинары</span>
<span class="service-desc">Календарь предстоящих событий, регистрация на конференции и доступ к архиву видео.</span>
</a>
<a href="#" class="service-item">
<span class="service-title">Библиотека врача</span>
<span class="service-desc">Доступ к электронным книгам, справочникам и методическим пособиям.</span>
</a>
</div>
<!-- ЛОГИКА КНОПОК ВНИЗУ -->
<div class="auth-buttons-group">
<?if ($isAuth):?>
<!-- Кнопка Выхода -->
<a href="?logout=yes" class="c-btn c-btn-logout">Выйти из аккаунта</a>
<?else:?>
<!-- Кнопки Входа и Регистрации -->
<a href="/auth/?login=yes" class="c-btn c-btn-primary">Войти</a>
<a href="/register/" class="c-btn c-btn-outline">Регистрация</a>
<?endif?>
</div>
</div>
</div>
<?require($_SERVER["DOCUMENT_ROOT"]."/bitrix/footer.php");?>

19
register/index.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
require($_SERVER["DOCUMENT_ROOT"]."/bitrix/header.php");
$APPLICATION->SetTitle("Регистрация в системе Con-Med");
?>
<div class="container" style="padding: 40px 0;">
<?php
$APPLICATION->IncludeComponent(
"conmed:sso.register",
".default",
array(
"SUCCESS_PAGE" => "/personal/", // Куда вести, если нет backurl
),
false
);
?>
</div>
<?php require($_SERVER["DOCUMENT_ROOT"]."/bitrix/footer.php");?>