О сервисе
- Название
- TSOY ID
- Адрес
- https://id.tsoy.my
- Версия движка
- 4.69.0
- PHP
- 8.4.17
Название, логотип, цвет и текст подвала настраиваются в разделе «Настройки» — движок не навязывает своего имени.
TSOY ID — открытый OpenID-провайдер на PHP: один аккаунт на сеть сайтов, без Composer и Node. Лицензия MIT: можно использовать, дорабатывать и разворачивать у себя.
Правовые документы этой инсталляции. Редакция и дата изменения — на странице каждого.
4.69.0 · В лицо
Замечания владельца на живых экранах после 4.68.
Исправлено
- Карта ролей показывала admin и user по два раза. В ней стояли и общие роли SSO, и одноимённые роли самого портала, а поля формы назывались по слагу: второе
map[admin]при сохранении затирало первое. Теперь в карте только общие роли SSO, а собственные роли портала уходят ему как есть, мимо карты. Для CV ничего не меняется — он получает те жеadminиuser. - Добавить в состав организации по логину было нельзя. Форма обещала «почта или логин», а искала только по почте — человек, перенесённый с портала по логину, не добавлялся.
- Кнопка «копировать» теряла значок после первого нажатия: возвращала только текст.
- Подзаголовки разделов портала догнали перестановку 4.68.
Изменено
- Люди организации — одним списком: участники и те, кто ходит в её сервисы; у каждого роли в организации, отметка «участник», число сервисов. «Полномочие» отдельным органом убрано — это те же роли org_owner и org_admin, что в столбце рядом. Роль на организацию выдаётся любому из списка; человек из сервиса при этом становится участником. «В состав» — в его же строке.
- Шапка карточки объекта: крошки ведут только к родителю — имя объекта в них больше не повторяется. В шапке колонки — лицо объекта (иконка портала, аватар человека, знак организации), имя и служебные метки с кнопкой «копировать» у идентификатора.
Проверки
tests/admin_cards.php(новая): карта ролей — две строки вместо четырёх, роли портала мимо карты, одно поле на роль в форме; один список людей организации, роль человеку из сервиса делает его участником, «В состав» из строки, добавление по логину; крошки без имени объекта, лицо и копирование в шапке. Шесть мутаций.
4.68.0 · По местам
Замечание владельца: раздел «Роли» в карточке портала повторял «Пользователей» — та же выдача ролей второй таблицей тех же людей со своим списком, — и был при этом самым тяжёлым разделом карточки. Два места для одного действия. То же было у организации и у человека.
Изменено
- Карточка портала — одна тема на раздел.
- «Пользователи»: заявки, доступ, роли в строке человека, «Дать доступ» и перенос людей с портала (переехал из «Ролей»).
- «Роли»: только роли — каталог с уровнями и «Завести роль», роли, о которых сообщил портал, карта ролей (загрузка из JSON — внутри неё). Выдачи людям здесь больше нет.
- «Подключение»: связь с порталом — «Спросить у портала», причина последней неудачи, применяется ли тема входа (переехала из «Ролей»).
- Организация: роли на её область выдаются в строке участника «Пользователей» тем же органом, что у людей сервиса. Отдельного раздела «Роли» нет — своих ролей у организации нет; старый адрес ведёт в «Пользователей».
- Человек: роли выдаются в разделе «Роли» (все области сразу); урезанная копия той же выдачи в «Профиле» убрана.
Проверки
tests/deletion_events.php: «Роли» без выдачи людям, «Пользователи» с ролями и переносом, связь с порталом в «Подключении».tests/admin_smoke.php: выдача роли сервиса из строки человека, старый адрес «Ролей» организации, выдача из строки участника. Две мутации.
4.67.0 · Прогон
Прогон по просьбе владельца: замеры боевой (заголовки, закрытые пути, скорость, сжатие, cron, очереди, журнал) и разбор экранов администратора.
Исправлено
- «Отклонить» у заявки говорило «учётная запись ушла в архив». Отклонение идёт через ту же ветку, что «Закрыть», и сообщение не смотрело, что было. Теперь: заявка — «заявка отклонена», открытый доступ — «ушла в архив», уже закрытый — «и так был закрыт».
- Администратор мог закрыть доступ себе — кнопкой в своей же строке «Людей» портала. Сервер такое больше не выполняет, в своей строке вместо кнопок — «это вы».
- Бейдж «Доставка» показывал сумму, включая доставленное: у каждого живого портала висело «Доставка 5», как будто что-то не так. Теперь число — недоставленные события; ноль не рисуется.
- Плашки ролей показывали слаг («client_admin») вместо названия («Админ портала»). Слаг — в подсказке и в списке выдачи рядом с названием.
Безопасность и скорость
- Версия PHP больше не уходит наружу (
X-Powered-By: PHP/8.4.17был в каждом ответе боевой). - Сжатие ответов в
public/.htaccess(brotli, иначе deflate — каждая строка под проверкой своего модуля): HTML, app.css (137 КБ) и app.js (59 КБ) уходили несжатыми. Копия правил в заглушке корня обновлена тем же блоком — её побайтную сверку держитtests/stub_test.php, она и поймала расхождение.
Проверки
tests/deletion_events.php: сообщения «Закрыть» и «Отклонить», отказ закрыть себе, «это вы», название на плашке, число у «Доставки» до и после доставки.tests/portal_theme.php: нетX-Powered-By. Пять мутаций.
4.66.0 · Со своими ролями
Живой случай: владелец забрал роли CV, завёл их и перенёс людей кнопкой, а в «Людях» портала у перенесённых не оказалось ни одной роли. Перенос ролей не читал вовсе, а в контракте выгрузки (§11) поля для них не было — CV их и не отдавал. Итог переноса о ролях молчал, так что пропажа была не видна.
Добавлено
- Роли в выгрузке людей. Строка может нести
roles— слаги ролей человека у портала, те же, что в манифесте (или одной строкой вrole). SSO выдаёт по карте ролей портала, а без пары в карте — одноимённую роль, заведённую за порталом. - Итог переноса называет, сколько ролей выдано, какие роли портала не нашли пары и какие отвергнуты. Предпросмотр показывает роли каждого человека до нажатия.
- Контракт:
integration.md§11, шаблонusers.php,PORTAL_PROTOCOL.md.
Границы
- Роли выдаются только в области этого портала. Общие роли SSO перенос не выдаёт никогда, даже сопоставленные в карте, — кроме «Админа портала» в его же области: иначе портал своей выгрузкой раздавал бы права на весь SSO.
- Повторный перенос роли только добавляет: выданное руками не снимается.
Что сделать на установке
- Портал добавляет
rolesв свою выгрузку; после этого — «Забрать людей» ещё раз: уже перенесённые получат роли по связи с их номером у портала.
Проверки
tests/portal_roles.php(новая): роль по одноимённой роли портала и по карте, «Админ портала» в области портала, отказ в общей роли, названные в итоге слаги без пары, повтор без снятия ручной роли, мусор в поле ролей. Четыре мутации.
4.65.0 · В чужом платье
Владелец захотел, чтобы вход в CV выглядел как сам CV. Вариант «форма у CV, пароль в SSO» отвергнут им же: он учит вводить главный пароль на любом сайте, ломает ключи доступа и держит единый вход на общем домене. Форма остаётся здесь, а CV присылает своё оформление параметрами.
Добавлено
- Тема экрана входа от портала. В блоке
brandingманифеста — фон, карточка, текст и акцент для обеих тем, скругление, шрифт интерфейса и шрифт цитаты, подкраска фона. Действует, когда человек пришёл из этого портала, на всех экранах потока: вход, регистрация, «не помню», второй фактор. - Панель портала слева: его цитата и подпись. Знак SSO стоит внизу панели и в связке над формой.
- Шрифты Inter и EB Garamond (SIL OFL 1.1) — свои файлы установки. Портал выбирает шрифт ключом, не адресом.
- В карточке портала («Связь с порталом») — применяется ли тема и почему нет.
Границы
- CSS, HTML и скрипты от портала по-прежнему не принимаются. Стиль собирает SSO: в нём только проверенные hex, наши числа и наши стеки шрифтов.
- Каждая тема применяется целиком или никак: текст на фоне и карточке портала — не ниже 4.5, тёмная тема обязана быть тёмной. Приглушённый текст, ссылки, рамки полей и текст на кнопке SSO выводит сам с той же гарантией.
- Тема из базы разбирается заново на каждом показе.
Проверки
tests/portal_theme.php(новая): тема CV из его style.css; контраст на его подложках; алфавит стиля; отказы (серое на сером, светлое под видом тёмного, неполная палитра, шрифт адресом, довесок CSS, подделка в базе); экранирование цитаты; живой/authorize→/loginи/registerв теме, без портала — родной экран. Семь мутаций.
4.64.0 · Закрыть или убрать
Живой случай: пользователя test «закрыли» и «убрали» из Архивариуса, а в самом Архивариусе он остался. Архивариус получил одно access.revoked без подробностей и, как велела инструкция, только закрыл сессию; «Убрать» после «Закрыть» не отправило ничего. Решение владельца: две кнопки — два исхода.
Изменено
- «Закрыть» — в архив, «Убрать» — стереть.
access.revokedнесётmode:closed— сервис уводит учётную запись в архив (входа нет, данные целы),removed— стирает учётную запись и её данные. «Убрать» теперь сообщает и о закрытом доступе: раньше после «Закрыть» оно молчало. Отклонённая заявка по-прежнему не событие — доступа не было. - Новое событие
access.granted: доступ открыли снова — сервис возвращает человека из архива. Раньше о возврате доступа узнавали только подписчики вебхуков. - Отказ человека от сервиса в своём профиле — «закрыть», а не «стереть»: терять данные из-за одного клика нельзя.
- Предупреждения у кнопок в «Людях» портала и сообщения после действия говорят, что именно случится в сервисе.
- Набор и инструкция.
sso_on_access_revoked()получаетmode, новый хукsso_on_access_granted();integration.md§5 и чеклист приёмки.
Проверки
tests/deletion_events.php: «Закрыть» —closed; «Открыть» —access.granted; «Убрать» закрытого и открытого доступа —removed; отклонённая заявка — ничего; отказ в профиле —closed; всё — только тому порталу. Четыре новые мутации и две переписанные.tests/e2e.php, раздел 6б:modeиaccess.grantedдоезжают до настоящего набора.
Что сделать на установке
- Архивариус свою сторону делает в своём репозитории (контракт передан). Задним числом ничего не применяется: чтобы стереть test, после выкладки обеих сторон — «Открыть» → «Закрыть» → «Убрать».
4.63.0 · Три экрана
Просьба владельца: у каждого сервиса, подключённого через набор, — обязательные экраны, как у Архивариуса: настройки SSO с вводом ключа моста, ввод ключа переноса людей и батарея проверки связи.
Добавлено
- Экран настроек SSO в наборе —
sso/admin.php. Три блока: мост (издатель,client_id, адреса, области — показ; поле для нового секрета клиента), перенос людей (поле для ключа переноса), проверка связи — таблица «есть / не всё / нет» по кнопке «Проверить». Пускает администратора портала: уровень 70 в SSO или владельца установки; своя проверка —sso_is_admin()в hooks.php. Секреты не печатаются никогда — только длина и откуда взят.
- Секрет проверяется у SSO до сохранения.
/tokenсверяет клиента раньше кода: чужой секрет получаетinvalid_client, свой —invalid_grantна заведомо негодный код. Опечатка при вставке не сохраняется и не ломает вход. Ключ переноса узнаётся поpusec_: перепутать поля нельзя.
- То же из консоли, когда вход сломан и экрана не видно:
php sso/admin.php check | secret | users-key; ключ вводится с клавиатуры, а не аргументом, чтобы не остаться в истории команд.
- Одна проверка связи на всех —
sso/checks.php: экран, установщик, консоль. Новое в ней: принимает ли SSO секрет;issу discovery иkidу ключей; знает ли SSO адрес возврата (/authorizeбез сессии — 302 или 400); отвечает ли свой приёмник событий; подписанный запрос к своей выгрузке людей.
integration.md§12 «Обязательные экраны портала» — для порталов, собранных без набора: что показать, как проверить секрет, таблица обязательных проверок с критериями «есть». Два пункта в чеклисте приёмки. Строка об экране — там, где набор берут: на экране «Подключение».
Изменено
selftest.phpушёл из набора. Он был открыт всем и просил удалить себя после отладки, а его проверки разошлись с проверками установщика: одна знала про OPcache, другая про ключи подписи. Всё, что он проверял, — вchecks.php.- Секреты пишет одна функция
sso_save_secrets()— и установщик, и экран. Пишет туда, где файл уже лежит (иначе новый оказался бы в тени старого), и читает записанное обратно, сбросив OPcache. - Вход возвращает туда, откуда позвали (
login.php?back=— только путь этого сайта), а в сессии портала запоминаетсяaccess_level: по нему экран узнаёт администратора.
Проверки
tests/e2e.php, раздел 6в, — на настоящем наборе: без входа экран закрыт; администратор видит проверку, ключевые строки «есть», заглушка хука — «не всё»; чужой секрет отвергнут самим SSO, и файл секретов не тронут; секрет в поле ключа переноса не принят; ключ переноса сохраняется и сразу виден проверке; ни одно значение не печатается. Проверка из консоли — в разделе 6б.tests/admin_smoke.php— устройство набора: экран и проверка на месте, открытой самопроверки нет, секрет сохраняется только после ответа SSO, поля ввода скрытые.
4.62.0 · Удалён везде
Вопрос с живой установки: «удаляешь пользователя в TSID — он не удаляется в сервисе». По журналу 11.09 удаления не было: человека убрали из Архивариуса кнопкой «Убрать» в карточке портала. Разбор нашёл по дороге от SSO до портала семь разрывов, и ни один не был виден: у SSO всё выглядело сделанным, до портала не доходило ничего.
Исправлено
- «Убрать» не говорило порталу ничего. Кнопка стирала строку доступа и отправляла только вебхук, а портал слушает свой приёмник событий: в очереди Архивариуса за всё время лежали одни
logout. Теперь отзыв доступа сообщает порталу самAccess— одно место на все пути: карточку портала, карточку человека, профиль, API. И только если доступ был: отклонённая заявка — не отзыв.
- «Убрать» у открытого портала не держалось. Строка доступа стиралась, а при следующем входе открытый портал выдавал доступ заново: человек вернулся в Архивариус через 23 секунды. У активного доступа теперь только «Закрыть» — запись остаётся с отметкой и держит. «Убрать» осталось для заявок и закрытых записей, с предупреждением, что в открытый портал человек войдёт снова.
- Отзыв доступа через API выкидывал человека из всех порталов. Вызов шёл рассылкой по всем порталам человека, а не одному. Теперь — только этому.
- Портал не мог отличить «вернётся» от «стереть всё». Одно
user.deletedуходило на четыре разных случая. Теперь в событии полеmode:scheduled(помечен, отменить можно доrestore_until),final(срок вышел, аккаунт обезличен),purged(стёрт администратором сразу),merged(слит с другим,merged_into).
- Отмена удаления и обезличивание проходили молча. Портал, отключивший человека, так и держал бы вернувшегося отключённым и не узнавал, что возвращения уже не будет. Теперь —
user.restoredиuser.deletedсmode: final.
- Инструкция велела на удаление только закрыть сессии. Про учётную запись
integration.md§5 не говорил ни слова, и Архивариус, собранный по нему, честно оставлял удалённого у себя. Теперь: запись отключить, с данными — поmode(таблица в §5), вернувшегося включить. Два пункта в чеклисте приёмки.
- Набор не закрывал сессии удалённого. Мост гасил сессию только по
sid, а событие про человека целиком приходит без него, и пока портал не допишет хук, удалённый оставался вошедшим. Теперь мост ведёт индекс сессий поsubи гасит их сам; хук получаетmode, появилсяsso_on_user_restored().
- Самопроверка набора хвалила заглушку. Выражение, узнающее пустой
sso_on_user_gone(), не допускало комментария послеreturn false;— а он стоит в самой заглушке. «Обработчик написан» — у набора, где не написано ничего.
Добавлено
- Проверки.
tests/deletion_events.php— все пути по HTTP: удаление администратором, отмена из письма, обезличивание по сроку, стирание, самоудаление, «Убрать», «Закрыть», отклонённая заявка — и что КОМУ встало в очередь. Шесть мутаций. Вtests/e2e.php— раздел 6б: настоящее событие через очередь SSO гасит сессию тестового портала поsub, заглушка хука кладёт событие вместе сmodeв файл, самопроверка узнаёт заглушку.
Замечено про сами проверки
Первый прогон новой проверки провалил все действия администратора — и не из-за кода. CSRF-токен брался со списка людей, где нет ни одной формы, и каждое действие молча отклонялось. Сначала замерили сессию администратора, потом поправили проверку; код не трогали.
4.61.0 · Свой администратор
Жалоба с живой установки: Архивариус сообщил свои роли, и его «администратор» встал в карточке SSO как «20 · Пользователь» — той же ступенью, что обычный user.
Исправлено
- «admin» портала получал уровень пользователя. Уровень роли, приехавшей из портала, выводится из имени по справочнику.
adminв справочнике — роль управления самим SSO, для порталов она не считалась, и срабатывало «незнакомое имя — 20». Это было записано правилом (ROLES.md §4): метка, придуманная на чужой стороне, не даёт старшинства у нас. Цена правила — сломанный портал: выданный в SSO «admin» Архивариуса приезжал к нему сaccess_level20, и Архивариус, у которого карта ролей пуста и роль берётся изaccess_level, видел обычного пользователя.
Теперь admin, administrator, superadmin, owner — и они же с приставкой, как cv-admin, — получают 70, «Администратор сервиса». Выше 70 приехавшая роль не поднимается никогда: там ступени управления самим SSO.
- Уровень роли портала выходил за пределы портала. Именно от этого берегла двадцатка. Общий уровень человека («кто он в системе»: удостоверение, подсказка номера в админке) считался по всем его ролям, включая роли порталов, а уровень для портала — по области выдачи, без проверки, чья это роль. Теперь роли порталов в общий уровень не входят, а в чужом портале не засчитываются, даже выданные в обход на всю систему. Тот же фильтр — в резолвере областей: две сверки уровня обязаны сходиться.
Видимое следствие: у модератора одного портала в удостоверении SSO теперь «Пользователь», а не «Модератор» — модератор портала не модератор системы. В сам портал по-прежнему уезжает 55.
- Таблица «Портал сообщает о своих ролях» показывала не тот уровень. У уже заведённой роли там стоял уровень «по имени», а не действующий. Теперь — действующий, а если он ниже того, что значит имя, рядом кнопка «Выставить 70». Молча уровни не переписываются: уровень меняет то, что портал видит о людях. Кнопка только поднимает; в журнал уходит «было → стало».
Добавлено
- Проверки в
tests/role_offer.php:adminиcv-admin— 70, незнакомое имя — 20; своя роль даёт 70 в своём портале; в соседнем её нет, даже выданной на всю систему; резолвер областей согласен; в общем уровне человека её нет. Четыре мутации, по одной на каждое место, — и каждая краснит ровно свою проверку. Вtests/admin_smoke.php— кнопка «Выставить 70» целиком, через интерфейс. Проверка вtests/templates_test.phpпереписана: не «чужой admin — 20», а «приехавшая роль не выше администратора сервиса».
Что сделать на установке
- В карточке Архивариуса, раздел «Роли», у
adminнажать «Выставить 70». Роль заведена до 4.61 с двадцаткой и сама не поднимется.
4.60.0 · Туда, откуда пришли
Разбор по жалобам с живой установки: после входа человек оказывался в личном кабинете SSO, а после выхода — на входе SSO. Оба раза вместо своего сервиса. Каждая причина найдена по журналу аудита боевой, воспроизведена на стенде и закрыта проверкой.
Исправлено
- Выход с портала вёл на вход SSO. В журнале у каждого выхода с портала поле «портал» было пустым: SSO узнавал портал только по
id_token_hint, а Архивариус подсказку не доносил. Без портала отбрасывался любой адрес возврата, даже зарегистрированный, — и срабатывал редирект на/login. Сам адрес Архивариус к тому же слал без завершающего слэша:https://arc.tsoy.myпротив записанногоhttps://arc.tsoy.my/.
Теперь портал узнаётся по порядку: подсказка → client_id (стандарт выхода его прямо предусматривает) → единственный портал с таким адресом возврата → единственный портал с таким origin. Незарегистрированный адрес по-прежнему не исполняется, но человек возвращается на сам портал (его base_url заводил администратор), а присланный адрес ждёт в карточке с кнопкой «Добавить» — тем же механизмом, что адрес возврата при входе. Чужой адрес никуда не ведёт.
- Повторное нажатие «Войти» уводило в личный кабинет. Живой случай 10.09: два входа подряд, у второго нет выдачи кода. Первое нажатие гасило ключ возврата, второе приходило ни с чем. Ключ больше не гасится: он живёт полчаса и сам по себе ничего не открывает — код привязан к PKCE портала, а без сессии SSO его не выдадут. Вошедший, вернувшийся на
/login?rt=…, видит форму входа, а не кабинет: молча выдать код тут нельзя — ключ мог прийти изprompt=login, и переспрос пароля пропустился бы.
Старая проверка «второй раз тем же ключом — нет, он одноразовый» переписана под новый договор. Одноразовость ничего не защищала — первое использование ключа так же сильно, как второе, — а держит ключ теперь срок: истёкший ключ на портал не возвращает, это проверено и доказано мутацией. Метод, гасивший ключ, удалён — звать его больше некому.
- Экран выхода. «Вы вышли из Arc.Выйти и из остальных…» — между фразами не было пробела: закрывающий тег PHP съедал перевод строки. «Выйти только здесь» на странице SSO непонятно где — теперь «Выйти только из „Arc“». «Отмена» вела в профиль SSO, теперь — обратно на портал.
- Экран согласия. Первая фраза говорила «Это сервис TSOY ID» — но портал не сервис SSO, он работает через него. Документы стояли в одну строку через запятую и читались как один документ — теперь каждый своей строкой. Фраза «Пароль сервису не передаётся — только перечисленные данные» стояла всегда, в том числе у доверенного портала, где никакого перечня нет; теперь она только под перечнем и говорит прямо: пароль остаётся у SSO.
Уточнено
- «Грузится и ничего» на согласии — это обрыв возврата политикой CSP, починенный в 4.59. По журналу: каждое из восьми нажатий на сервере проходило целиком — согласие, выдача кода, — а браузер обрывал последний шаг. Повторное нажатие на согласии в кабинет не уводит: на текущем коде проверено, что и первое, и второе ведут на портал.
Добавлено
- Проверки в
tests/portal_connect.php: двойное нажатие «Войти»; вошедший на входе с ключом возврата; истёкший ключ возврата; выход сclient_id, без подсказки, с адресом без слэша, с чужим адресом, с двумя порталами на одном origin; экран выхода; строки экрана согласия у доверенного и недоверенного портала. Три мутации: вход снова гасит ключ; выход снова не узнаёт портал без подсказки; ключ возврата живёт вечно.
Замечено про сами проверки
Снимки экранов «в ширину телефона» показали обрезанный правый край на всех страницах входа, и я было записал это в дефекты вёрстки. Замер показал, что Chrome без окна не сужается меньше 494 пикселей, какую ширину ни задавай: снимки «430» рендерились при 494 и обрезались при сохранении. При реальной ширине документ ровно в окно, карточка резиновая. Подозрение снято до того, как попало в починку.
Там же пропали галочки в перечне данных на согласии. Причина та же — способ съёмки: страница открывалась из файла, а иконки берутся из спрайта по адресу сайта, и Chrome такую ссылку из file:// не выполняет. Та же страница, отданная по http, — с галочками. Тоже не дефект.
Прогон мусора по всем маршрутам упал, не начавшись: вход администратором получил 429. Лимит входа — пять попыток с адреса в минуту, удачные тоже считаются. Проверки срока ключа добавили portal_connect.php два входа, мутации гоняют его раз за разом, и последний прогон оставлял бюджет выбранным ровно к старту зонда. Воспроизведено отдельно: portal_connect.php, сразу за ним зонд — 429. Зонд теперь, как и portal_connect.php, сбрасывает счётчики перед своим входом.
4.59.0 · Продолжить
Кнопка «Продолжить» на экране согласия крутила спиннер вечно — в Chrome, на живой установке, при первом входе в сервис.
Исправлено
- Возврат на портал после формы обрывался политикой безопасности. Во всех ответах SSO стояло
form-action 'self'. Форма согласия уходит на/consent, ответ — 302 на/authorize, дальше 302 на портал, и Chrome сверяетform-actionс каждым шагом этой цепочки. Чужой origin — и она обрывается молча: ни перехода, ни ошибки на экране, только строка в консоли.
Под ударом были все пути, где человек возвращается на портал формой: согласие, «Не сейчас», вход паролем посреди потока, второй фактор, регистрация через соцсеть, выход с возвратом на портал. Проходил только тот, кто уже вошёл: у него возврат идёт GET-редиректом, без формы.
Все проверки ходили через curl, а curl CSP не исполняет, — поэтому ни одна этого не видела. Воспроизведено в настоящем Chromium до починки и проверено после.
Теперь политика собирается в одном месте (Csp), и form-action пускает на 'self' и на origin портала, куда человек сейчас возвращается. Origin берётся только оттуда, куда адрес попадает уже проверенным байт-в-байт: из запроса авторизации в сессии и из ключа возврата, лежащего в базе, — подделать ключ нельзя, выдуманный ничего не добавит. Подтверждение выхода добавляет свой проверенный адрес возврата. Вне потока входа политика прежняя.
- Каждая выкладка приносила на сервер права 0666. Архив поставки собирается на Windows, и все 359 файлов лежали в нём с правами
0666, каталоги — с0777.rsyncпри выкладке честно переносил их на боевую, и код SSO был доступен на запись любому процессу машины, где живут три десятка сайтов. Теперь права — в самом архиве (0644/0755), а сборка проверяет их сама и отказывается собираться, если у какой-то записи есть запись для группы или остальных.
Добавлено
- Проверка самого заголовка в
tests/portal_connect.php: посреди потока форма пускает на портал, вне потока — только свой origin, выдуманный ключ возврата ничего не добавляет. Мутация:form-actionснова закрыт — батарея краснеет.
4.58.0 · Без догадок
Подключение Архивариуса. Четыре дефекта одного вечера — и ни один не был виден из батареи, потому что каждый жил на стыке с чужим сервером.
Исправлено
- Адрес возврата угадывался. Подключение портала подставляло
…/sso/callback.php, а Архивариус слал…/sso/callback. Вход кончался страницей «адрес возврата не совпадает» — без подсказки, какой адрес пришёл.
Для нашего набора callback.php — правда: установщик кладёт такой файл. Для сервиса со своей маршрутизацией это догадка. Теперь не угадываем, а запоминаем: сервис сам присылает адрес при первом входе, SSO его не пускает (сравнение байт-в-байт остаётся законом), но записывает и показывает в карточке с кнопкой «Добавить». Страница ошибки называет присланный адрес и говорит администратору, где он ждёт.
Запоминаем только адреса с собственного origin портала. Чужой адрес прислать может кто угодно; запоминай мы его — карточку заваливали бы мусором, а среди мусора однажды нажали бы «добавить» не глядя. Кнопкой добавляется только то, что сервис действительно присылал.
Набор, установленный по ссылке, записывает свой адрес сам — у него это факт.
- Манифест «не отвечал» без причины. На любую неудачу была одна фраза. При подключении Архивариуса настоящих причин было две, и различаются они по ответу: 404, который отдал сам nginx (правило панели отдаёт
/.well-knownс диска), и 403 на путях с точкой (.htaccessрежет «скрытое»). Теперь карточка и прозвон называют причину и строку, которую надо поправить. Страницу ошибки nginx узнаём по телу, а не по заголовкуServer: за nginx-прокси любой ответ Apache тоже подписан «nginx».
- После неудачи SSO ждал сутки. Сервис чинил путь к манифесту, а в карточке до завтра висело «не отвечает». Теперь — час, дальше вдвое дольше с каждой следующей неудачей, до суток: портал, у которого манифеста нет и не будет, не опрашивается каждый час вечно. Карточка перечитывает манифест сразу после сохранения, если сменился адрес или прошлая попытка кончилась неудачей, — в фоне, чтобы сохранение не ждало чужой сервер.
- Кнопка входа молчала на сайте с htmx. На «ускоренной» странице (
hx-boost, Turbo Drive) переход уходит фоновым запросом, 302 на SSO упирается в CORS, и на экране не происходит ничего. На всех кнопках набора теперьdata-turbo="false" hx-boost="false"; в инструкции — раздел про ускорители иform-actionв CSP: Chrome сверяет с ним и адрес, куда форму уводит 302.
- Наша инструкция советовала правило, которое роняет манифест. Заготовка
location ^~ /.well-known/ { allow all; }безproxy_passотдаёт весь путь с диска — ровно та поломка, что случилась у Архивариуса. Теперь: сертификатам — только/.well-known/acme-challenge/, общий запрет на точку — с исключением для.well-known; для Apache — исключение в<FilesMatch>, проверенное на живом портале.
- Импорт людей терял человека с занятым телефоном — и показывал сырой текст базы. На
users.phoneстоитUNIQUE, а импорт проверял только адрес и логин. Второй человек с тем же номером — у кого-то в базе или выше в том же файле — падал на ограничении, строка терялась целиком, а в отчёт уходилоSQLSTATE[23000]: Integrity constraint violation….
Теперь занятый номер — как занятый логин: человек заводится без телефона, и отчёт говорит об этом словами. Проверка «номер занят» жила двумя копиями — в профиле и в поглощении профиля провайдера; теперь это один User::phoneTaken() на все три места, и нормализация номера одна: импорт писал «+7 900 …» с пробелами и без обрезки до ширины колонки.
Первая версия починки была неверной, и это стоит записать. Я решил, что сырой текст даёт дубль адреса внутри файла, и написал разбор, который так и отвечал: «такой адрес или логин уже встречался». Замер показал: дубль адреса импорт пропускает сам, а падает телефон — то есть непонятную ошибку я заменил на неверную. Теперь разбор берёт колонку из текста ограничения, а не угадывает.
Отчёт импорта заодно показывается один раз: раньше он висел на странице инструментов до конца сессии.
- Ответ драйвера базы уходил во флеш сырым. Кнопка переключения базы кладёт результаты проверок в список раздела «Система» — и тут же дублировала ответ драйвера во флеш-сообщение:
SQLSTATE[HY000] [2002] …. Флеш всплывал на любой следующей странице. Теперь ответ драйвера живёт только в списке проверок, оформленный цитатой, а флеш называет, какая проверка не прошла.
Добавлено
- Сторож собственных адресов. Раз в час cron проверяет discovery, ключи и экран входа и пишет владельцу, когда состояние сменилось на «сломано». Зачем: на хостинге с панелью конфиг веб-сервера принадлежит панели. Правку, пропускающую
/.well-knownк приложению, BrainyCP может молча перезаписать при продлении сертификата, а код панели закодирован — починить шаблон на её стороне нельзя. Остаётся узнавать в тот же час. Письмо сторожа включено по умолчанию: сторож, который молчит, пока его не включат, не сторож. Молчание сервера тревогой не считается — из cron публичный адрес может не резолвиться сам в себя.
tests/portal_connect.php— 43 проверки, сценарий подключения целиком, включая перенос людей. Шесть мутаций — по одной на каждый дефект.
Замечено про сами проверки
Первая версия проверки «чужой адрес не запомнен» была зелёной не по той причине: тест читал настройку через кэш своего процесса, а записывал её HTTP-сервер. Выдало себя соседнее — повтор «не считался», хотя сервер его посчитал. Все чтения после HTTP-запросов идут теперь мимо кэша.
Закрыта «слепота без улики» из AUDIT.md. Улика, которую харнесс начал печатать в 4.55, пригодилась при первом же повторе: слепа была мутация №9, max_age=0. Причина — граница секунды. Тест ставил сессии created_at = now(), и если запрос попадал уже в следующую секунду, возраст 1 > 0 давал «устарела» и сломанному сравнению тоже. Сессия теперь создаётся на пять секунд «из будущего»: исход больше не зависит от секундомера. Проверено пятью заходами подряд с внесённым дефектом — пять из пяти красные.
Прозвон печатает улику. Находка «SQLSTATE на /admin/tools» стоила четырёх заходов: я предполагал отчёт импорта, потом старую сессию, потом проверку соединения с базой — и не подтвердилась ни одна догадка. Прозвон печатал одно слово, без окружения. Теперь рядом с находкой он печатает окружение приметы, и первый же прогон назвал настоящий источник — флеш переключения базы. Два соседних дефекта, найденных по дороге (телефон в импорте, вечный отчёт), настоящие и починены, но к находке прозвона отношения не имели.
И последнее — про саму эту запись. Первая её версия кончалась без перевода строки, и заголовок выпуска 4.57 приклеился к её последней строке. Заголовок не с начала строки — уже не заголовок: 4.57 молча пропал из ленты «что нового» (112 выпусков → 111). Проверки ленты этого не видели: они сверяли первый выпуск с версией и смотрели на даты и сводки оставшихся — а то, что разобраны все, не проверял никто. Теперь каждый заголовок в файле обязан дойти до ленты, и мутация, склеивающая заголовок, это доказывает.