Перейти к содержанию

Приглашение с ограниченным сроком действия

Чтобы Вера стала редактором «Севера», Анна должна предложить ей участие. Простое добавление любого subject по тексту формы неудобно: владелец может не знать идентификатор внешней системы входа, а ошибочный адрес даст доступ другому человеку. Приглашение разделяет предложение и подтверждённое принятие.

В «Пространстве» приглашение содержит компанию, нормализованный адрес, предлагаемую роль, срок и случайный токен. Учебный API возвращает токен владельцу для ручного сценария; письма не отправляются. В настоящем продукте ссылка доставлялась бы адресату через отдельный процесс. Полный код расположен в InvitationEndpoints.cs архива, выполнение операций — not_run.

Токен подтверждает предложение, а не личность

Случайный токен нужен, чтобы нельзя было принять произвольное приглашение по последовательному номеру. Но обладатель ссылки ещё не обязательно Вера. Ссылка может быть переслана или попасть в историю браузера. Поэтому принятие требует входа, а адрес текущей личности должен совпасть с адресом приглашения.

В нашем локальном адаптере email берётся из таблицы users по subject. Заголовок X-Dev-Email не участвует в проверке. Настоящий механизм входа должен предоставлять подтверждённый адрес либо другую согласованную идентичность. Самостоятельно объявленный email в теле принятия не является подтверждением владения адресом.

Токен создаётся из криптографически случайных байтов. В базе сохраняется его SHA-256, а исходное значение возвращается только при выдаче. Ниже — завершённый фрагмент генерации из обработчика; SQL затем сохраняет tokenHash, но не token.

var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
var tokenHash = Convert.ToHexString(
    SHA256.HashData(Encoding.UTF8.GetBytes(token)));
var expiresAt = DateTimeOffset.UtcNow.AddDays(2);

Хеширование снижает последствия чтения таблицы приглашений: готовую ссылку из значения хеша не восстановить прямым копированием. Оно не исправляет короткий предсказуемый токен, поэтому случайность нужна отдельно. Соль пароля здесь не решает главную задачу: исходное значение уже имеет большой случайный запас, а поиск выполняется по точному хешу.

Владелец предлагает ограниченную роль

Анна может пригласить Веру как editor либо viewer. Создавать owner через приглашение наш пример не позволяет: владение передаётся уже известному участнику отдельным действием. Это уменьшает риск случайно выдать ключевые полномочия по опечатке адреса. Администратор имеет те же разрешённые роли приглашения и не обходит матрицу предыдущего урока.

Повторное приглашение того же адреса не должно бесконтрольно создавать десятки действующих ссылок. В схеме есть уникальность активного предложения для пары компания и email. Поскольку срок сам по себе не удаляет строку из частичного индекса, истёкшие приглашения явно переводятся в expired перед новой выдачей. Нельзя использовать текущее время как динамическое условие обычного уникального индекса.

При выдаче проверяется, не является ли адрес уже участником этой компании. Если является, API возвращает 409 и предлагает менять роль существующего членства. Такое поведение сохраняет смысл приглашения: оно добавляет новую связь, а не скрыто заменяет старую роль. Иначе admin мог бы повысить участника через маршрут, правила которого отличаются от управления ролями.

Срок проверяется серверными часами в базе или общей временной шкалой приложения. Часы браузера не определяют допустимость ссылки. Отображаемая дата может быть местной, но сравнение с expires_at должно означать один момент. В архиве срок хранится как timestamptz и возвращается в формате ISO.

Принятие фиксирует два изменения вместе

Маршрут принятия находится вне выбранной группы организации: Вера ещё не имеет членства и потому не прошла бы обычный tenant filter. Однако он требует входа, находит приглашение по хешу и сам строит область из сохранённой записи. Компания здесь не приходит из тела клиента.

Полный SQL поиска блокирует строку предложения, чтобы два одинаковых запроса не приняли её независимо. Далее в той же транзакции проверяются срок, состояние компании и адрес пользователя, добавляется членство и меняется состояние приглашения.

SELECT organization_id,id,normalized_email,role,expires_at,status
FROM invitations
WHERE token_hash=@hash
FOR UPDATE;

В окончательном проекте поиск использует отдельный реестр хешей и затем открывает ограниченный сеанс нужной компании. Такой реестр содержит маршрут к предложению, но не список проектов. Это техническая таблица для входящей ссылки; прямой общий SELECT прикладных данных ради нахождения компании не требуется.

Ожидаемый первый ответ для Веры — успешное принятие и новое членство editor в «Севере». Повтор с той же личностью возвращает уже завершённый результат, а повтор другой личности — отказ. Идемпотентный ответ не означает повторное создание членства: первичный ключ и заблокированное состояние исключают вторую связь.

Если вставка членства удалась, а обновление приглашения не сохранилось, общая транзакция откатывает оба действия. Это важно при сбое: иначе ссылка останется действующей, хотя участие уже создано. Так же записывается аудит принятия, чтобы история не расходилась с составом компании.

Просроченная и пересланная ссылка

Попробуйте в ручном сценарии принять предложение Веры от Бориса. Ожидается отказ из-за несовпадающего подтверждённого адреса. Затем дождитесь срока либо измените учебную запись административным SQL и повторите принятие Верой: ожидается 410 для истёкшего предложения. Курс описывает эти варианты как ожидаемые, а не как выполненные запросы.

Не выдавайте в ответе на неверный токен email приглашённого человека. Неправильная ссылка не даёт права узнавать состав компании. Для действующей ссылки после входа можно показать название компании и предлагаемую роль, но правило раскрытия нужно сохранить одинаковым в просмотре и принятии.

При отзыве предложения состояние становится revoked; старый токен перестаёт давать участие. Повторная выдача создаёт новое случайное значение. Менять срок у старой ссылки без ведома владельца опасно, потому что она могла уже попасть не тому адресату. История приглашений остаётся предметным аудитом, а исходные токены не пишутся в журнал.

Адрес не является поиском участника

Нормализация email в примере ограничена Trim и приведением к нижнему регистру. Она соответствует заранее заданным fixture-адресам, а не универсальному правилу всех систем личности. При настоящем входе используйте согласованный нормализованный идентификатор провайдера, учитывая его подтверждение. Отображаемое имя пользователя не подходит для сопоставления: два участника могут называться одинаково, а один человек вправе сменить имя без потери членства.

Следующий урок вводит согласованное изменение версии проекта. Мы уже применили блокировки к одноразовому предложению; теперь увидим, как предотвратить потерю изменения, когда два участника редактируют один обычный документ.

Оглавление серии