О сервисе
- Название
- TSOY ID
- Адрес
- https://id.tsoy.my
- Версия движка
- 4.3.4
- PHP
- 8.4.17
Название, логотип, цвет и текст подвала настраиваются в разделе «Настройки» — движок не навязывает своего имени.
TSOY ID — открытый OpenID-провайдер на PHP: один аккаунт на сеть сайтов, без Composer и Node. Лицензия MIT: можно использовать, дорабатывать и разворачивать у себя.
Правовые документы этой инсталляции. Редакция и дата изменения — на странице каждого.
4.3.4
Три замечания об одном: одинаковые по смыслу куски интерфейса были написаны по-разному, и каждый жил своей жизнью.
Исправлено
- Карточки слипались вплотную на страницах с сохранением. Отступ между блоками держало правило
.container > * + .bento-card— оно работало, только пока карточка была прямым ребёнком контейнера. Стоило обернуть её в<form>, а так устроена любая страница с сохранением, и отступ пропадал: на карточке роли две карточки стояли в ноль. Отступ теперь принадлежит самой карточке и не зависит от того, кто её обернул; в сетке.bentoрасстояние по-прежнему держитgap, чтобы не сложилось вдвое.
Добавлено
- Полоса действий: «Сохранить» больше не уезжает вверх. На длинных страницах — права роли, настройки, карточка портала — до кнопки нужно было домотать: человек правит галочку в середине таблицы и не видит, чем закончить. Полоса липкая, а не фиксированная: пока форма длиннее экрана, она висит внизу; домотал до конца — встала последней строкой. Формы на скрытых вкладках ничего не показывают сами собой, поэтому семь форм настроек не дают семи полос.
Восемнадцать кнопок сохранения в девяти шаблонах были написаны пятью разными наборами классов. Теперь это один вызов form_actions(), а батарея не даёт написать восемнадцатую копию руками.
- Пустое состояние одно на всю систему. Пятнадцать «пока ничего нет» были написаны четырьмя способами: где-то внутри карточки, где-то голым текстом на сером фоне. Из-за этого «Порталов пока нет» и «Организаций пока нет» — одна и та же мысль — выглядели как страницы из разных продуктов. Собрано в
empty_state(); у пустых разделов появилась кнопка «создать» прямо в состоянии, а не только в шапке.
Изменено
- Организации, порталы и webhooks устроены одинаково. Стена карточек, пустое состояние внутри неё, кнопка с иконкой в шапке. До этого организации рисовали пустоту голым текстом мимо сетки, а порталы — карточкой внутри сетки, хотя логика у разделов одна.
4.3.3
Исправлено
- После сохранения выкидывало не туда. Сохранил документы — вернулся на брендинг. Причина: якорь возврата собирался из имени раздела (
#docs), а вкладки в разметке называются иначе (tab-docs). Совпадения скрипт не находил и оставлял открытой первую вкладку — ту, с которой страница начинается. Заметнее всего это было на ошибке: человек не только не понимал, что не сохранилось, но и терял место, где стоял.
Чинится одной строкой, но класс ошибки живучий: якорь пишется в контроллере, а вкладка называется в шаблоне, и связь между ними держалась на памяти. Её теперь держит батарея — сверяет каждый redirect() с якорем против идентификаторов вкладок и элементов в шаблонах и говорит, если якорь ведёт в никуда.
- Логотип в админке возвращал в профиль. Дом зависит от того, где человек стоит: в админке это дашборд. Прежнее поведение читалось как «меня выкинуло из админки» — ровно то, чего логотип делать не должен.
- Полоса «применить миграции» уводила со страницы. Она висит в шапке любого раздела админки, а обработчик знал только собственный адрес: нажал в списке пользователей — очнулся в инструментах. Теперь возвращает туда, откуда нажали. На провале по-прежнему ведёт на экран миграций: там видно, какая упала и почему, а красное всплытие поверх прежней страницы этого не расскажет.
Адрес возврата приезжает скрытым полем, то есть из браузера, то есть подделывается. Пропускаем только путь на себя: //чужой.сайт и /\чужой.сайт начинаются со слэша и выглядят локальными, но браузер уводит по ним на другой хост — это открытый редирект с нашего домена, готовая заготовка для фишинга. Отбито и проверено в батарее.
4.3.2
Исправлено
- Миграция 014 не применялась на MySQL и MariaDB. Внешний ключ требует, чтобы тип колонки совпадал с типом той, на которую он ссылается, байт в байт:
{roles}.id—BIGINT UNSIGNED, аrole_permissions.role_idбыл объявленINT UNSIGNED. Ответ сервера — «errno: 150 Foreign key constraint is incorrectly formed», и таблица не создавалась вовсе. SQLite на типы внешних ключей не смотрит, поэтому в разработке всё было зелёное.
Тот же класс ошибки, что с SHOW TABLES LIKE ? в бэкапе: работает на SQLite, падает на боевой. Поэтому починка не только в самой миграции — батарея теперь сверяет типы всех внешних ключей во всех миграциях (27 связей) и называет расходящуюся пару поимённо.
4.3.1
Ревизия проекта — не чтением, а замером. Главное, что она нашла, стоило отдельного выпуска.
Исправлено
- Таблица прав ни на что не влияла. Каталог, таблица в базе, сетка галочек и
Auth::can()были на месте, но ни один контроллер права не спрашивал: снять все галочки и остаться с полным доступом было можно. Подключено 44 проверки по методам плюс таблицы прав для трёх общих POST-эндпоинтов — таблицей доswitch, потому что именно там проверку проще всего забыть, добавляя новое действие. - Делегированный админ остался бы без прав. Роль
client_adminзаводится лениво, при первом назначении админа портала, — миграция её не видела, и созданная после обновления роль получала пустую таблицу. Теперь права выдаются при создании роли;client_adminполучилclients.write— границу «только свой портал» держитassertClientScope, а не отсутствие права. - В
integration.mdне было ни слова проaccess_level. Уровень описан в протоколе, но не в документе, который получает разработчик портала, — то есть до сервисов не доезжал вовсе. Добавлен раздел со шкалой, примером ветвления и границами; появилась подстановка{{CLIENT_SLUG}}. - Автозагрузчик писал в лог каждый ненайденный класс, а
class_exists()— штатный способ спросить «есть ли такой»: в проде запись забивала бы лог. Теперь только в отладке. - Подпись поля логотипа обещала SVG, которого GD не читает.
Добавлено · проверки
- Состязательная проверка OIDC отдельным шагом батареи: 21 попытка сделать не то — чужой
redirect_uri, обход через../, неверныйcode_verifierиclient_secret, повторный обмен кода, битая подпись, старыйrefreshпосле ротации, служебные эндпоинты без клиента, чужойpost_logout_redirect_uri. Обычный e2e проверяет счастливый путь; этот прогон — что неверное отвергается. - Кит для портала разбирается PHP-линтером прямо в батарее. Синтаксическая ошибка в ките обнаружилась бы не у нас, а у того, кто его распакует.
- Почта: письмо при отказе SMTP остаётся в очереди с причиной и после пяти попыток уходит в
failed— мёртвый адрес не заклинивает очередь. tools/audit.php— ревизия, которой и нашлась декоративная таблица прав: маршруты против методов, переменные шаблонов против переданных, объявленные права против спрашиваемых, SQL, живущий только на SQLite.
4.3.0 · Уровень
Права перестали быть тремя ступенями, зашитыми в код. Теперь это таблица с галочками, а сервисы получают короткий ответ «сколько этому человеку можно» — не изучая наш словарь ролей.
Добавлено · права и уровень
Разбор целиком — ACCESS_MODEL.md.
- Таблица прав у роли. Право — строка
область.действие, в интерфейсе это сетка галочек: строки — области, столбцы «смотреть / изменять / удалять». Ровно rwx, и вся раскладка роли видна разом. Раньше ступеней было три — обычный,admin,superadmin, — и «тестировщик, который видит всё, но ничего не меняет» в них не помещался.
Действия, к области неприменимые (журнал аудита не правят поштучно), стоят прочерком: галочка, которая ничего не включает, — враньё интерфейсом. Изменение без чтения дописывается чтением молча и с объяснением: человек хотел разрешить, а не запретить. Права superadmin галочками не выдаются — снять их с себя значило бы запереть систему.
access_level0–99 иaccess_tierсловом едут порталу в токене. Число упорядочено,>= 70осмысленно; ступени с промежутками, чтобы новая не ломала уже написанные сравнения. Внутри SSO уровень не решает ничего — там решает таблица прав. Статус старше роли: заблокированный получает 0.
Уровень намеренно не зашит в номер аккаунта, хотя такая идея была первой: номер перестал бы быть идентификатором, появился бы второй источник правды рядом с подписанным roles, а «99…» в тикете само показывало бы, кого фишить. Разбор — ACCESS_MODEL.md §0.
- Реквизиты удостоверения — во всплывающем окне с QR: номер, дата, уровень,
sub, MRZ. В коде только номер аккаунта: QR со скриншота живёт вечно, и класть туда что-то дающее доступ нельзя.
Добавлено · полномочия администраторов
- Ссылка для входа — для поддержки, когда человек не помнит пароль и не имеет доступа к почте. Одноразовая, 15 минут. Письмо владельцу уходит мимо настроек уведомлений: это единственное, что отличает помощь поддержки от тихого входа в чужой аккаунт.
- Окончательное удаление мимо 14-дневного холда — в опасной зоне. Холд защищает от случайности, а не от намерения. Записи аудита сохраняются: журнал безопасности обязан переживать своих фигурантов.
- Маска IP в личном профиле стала настройкой. В админке адрес не маскировался никогда: по «91.132.✻.✻» инцидент не разобрать.
Изменено
- Меню по вложенности: организация объединяет порталы, портал — людей. Раньше ряд читался случайным списком.
- «О сервисе» — лента релизов: строка с краткой сводкой, клик — окно с полным текстом. Разбор
CHANGELOG.mdвынесен вApp\Core\Changelog. - Настройки: «Сроки» переехали в свою вкладку — внизу «Политик» получались две кнопки «Сохранить изменения» подряд. Тумблеры капчи вынесены из тесной колонки, где переключатель выезжал за карточку.
- Подвал: язык и тема иконками, документы — одной ссылкой на «О сервисе». На экране входа документы остались ссылками: там человек с ними соглашается.
Исправлено
- Бэкап падал на MySQL и MariaDB.
SHOW TABLES LIKE ?не принимает плейсхолдер — обычныйSELECTпринимает, аSHOWнет. На SQLite это не проявлялось, поэтому в разработке было зелено, а падало только на боевой. SqliteCompatне понималCREATE TABLE IF NOT EXISTSи падал с «Undefined array key 1» — по такому сообщению причину не угадать.- Роли создаёт инсталлятор после миграций, и её PHP-шаг этих ролей ещё не видит: на свежей установке у всех оставался уровень 20 и пустая таблица прав.
- Автозагрузчик молчал о ненайденном файле класса. Теперь пишет ожидаемый путь: чаще всего причина в том, что файл не доехал при заливке.
- Мастер смены домена винил чужой экземпляр при 404, хотя чаще виновато правило веб-сервера, перехватывающее весь каталог
/.well-known/. - Сводки релизов брались из середины фразы: продолжение пункта списка выглядело обычным абзацем.
- Выделение, разорванное переносом строки, оставалось звёздочками. Разметка шла построчно, и
**жирноев конце одной строки не находило пары в начале следующей. Касалось и правовых документов: их текст свёрстан по ширине окна редактора, где такой перенос — норма, а не исключение.
4.2.0 · Хозяин
У инсталляции появился владелец — и всё, что нельзя отменить, спрятано за его паролем. Заодно закрыта дыра, из-за которой почта подтверждалась без владельца адреса, а необратимые операции перестали выглядеть как обычные кнопки.
Добавлено · системный раздел владельца
Замысел целиком — SYSTEM_LIFECYCLE.md.
- Владелец инсталляции — отдельно от
superadmin: ту роль выдают в два клика, чтобы человек завёл портал, и кнопке «стереть всё» там не место. Владение наследуется старейшим суперадмином, передаётся с подтверждением получателя, восстанавливается черезphp system.php owner:set./admin/systemне владельцу отвечает 404, а не 403. - Состояние базы и проверка чужого подключения. Раньше о базе нельзя было узнать ничего, не открыв
config.phpпо SSH. Проверка ничего не переключает — сначала убеждаешься, потом переключаешь. Переключение проходит только когда все четыре проверки зелёные, и отказ случается до записи конфига: приложение с недоступной базой выглядит работающим. - Мастер смены домена. Кнопка не появляется, пока новый адрес не докажет словом с
/.well-known/tsoy-move, что уже отвечает этим же экземпляром. Экран последствий считает по базе: сколько passkey умрёт (rpId привязан к хосту), у кого passkey — единственный вход, каким порталам менятьissuerи с кем связаться некому. Применение: копия конфига, вебхукissuer.changed, письма администраторам порталов, откат в течение суток. - Сброс двух уровней. «Данные» сносят людей, оставляя порталы, роли, настройки и ключи. «Проект» возвращает установку к состоянию сразу после инсталляции, перевыпуская ключи,
APP_KEYи cron-токен. Четыре ограждения, все проверяются на сервере: свежий бэкап (факт скачивания), набранный руками адрес, код из письма владельцу, запись в файловый лог. App\Core\ConfigFile— генератор конфига переехал изinstall/: каталог после установки удаляют, а конфиг нужно переписывать. Резервная копия не полагается на точность часов — при занятом имени добавляется счётчик.
Исправлено
- Почта подтверждалась сама. Аккаунт, созданный через форму, оказывался подтверждённым, хотя владелец по ссылке не нажимал. Разобрал по шагам: сценарий «зарегистрировался → походил по настройкам → вернулся на главную» почту не подтверждает — проверил и в чистом браузере, и в браузере, где уже открыт другой аккаунт. Подтверждает другое: один посторонний GET по ссылке из письма. По ссылкам из писем ходят Outlook Safe Links, антивирусы, корпоративные сканеры и превью в мессенджерах — они гасили одноразовый токен за человека, и тот видел готовый результат, ничего не нажимая.
Теперь /verify/{token}, /email/confirm/{token} и /email/revert/{token} на GET показывают страницу с кнопкой, а действие выполняет POST. Сканер кнопку не нажимает. Заодно закрылось подтверждение чужой почты по пересланному письму. Цена — один клик; правило тридцати секунд не страдает, а «подтверждено без владельца» страдало сильнее.
Добавлено · опасные зоны
- Удаление портала. Раньше портал можно было только выключить. Удаление живёт отдельной страницей за паролем: кнопка, уносящая
client_id, секрет и все доступы, не должна попадаться тому, кто зашёл поправитьredirect_uri. Подтверждение — слаг руками. Уборка не полагается на каскады: в SQLite они работают лишь при включённом PRAGMA, а часть таблиц ссылается на портал без внешнего ключа вовсе — оставленные строки это выданные токены, которые никто не отзовёт. App\Core\DangerZone— общий механизм: область видаclient:5,org:3, пароль, десять минут. Подтвердив пароль ради одного объекта, человек не получает открытую дверь ко всем. Проверка стоит на сервере, а не только рисуется: прямой POST в обход закрытой зоны отклоняется.
Добавлено
- Галочки согласия в обычной регистрации. В v3.9 они появились только на экране соцвхода, а в форме осталась пассивная строка «создавая аккаунт, вы принимаете…». Согласием она не является, и два пути регистрации расходились. Теперь форма показывает те же галочки по включённым документам; выключены все документы — блока нет вовсе.
- Раздел «Сроки» в настройках. Сроки жили константами по всему коду: сессия в политиках, срок access-токена числом
900вTokenFlow, сроки ссылок из писем — константами вOneTimeToken. Укоротить их без правки кода было нельзя, а просят именно укорачивать. Теперь пять полей в одном месте: сессия (от 5 минут), «запомнить меня», access-токен (от минуты — короче, значит блокировка быстрее дойдёт до порталов), ссылка подтверждения почты, ссылка сброса пароля. Границы объявлены один раз и используются и формой, и сохранением, иначе поле принимало бы значение, которое контроллер молча обрежет. Ноль нигде не значит «вечно». - Суперадмин может сменить номер аккаунта. Контрольная цифра обязана сходиться, занятый номер не отдаётся, прежний остаётся в аудите — иначе обращения по старому номеру повисли бы в воздухе.
Изменено · организации доведены до рабочего вида
Раздел выглядел стендом «чтобы было»: удаление голой кнопкой с одним «вы уверены?»; название и описание нельзя поменять после создания; роль участника менялась только через «убрать и добавить заново», то есть с потерей даты добавления; выключение — единственное действие раздела без записи в аудит, хотя снимает доступ со всех участников разом; форма выдачи доступа с пустым списком порталов молча ничего не делала; в английском интерфейсе половина раздела оставалась русской. Всё перечисленное закрыто.
Роли переименованы в «Владелец организации» и «Администратор организации»: ключ «Владелец» уже занят владельцем удостоверения в профиле, и словарь давал в английском «Holder» вместо «Owner».
Изменено
- Недоступная база больше не выглядит работающей установкой. «Нет колонки» и «нет базы» ловились одинаково; первое пережить обязательно, второе — нельзя, но именно это и происходило: страницы отдавали 200 на файловых дефолтах, а падали только POST-ы. Теперь неудача соединения — отдельный тип
DbUnavailable, ответ 503 с прямым текстом. Заодно режим обслуживания стал отвечать 503 вместо 500:render(500)перебивал выставленный код, и плановые работы выглядели для мониторинга аварией. - Подвал на телефоне.
space-betweenразносил каждую перенесённую строку по краям: копирайт слева, язык посередине, тема справа, на разной высоте. На узких экранах собирается по центру. - Мобильные поля больше не зумят страницу при фокусе (Safari делает это, когда поле мельче 16px, и обратно не отматывает). Поле даты не вылезает за карточку.
- Карточки, стоящие вне сетки
.bento, липли вплотную:col-*вне грида не значит ничего, и зазор был ровно 0. Проверено замером на 20 страницах, включая нетронутые; горизонтальных переливов на 375px нет ни на одной. /admin/tools: выноска про планировщик срасталась со списком, а карточка, кончающаяся абзацем, была на 12px ниже соседней, кончающейся кнопкой. Правка в CSS, а не в разметке: причины общие для всех страниц.- «Принимаю политика конфиденциальности» → винительный падеж, на обоих экранах.
Инфраструктура
tests/router.php: встроенный сервер работал изpublic/, а путь к базе в конфиге относительный. PDO падал, ноSetting::load()глушит исключение — страницы отдавали 200 на дефолтах, и ломались только POST-ы. Роутер возвращает рабочий каталог в корень проекта.
4.1.0 · Мост
Портал сам рассказывает SSO о себе — один статический файл {base_url}/.well-known/sso-portal.json. Спецификация — в PORTAL_PROTOCOL.md, готовый пример — в integration.md, который скачивает администратор.
Добавлено
- Роли не вбиваются руками. Кнопка «Спросить у портала» забирает манифест; роли запоминаются, а сопоставление предлагается по похожему слагу — в подсказке поля, не в значении. Карта управляет доступами, и заполнять её по содержимому чужого файла нельзя: сменился файл — сменились бы права. Кнопка «Подставить предложенное» переносит подсказки в поля, дальше человек смотрит и сохраняет сам. Роли в SSO по манифесту не заводятся: какие роли есть у нас, решаем мы.
- Совместный «мостик». Человек, нажавший «войти» на портале, попадает на чужой домен; если экран выглядит иначе, это читается как подмена. Теперь на экране входа логотип портала, его цвет и подпись — рядом с нашими.
- Фоновый обход раз в час: порталы, которых ещё не спрашивали или спрашивали больше суток назад. С отступом после неудачи — иначе портал без манифеста опрашивался бы вечно. В карте я предлагал дёргать манифест после первого входа через портал; от этого отказался — запрос к чужому серверу занимает до восьми секунд, и они легли бы прямо на вход человека.
- Раздел о манифесте в
integration.mdс примером на PHP и подставленными данными портала. Без него протокол остался бы на бумаге. - Миграция
012_bridge.
Безопасность
- CSS от портала не принимается ни в каком виде, и в схеме для него нет даже места. Заказчик предлагал забирать «css макета страницы авторизации» — так делать нельзя: произвольный CSS позволяет спрятать логотип SSO, перекрыть адрес, подменить подписи полей и нарисовать поверх формы что угодно, то есть оформить страницу входа под чужой сервис и собирать пароли от него. Вместо CSS портал отдаёт параметры: цвет, логотип, подпись. Результат тот же, поверхность нулевая.
accent— только шестизначный hex;red,rgb(…),#fff,var(--x)и#123456;} body{…отвергаются, проверено тестом. Значение уходит в атрибут style, и всё непроверенное там становится вектором.- Логотип скачивается и хранится своей копией: ссылка на чужой домен раскрывала бы порталу IP каждого, кто открыл экран входа, включая тех, кто в этот портал не собирался, и картинку по ней можно подменить после проверки. Хост обязан совпадать с хостом портала, редиректы не выполняются, перекодирование через GD убивает всё, что картинкой не является.
Исправлено
DB::upsertобрамлял составной ключ конфликта одним апострофом — SQLite отвечал «no such column: client_id, slug». Нашлось только живым запросом к фиктивному порталу; статические проверки этого не видели.
Тесты
- Смоук 319 → 322 (за релиз 305 → 322).
4.0.0 · Порядок
Добавлено
- Карта ролей между SSO и порталом. SSO знает роли по-своему, сервис на той стороне — по-своему, и до сих пор портал получал claim
rolesсо слагами SSO и должен был догадываться сам: договорённость держалась в переписке. Теперь она записана. Пустое поле означает «не передавать», а не «передать как есть» — набор ролей это внутреннее устройство инсталляции. Пустая карта целиком ничего не ломает: портал получает слаги как раньше, иначе появление возможности молча обрезало бы доступы у всех, кто её не настроил. Экспорт и импорт JSON; неизвестные слаги пропускаются со списком, а не роняют импорт. Миграция011_role_map. - «Ху из зу» со стороны портала:
GET /api/v1/users/by-number/{номер}иby-email/{адрес}. Человек написал в поддержку портала и назвал номер аккаунта —subон не знает и знать не может. Поиск по адресу идёт через дополнительные адреса, поэтому находит и по тому, что оставил соцвход. - Своя капча. Не Google: для провайдера идентичности внешняя капча означала бы чужой скрипт ровно там, где вводят пароль, и открытую CSP. Порог, а не «включить»: человек, который ввёл пароль верно с первого раза, картинки разгадывать не должен. Счётчик неудач по IP в
rate_limits, а не в сессии — бот, чистящий cookie, обошёл бы сессионный счётчик. Задача одноразовая, ответ в сессии хэшем, картинка сno-store. - Чистка вручную и картина занятого места. Cron-задачи и сроки хранения были и раньше, но всё упиралось в планировщик — а он после установки настроен далеко не всегда (та же грабля, что с выгрузкой в v3.5). Кнопка «Почистить сейчас» выполняет ровно те же задачи: список вынесен в общий метод, а не скопирован. Видно, сколько занимают логи, выгрузки, аватары и сколько строк в аудите. Уборка сирот — файлов без записей в базе: на dev-базе первый запуск нашёл 228 файлов аватаров, 22 фотографии и 61 выгрузку.
DB::columnExists()и необязательный PHP-шаг миграцииNNN_имя.phpрядом с.sql— там, где SQL бессилен.
Исправлено
- Подсказки были нечитаемы. Токен
--text-3давал контраст 2.52:1 в светлой теме и 3.16 в тёмной при норме AA 4.5 — вдвое ниже. Это не нюанс оформления: подсказки стоят под каждым полем, то есть половина объяснений в интерфейсе была написана текстом, который трудно прочесть. Цвета подобраны числом против худшего фона каждой темы: 4.62 и 4.64. Закрыто проверкой в батарее. - Подвал экрана входа складывал элементы в стопку: в
base.phpфлекс задаёт.container, а вauth.phpконтейнера нет.
Переведено
- Пользовательская часть переведена целиком. Раньше английской была только админка, а
templates/profile/иtemplates/auth/содержали русский текст прямо в разметке. Теперь весь интерфейс двуязычен: словарь 528 → 676 ключей. - Способ проверки важнее объёма:
t()возвращает ключ при отсутствии перевода, значит русский рендер после обёртки обязан остаться прежним. Каждый заход сверялся с эталоном страниц. Проверка трижды сработала не впустую — нашла ошибку в самом нормализаторе, зашитый бренд «TSOY ID» на удостоверении (в white-label продукте это дефект) и объяснила сдвиг разметки от?>.
Тесты
- Смоук 278 → 305, проверка контраста в батарее ловит регрессию цвета.
- Два теста пришлось чинить: один переиспользовал API-токен из ранней секции, а секция v3.4 намеренно роняет таблицу
api_tokensради проверки устойчивости; другой бралpublic_idиз выборки, где его не запрашивали.
3.9.0 · Согласие
Добавлено
- Пакет правовых документов: политика конфиденциальности, согласие на обработку персональных данных, пользовательское соглашение и реквизиты владельца. Каждый включается отдельно, текст правится из админки и переживает обновление кода. Есть заготовки на русском с подстановкой названия, домена, владельца и контакта из настроек — но это заготовки, и в админке прямо сказано, что за содержание отвечает владелец установки. Публичные страницы
/legal/{ключ}, ссылки в подвале, миграция010_legal. - Версия документа. Каждое изменение текста поднимает редакцию, и запись о согласии хранит её номер. Без этого запись бессмысленна: документ перепишут, а «согласился» будет указывать на текст, которого человек никогда не видел. Редакция растёт только при реальном изменении — иначе открыл-закрыл форму, и у всех согласия протухли.
- Соцвход больше не заводит аккаунт молча. До сих пор учётная запись создавалась сразу после ответа провайдера, и человек узнавал о ней постфактум. Теперь между «провайдер подтвердил личность» и «аккаунт создан» стоит экран «Создать аккаунт?»: конкретный список того, что приедет (адрес, имя, телефон, фотография), а не формулировка «данные профиля», и галочки на каждый включённый документ. Галочки не предустановлены и обязательны — согласие, проставленное за человека, не является согласием. Отмена не оставляет ни аккаунта, ни связки; возвращающиеся входят как раньше.
- Экран входа — лицо группы проектов. На широком экране две колонки: представление слева, форма справа; на узком — одна, как было. Фон — мягкое пятно акцента инсталляции, ровно один визуальный приём: цвет живёт в фоне страницы, карточка остаётся на непрозрачной подложке, поэтому контраст текста не зависит от выбранного акцента. Popup-режим остаётся компактным.
- Согласия попадают в выгрузку персональных данных.
Исправлено
- Сохранённый секрет выглядел как незаполненный. Пароль SMTP и секреты провайдеров рендерились пустым полем, неотличимым от «не настроено». Теперь при сохранённом значении в поле стоит
••••••••, а у пустого — ничего, чтобы точки не врали. Сам секрет наружу по-прежнему не отдаётся. - «Соцвход» переименован в «Вход через сервисы» по всему интерфейсу.
Безопасность
- Разметка документов — подмножество Markdown, HTML запрещён. Тексты правит администратор, а администратор не всегда тот, кому можно доверять произвольный HTML: одна учётка с правами на настройки превращается в XSS на странице, которую видят все посетители. Вход экранируется целиком, разметка собирается уже из экранированного, ссылки проверяются по схеме —
javascript:иdata:остаются текстом, внешние получаютrel="noopener noreferrer". - Экран «Создать аккаунт?» недостижим без пройденного OAuth: состояние кладёт только callback, TTL 600 секунд.
- Выключенный документ отдаёт 404, а не пустую страницу.
Тесты
- Смоук 274 → 278 (за релиз 251 → 278). Проверяется, что разметка не пропускает скрипты и опасные схемы, что редакция растёт только при изменении текста, что повторное согласие не плодит записей, а новая редакция создаёт новую, и что прямой POST не заводит аккаунт.
- Тест доступности из батареи поймал недосмотр: у textarea документов не было подписи. Два собственных теста оказались негодными и переписаны — один зависел от начального состояния базы, другой проверял код ответа 302 там, где раньше срабатывает CSRF и возвращает 419.
3.8.0 · Профиль
Сервис — не только OAuth-прослойка: канонический профиль живёт здесь, порталы его только читают. Значит, и относиться к нему надо как к источнику правды.
Добавлено
- Номер аккаунта. У аккаунта был только
sub— UUID: стабильный, уникальный и совершенно непроизносимый. Продиктовать его в поддержку нельзя. Теперь есть девятизначный номер вида4827-13905: случайный, потому что последовательный выдавал бы наружу и число пользователей, и порядок регистрации, и темп прироста — два номера измеряли бы прирост базы. Последняя цифра контрольная (Луна, ISO/IEC 7812-1): ловит любую одиночную опечатку и все перестановки соседних цифр, кроме известной алгоритму пары 0↔9. Номер показан в удостоверении и карточке админки, ищется в админке и палитре в любом виде записи, приезжает порталу claim'омaccount_numberв scopeprofileи попадает в выгрузку данных. Субъекта по-прежнему определяетsub. Миграция008_account_numberс PHP-шагом: существующие аккаунты получают номера в перемешанном порядке, чтобы случайность не выродилась в порядок id. - Галерея фотографий. Все четыре провайдера отдают ссылку на фото при каждом входе — до этого релиза она складывалась в
linked_identities.metaи не использовалась вообще: данные собирались и выбрасывались. Теперь фото копятся в галерее, а аватарку человек выбирает нажатием на плитку. Аватарка сама не меняется: поставил своё фото — вход через Яндекс пополнит галерею, но не подменит выбранное; автоматически ставится только когда аватарки нет вообще. Загруженное вручную попадает туда же, так что все фотографии в одном месте. У каждой видно источник; удаление текущей снимает и аватар, чтобы он не ссылался в пустоту. Миграция009_photos. - Имя и фамилия от провайдеров — по отдельности. Соцрегистрация клала всю строку в
first_name: «Артём Цой» целиком уезжал в имя, фамилия оставалась пустой, и это вылезало в удостоверении и MRZ. Telegram, VK и Яндекс отдают поля раздельно — теперь они и берутся. У GitHub есть только слитное имя: оно идёт в «отображаемое имя», а не режется наугад по пробелу, потому что «Анна Мария Ковалевская» разрежется неправильно. Забирается и дата рождения; без года (VK «15.6», Яндекс «0000-06-15») она отбрасывается целиком — пустое поле честнее выдуманного года. - Дозаполнение профиля переехало в
App\Core\ProfileAbsorbи оставляет след в аудите (profile.absorbedсо списком полей и провайдером): видно, что и откуда приехало. Правило прежнее и главное — заполняем только пустое, введённое человеком провайдер не перетирает. DB::columnExists(): проверка отдельной колонки, а не таблицы целиком. До этого код определял «применена ли миграция» по соседней таблице из того же файла — работало, но держалось на том, что автор помнит состав файла. Миграции получили необязательный PHP-шагNNN_имя.phpрядом с.sql.
Безопасность
- Скачивание фото — только с хостов провайдера. Ссылку даёт ответ провайдера, то есть внешние данные, а загрузка по произвольному адресу с сервера — это SSRF. Хост сверяется со списком, редиректы не выполняются (иначе разрешённый хост первым же 302 увёл бы куда угодно), схема только https, размер и таймаут ограничены. Проверено:
http://, внутренний адрес169.254.169.254, посторонний хост иavatars.yandex.net.evil.comне скачиваются. - Файлы галереи отдаются по строгой маске имени:
../512.webpи прочее не проходит. Ошибка загрузки фото никогда не ломает вход — фотография украшение. - Анонимизация не оставляла фотографии на диске. Очистка обходила каталог по маске
/*, в подкаталогphotosне заходила,unlinkна каталоге не срабатывал, аrmdirне удалил бы непустую папку. Найдено при написании теста; фотографии удалённого аккаунта пережили бы удаление.
Тесты
- Смоук 224 → 251, юнит-тесты дополнены вектором Луна из ISO/IEC 7812-1 и проверкой, что мимо контрольной цифры проходит только пара 0↔9.
- Тест на поиск по номеру сперва оказался пустышкой: он искал email админа, который есть в меню профиля на каждой странице админки, и проходил бы при полностью сломанном поиске. Переписан на ссылку из строки результата и проверен намеренной поломкой поиска.
3.7.0 · На домашний экран
Исправлено
- Вёрстка сдвигалась вбок при появлении полосы прокрутки. Короткая страница без полосы шире длинной с полосой на ширину самой полосы, и при переходе между разделами весь интерфейс прыгал на 15 пикселей. Теперь место под полосу зарезервировано всегда (
scrollbar-gutter: stable). Тот же скачок был при открытии модалки: блокировка прокрутки вешалась на<body>и добивалась компенсирующимpadding-right. С постоянным жёлобом компенсировать нечего — блокировка переехала на<html>, владельца полосы, а padding-хак удалён: оставленный, он бы теперь сам создавал сдвиг. Замерено: ширина контейнера 1185px одинакова на прокручиваемой и непрокручиваемой странице, открытие и закрытие палитры — 1200 → 1200 → 1200.
Добавлено
- Установка на домашний экран (PWA). Манифест генерируется из настроек брендинга — название, короткое имя по границе слова, акцент как цвет темы, ярлыки на «Безопасность» и «Сервисы»; иконки 192/512 и отдельные maskable собираются на лету из загруженного favicon, а если его нет — акцентный квадрат со знаком «●», тем же, которым интерфейс подменяет ненайденный логотип. Иконки рисуются в двойном размере и уменьшаются: GD не сглаживает ни дуги скругления, ни залитый круг. Результат кэшируется на диск с ключом от акцента, названия и исходника — сменилось любое, картинка пересобирается.
- Офлайн-заглушка вместо браузерного «динозавра»: адрес в строке остаётся тем, куда человек шёл, поэтому «Повторить» перезагружает нужную страницу, а не саму заглушку. Проверено с погашенным сервером: страница пришла из кэша со шрифтами, стилями и иконками.
- Учёт вырезов экрана. В установленном приложении браузерной обвязки нет: шапка вставала под статус-бар, подвал — под индикатор жеста, в альбомной ориентации края уезжали под «чёлку». Боковое поле и нижний вырез сведены в
--gutterи--safe-b— до этого мобильный медиазапрос перебивал отступы вместе с вырезом ровно там, где вырез и есть. В standalone заодно отключена оттяжка страницы вниз. - Кнопка «Установить» в настройках профиля появляется только когда браузер сам подтвердил, что установка возможна; в Safari, который такого события не присылает, вместо кнопки — куда нажать в самом браузере.
- Атрибут
hiddenтеперь действительно скрывает. Браузер прячет такие элементы правилом[hidden] { display: none }с той же специфичностью, что у любого нашего класса, а авторский стиль бьёт браузерный — и.bento-cardсdisplay: flexпоказывал помеченнуюhiddenкарточку как ни в чём не бывало. Нашлось на карточке «Приложение», которая обязана быть невидимой, пока браузер не подтвердит возможность установки.
Безопасность
- Service worker намеренно скупой. На origin SSO он перехватывает все запросы, поэтому кэширует только версионированную статику из
/assets/и никогда HTML: страница несёт имя, аватар и CSRF-токен, а отданная из кэша показала бы чужие данные или сломала отправку формы. Протокол и API —/authorize,/token,/userinfo,/login,/logout,/fedcm/,/api/,/scim/*и прочее — проходят мимо воркера целиком; POST не перехватывается вообще. Проверено в браузере: в кэше только/assets/**и офлайн-страница. - Офлайн-заглушка рисуется минимальным layout'ом без шапки и профиля: её кладёт в кэш воркер из сессии вошедшего, и любые личные данные увидел бы следующий, кто откроет приложение на этом устройстве.
- Сам воркер отдаётся с
no-cache: закэшированный, он держал бы старую статику и обновление приложения застряло бы. - Манифест, иконки и
sw.jsбольше не стартуют PHP-сессию. Браузер тянет их вне пользовательского контекста, и каждый такой запрос заводил пустую сессию; заодно ушлиPragma: no-cacheиExpires: 1981, из-за которых иконка с недельным кэшем перезапрашивалась каждый раз.
Тесты
- Смоук 200 → 217: манифест разбирается и берёт название из брендинга,
short_nameв пределах 12 знаков, есть и обычные иконки и maskable, все четыре PNG нужного размера, произвольный размер не генерируется, воркер отдаётся как javascript с областью на весь origin и без кэша, имя кэша содержит версию, офлайн-заглушка без имени, шапки и выхода. - Целостность шаблонов: каждое
icon('…')есть в спрайте (опечатка в имени раньше давала пустое место без единой ошибки), оба layout'а зовутpwa_head(), в списке обхода воркера на месте все протокольные пути, запись в кэш стоит внутри ветки/assets/, POST не перехватывается, атрибутhiddenпобеждает классы сdisplay.
3.6.0 · Один аккаунт
Добавлено
- Слияние аккаунтов-дублей. Сценарий: человек вошёл через Яндекс до того, как привязал его к основному аккаунту, — соцвход завёл дубль. Раньше попытка привязки упиралась в тупик «уже привязан к другому пользователю». Теперь вместо тупика — экран «Это случайно не вы?»: маскированный email дубля, дата создания, что именно переедет. Владение соцаккаунтом доказано пройденным OAuth; если у дубля есть пароль, спрашивается и он. При подтверждении в основной аккаунт переезжают соцсвязки, passkeys, доступы к порталам, роли, членство в организациях и email дубля; телефон и пол дозаполняют пустые поля, ничего не перетирая. Дубль закрывается со следом
merged_into, его сессии гасятся везде, включая порталы (back-channel). Соцвход в уже слитый дубль прозрачно приводит в основной аккаунт. Попасть на экран слияния можно только из OAuth-потока: с произвольным id — нельзя. - Установка пароля у беспарольных аккаунтов. Аккаунт от соцвхода или magic-ссылки пароля не имеет, а форма требовала «текущий», которого не существует, — установить пароль было невозможно в принципе. Теперь у такого аккаунта кнопка «Установить пароль» без лишних полей: человек уже в своей сессии, спрашивать нечего. В аудите различаются
password.setиpassword.change. - Телефон, пол и дополнительные email. Яндекс отдаёт телефон и пол — теперь они забираются (права
login:default_phoneиlogin:info) и дозаполняют пустые поля профиля. Email каждого провайдера копится в дополнительных адресах: вошёл через Яндекс — остался яндексовый, через Google — gmail. По любому из них аккаунт находится при следующем соцвходе — это главное лекарство от дублей. Обязательность телефона и пола — переключатели в политиках, по умолчанию выключены; требование мягкое: напоминание в профиле, а не запертая дверь. Телефон под UNIQUE, смена сбрасывает подтверждение. Миграция007_account_data. - Claim
genderв scopeprofile(OIDC Core §5.1) и в discovery; новые поля и дополнительные адреса попали в выгрузку персональных данных.
Безопасность
- Поиск аккаунта по дополнительному адресу учитывает только подтверждённые: страховка от перехвата чужих соцвходов через ручной ввод чужого email.
- Чужой телефон не присваивается ни из профиля, ни из соцвхода (UNIQUE проверяется до записи, конфликт не роняет вход).
Тесты
- Смоук 187 → 200: полный цикл слияния (перенос связки, дозаполнение телефона, след merged_into, email как доп-адрес, отбитие повторного слияния), недоступность экрана слияния без OAuth, нормализация телефона, сохранение пола, выключенность политик по умолчанию. Тест поймал реальный баг до релиза: перенос телефона падал на UNIQUE, пока номер держал дубль.