Перейти к основному содержимому

Entity / Aggregate: SignInConfirmationRequest + RefreshSession

Дом полей сущностей аутентификационных сессий контекста identity: одноразовые запросы подтверждения кодом (СМС-flow) и refresh-сессии.

Metadata

ПолеЗначение
Контекстidentity
Статусdraft
Owning teamPIN Platform Core

Назначение

  1. SignInConfirmationRequest — одноразовый запрос подтверждения действием-кодом. Он рождается в requestSignInCode/signUpOrSignIn (результат содержит signInConfirmationRequestId и realConfirmationCodeDispatched) и гасится в confirmSignIn (signInConfirmationRequestId + confirmationCode ≤ 16 симв.). Той же механикой работают подтверждения 2FA (request/confirmEnableTwoFactor, request/confirmDisableTwoFactor), смены email (request/confirmEmailChange) и телефона (request/confirmPhoneChange) — сущность едина, различие — поле Purpose.
  2. RefreshSession — долгоживущая сессия входа: пара access JWT + rotating refresh-токен; основа списка устройств и отзыва сессий (user-activity.sessions.* — витрина в контексте user-activity, source of truth — здесь).

Классификация

ПолеЗначение
Контекстidentity
Kindоба — AggregateRoot (короткоживущий и долгоживущий)
Source of truthidentity
Таблицыidentity.sign_in_confirmation_requests, identity.refresh_sessions
Tenant isolationне tenanted; изоляция record-level по UserId/PhoneNumber (инвестор вне tenant — ADR-0052)

1. SignInConfirmationRequest — поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappsignInConfirmationRequestId, уходит клиенту в результате request-шага.
PurposeConfirmationPurposeДаvarchar(32), index ix_sicr_purposeappНазначение (см. enum).
UserIdGuid?Нет (обязателен для всех Purpose, кроме SignIn нового пользователя)FK → identity.users(id), index ix_sicr_user_idappСубъект. При SignIn до регистрации — null, связка по PhoneNumber.
PhoneNumberstringДаvarchar(32), E.164, index ix_sicr_phone_numberapp (форма) / userКуда отправлен код: при PhoneChangeновый номер; при EmailChange код уходит на NewEmail, поле хранит текущий номер субъекта для rate-limit.
NewEmailstring?Нет (обязателен при Purpose = EmailChange)varchar(256), формат emailapp (форма смены email)Целевой email смены.
NewPhoneNumberstring?Нет (обязателен при Purpose = PhoneChange)varchar(32), E.164app (форма смены телефона)Целевой телефон смены.
CodeHashstringДаvarchar(128); HMAC-SHA256(код, server-secret); сам код (4–6 цифр, генерация CSPRNG) не хранитсяappХэш отправленного кода.
RealCodeDispatchedboolДаdefault falseverifierrealConfirmationCodeDispatched: true — код реально ушёл через СМС-verifier; false — заглушка тест-контура.
AttemptsCountintДа>= 0, max 5 (INV-S2)appСчётчик неверных вводов кода.
ExpiresAtDateTimeOffsetДаindex ix_sicr_expires_at; = CreatedAt + 5 минутappTTL запроса.
ConfirmedAtDateTimeOffset?Нетзаполняется вместе со Status = ConfirmedappМомент успешного подтверждения.
StatusConfirmationRequestStatusДаvarchar(32)appPending / Confirmed / Expired / Exhausted / Superseded.
IpAddressstring?Нетvarchar(45) (IPv6)middlewareIP инициатора (анти-фрод, rate-limit).
UserAgentstring?Нетvarchar(512)middlewareUA инициатора.
CreatedAt / UpdatedAtDateTimeOffsetДаindex по CreatedAt (retention)appШтампы.

Enum ConfirmationPurpose

ЗначениеRequest-операцияConfirm-операцияЭффект подтверждения
SignInrequestSignInCode / signUpOrSignIn (sendSmsCode)confirmSignInВыдача JWT (+nextAction: FillName/FillPinCode/FillEmail/FillPassword), создание RefreshSession, lazy-заполнение имени (INV-11 user.md)
EnableTwoFactorrequestEnableTwoFactorconfirmEnableTwoFactorUser.IsTwoFactorEnabled = true
DisableTwoFactorrequestDisableTwoFactorconfirmDisableTwoFactorUser.IsTwoFactorEnabled = false
EmailChangerequestEmailChangeconfirmEmailChange (code)User.Email = NewEmail, EmailConfirmedAt = now
PhoneChangerequestPhoneChangeconfirmPhoneChangeUser.PhoneNumber = NewPhoneNumber, PhoneNumberConfirmedAt = now

Enum ConfirmationRequestStatus

ЗначениеОписание
PendingКод отправлен, ожидает ввода.
ConfirmedКод верен, эффект применён; повторное использование невозможно.
ExpiredExpiresAt истёк без подтверждения.
Exhausted5 неверных попыток — запрос сожжён, нужен новый request-шаг.
SupersededПользователь запросил новый код той же цели — предыдущий Pending гасится (одновременно активен один, INV-S1).

2. RefreshSession — поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор сессии; кладётся в claim sid access-JWT (связь access ↔ session для отзыва).
UserIdGuidДаFK → identity.users(id), index ix_refresh_sessions_user_idappВладелец сессии.
ActiveCompanyIdGuid?Нет (обязателен, когда у агента ≥ 1 активное членство и агентство выбрано)FK → identity.companies(id), index ix_refresh_sessions_active_company_idappDurable-выбор активного агентства (ADR-0070): в контексте какого агентства агент сейчас действует. Переживает refresh и повторные входы (хранится на сессии, а не только в access-токене). При каждом refresh из него переиздаётся claim tenant_id (= ActiveCompanyId, контракт ADR-0052 TenantId = CompanyId). null — независимый агент (нет активного членства) либо агент с >1 членством ещё не выбрал агентство (тогда login/refresh отдаёт nextAction = SelectActiveAgency). Зеркалит/зеркалится с UserEntity.CompanyId (INV-S9).
RefreshTokenHashstringДаvarchar(128), unique ux_refresh_sessions_token_hash; SHA-256 от 256-битного CSPRNG-токена; сам токен отдаётся клиенту один разappТекущий refresh-токен (rotating: каждый refresh выдаёт новый и запись переиздаётся).
StatusRefreshSessionStatusДаvarchar(32), indexappActive / Rotated / Revoked / Expired.
SignInMethodSignInMethodДаvarchar(32)appЧем создана: Sms / Password / PinCode / Refresh (ротация).
DeviceSessionDevice (owned)Дасм. VOmiddlewareСлепок устройства для списка сессий.
IssuedAtDateTimeOffsetДаappВыпуск.
ExpiresAtDateTimeOffsetДаindex ix_refresh_sessions_expires_at; = IssuedAt + 30 дней (sliding при ротации)appАбсолютный TTL refresh-токена.
LastUsedAtDateTimeOffset?НетappПоследний успешный refresh.
ParentSessionIdGuid?Нетself-FK → refresh_sessions(id)appПредок в цепочке ротаций (детект replay: повторное использование Rotated-токена → отзыв всей цепочки, INV-S6).
RevokedAt / RevokedReasonDateTimeOffset? / SessionRevokeReason?Нетreason: UserLogout / UserRevokedAll / PasswordChanged / AdminBlock / ReplayDetected (varchar(32))appОтзыв.
CreatedAt / UpdatedAtDateTimeOffsetДаappШтампы.

Value objects

SessionDevice (owned в RefreshSession)

ПолеТипОбязательностьОграниченияКолонка
UserAgentstring?Нетvarchar(512)device_user_agent
IpAddressstring?Нетvarchar(45)device_ip_address
Platformstring?Нетvarchar(32): web / ios / androiddevice_platform
DisplayNamestring?Нетvarchar(128), вычисляется из UA («Chrome, Windows»)device_display_name
JSONB

JSONB-модели не используются.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
SignInConfirmationRequest → UserEntityreferenceUserIdconfirm-шаг: по UserId либо по PhoneNumber (SignIn)cascade (retention-job)
RefreshSession → UserEntityreferenceUserIdrefresh: по RefreshTokenHash → пользовательcascade при архиве пользователя (отзыв)
RefreshSession → CompanyEntity (активное агентство)referenceActiveCompanyId (nullable)смена/ре-валидация active-agency: проверка активного членства (INV-S9)restrict
RefreshSession → RefreshSessionself-referenceParentSessionIdцепочка при replay-детектеrestrict

Инварианты и переходы состояний

ИнвариантУсловиеГде проверяетсяОшибка
INV-S1 SingleActivePendingНа (PhoneNumber, Purpose) — один Pending: новый request гасит предыдущий (Superseded)request-операция (в одном согласованном изменении)
INV-S2 AttemptsLimitПосле 5 неверных кодов — Exhaustedconfirm-операция (инкремент до сравнения хэшей)IDENTITY_CONFIRMATION_EXHAUSTED
INV-S3 NotExpiredConfirm только при Status = Pending и ExpiresAt > nowconfirm-операцияIDENTITY_CONFIRMATION_EXPIRED
INV-S4 RateLimit≤ 1 отправки кода на номер в 60 сек и ≤ 5 в час (по PhoneNumber + IpAddress)request-операция + счётчик rate-limitIDENTITY_CONFIRMATION_RATE_LIMITED (429)
INV-S5 CodeCompareСравнение кодов — constant-time по CodeHashдоменный метод Confirm(code)IDENTITY_CONFIRMATION_INVALID
INV-S6 ReplayDetectionИспользование refresh-токена в статусе Rotated → отзыв всей цепочки сессий (ReplayDetected)refresh-операцияIDENTITY_SESSION_REPLAY_DETECTED (401)
INV-S7 RevokeOnSecurityChangeСмена пароля/телефона, блокировка — отзыв всех Active сессий пользователя, кроме текущей (смена пароля) или всех (блокировка)операция смены пароля/телефона/блокировки
INV-S8 PurposeShapeNewEmail/NewPhoneNumber обязательны/запрещены согласно Purposeфабрика SignInConfirmationRequest.CreateValidationError
INV-S9 ActiveCompanyIsMemberЕсли ActiveCompanyId != null, то у UserId есть активное членство (AgentMembershipEntity.Status = Active) в этом агентстве; смена активного агентства зеркалится в UserEntity.CompanyIdсмена активного агентства + каждый refresh (ре-валидация против активных членств)IDENTITY_MEMBERSHIP_NOT_ACTIVE (403)
INV-S10 ActiveAgencySelectionПри refresh/login: >1 активного членства и ActiveCompanyId == null (или выбранное перестало быть активным) → nextAction = SelectActiveAgency, claim tenant_id не выдаётся до выбора; ровно 1 активное членство → авто-выбор (ActiveCompanyId = единственное агентство); 0 членств → независимый агент, ActiveCompanyId = null без nextActionlogin/refresh-операция

Конкурентность и идемпотентность

  • Повторный confirm одного запроса. Первый успешный confirm переводит запрос Pending → Confirmed и создаёт одну RefreshSession; повторное подтверждение того же signInConfirmationRequestId видит статус ≠ Pending и завершается отказом IDENTITY_CONFIRMATION_EXPIRED/IDENTITY_CONFIRMATION_INVALID — вторая сессия не выдаётся (идемпотентность входа: один запрос ⇒ не более одной сессии).
  • Параллельный refresh одним токеном. При одновременном предъявлении текущего refresh-токена выигрывает первая зафиксированная ротация (Active → Rotated, выдан новый токен и дочерняя Active); второй запрос попадает на запись в статусе Rotated и трактуется как replay (INV-S6) → IDENTITY_SESSION_REPLAY_DETECTED и отзыв всей цепочки сессий. Клиент не должен слать параллельные refresh одним токеном; ожидаемый паттерн — единичный refresh с последующим переизданием пары токенов.
  • Оптимистическая блокировка записи. Двойная запись в одну SignInConfirmationRequest/ RefreshSession (confirm ↔ retention-job, refresh ↔ revoke) разрешается по версии: проигравший получает ошибку статуса и не применяет эффект.
  • Согласованность с витриной сессий. Source of truth списка устройств — RefreshSession здесь; витрина user-activity.sessions.* — реплика (eventual, секунды). Отзыв (self-service/admin) виден в списке устройств после синхронизации проекции; краткий промежуток пользователь может видеть уже отозванную сессию как активную — при этом фактический доступ уже закрыт (проверка по source of truth).

Активное агентство и claim tenant_id (ADR-0070)

Для агента с членством в нескольких агентствах (мульти-членство, ADR-0070) сессия хранит устойчивый (durable) выбор активного агентства ActiveCompanyId. Именно из него на каждом refresh переиздаётся claim tenant_id access-JWT (контракт ADR-0052 TenantId = CompanyId); при одном членстве tenant очевиден, при нескольких — определяется явным выбором, а не выводится неявно.

  • Выбор при входе (nextAction = SelectActiveAgency). По завершении входа/refresh:
    • 0 активных членств — независимый агент (scope Platform, ADR-0061): ActiveCompanyId = null, CRM агентства недоступен, nextAction не про агентство, claim tenant_id не выдаётся.
    • 1 активное членство — активное агентство выбирается автоматически (единственный кандидат): ActiveCompanyId = это агентство, nextAction по агентству не выставляется, refresh переиздаёт tenant_id.
    • >1 активного членства и ActiveCompanyId == null (или ранее выбранное перестало быть активным — увольнение из него) — login/refresh возвращает nextAction = SelectActiveAgency; до вызова смены активного агентства claim tenant_id не выдаётся, CRM-операции агентства недоступны (INV-S10).
  • Смена активного агентства. Endpoint POST /api/v1/me/active-agency (тело несёт целевой CompanyId) валидирует, что у агента есть активное членство в этом агентстве (INV-S9, иначе IDENTITY_MEMBERSHIP_NOT_ACTIVE, 403), обновляет RefreshSession.ActiveCompanyId и зеркало UserEntity.CompanyId. Токен переиздаётся с новым tenant_id: текущая пара инвалидируется ротацией (Active → Rotated, выдаётся дочерняя Active с новым claim), прежние access-токены со старым tenant_id доживают до истечения короткого TTL. Смена активного агентства не трогает уже созданные ресурсы — их снапшоты AgencyCompanyId/OriginAgencyCompanyId иммутабельны (ADR-0061 п.4, ADR-0070 п.4).
  • Ре-валидация на refresh. На каждом refresh ActiveCompanyId сверяется с актуальным составом активных членств агента: если выбранное агентство перестало быть активным (увольнение, ADR-0072), ActiveCompanyId сбрасывается и применяется правило выбора выше (авто-выбор при одном оставшемся членстве либо nextAction = SelectActiveAgency).

Доменные и интеграционные события

СобытиеТипКогдаSnapshot/поля
ConfirmationCodeRequestedEventIDomainEventСоздание Pending-запросаRequestId, Purpose, PhoneNumber (маскированный в логах); handler отправляет СМС через verifier-провайдера
SignInConfirmedEventIDomainEventPurpose = SignIn подтверждёнUserId, SessionId, SignInMethod
SessionRevokedEventIDomainEventОтзыв Active-сессииSessionId, UserId, RevokedReason
UserSignedInKafkaEventIKafkaEvent (ContractVersion = 1, topic pin.identity.sign-ins.v1, key = UserId)Успешный входUserId, SessionId, SignInMethod, Device.Platform, OccurredAt; consumers: user-activity (связывание anonymous_id и витрина сессий), notifications (алерт о новом устройстве)
Ограничение

Интеграционного события на каждый confirmation-request нет — только вход.

Индексы, хранение, безопасность

АспектЗначение
Индексыsicr: PK; ix_sicr_phone_number btree; ix_sicr_user_id; ix_sicr_purpose; ix_sicr_expires_at (retention/expire-job); ix_sicr_created_at. refresh_sessions: PK; ux_refresh_sessions_token_hash unique; ix_refresh_sessions_user_id; ix_refresh_sessions_active_company_id (active-agency); ix_refresh_sessions_expires_at where status = 'Active'
Партиционирование / retentionsicr: retention-job удаляет записи старше 30 дней (батчами); refresh_sessions: терминальные записи старше 90 дней удаляются
Concurrencyоптимистическая блокировка: двойной confirm/refresh одной записи → второй проигрывает и получает ошибку статуса
Permissions / PIIСвои сессии — identity.sessions.view-self/revoke-self (витрина user-activity.sessions.*); коды и refresh-токены хранятся ТОЛЬКО хэшами; PhoneNumber/IpAddress/UserAgent — PII: маскирование в логах, недоступны другим пользователям
AuditВсе переходы — через статусы (записи не удаляются до retention); SessionRevokedEvent — лента для расследований заботы

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы

  • user.md — флаги hasPassword/hasPinCode/IsTwoFactorEnabled, INV-7/8 (2FA, lockout).
  • Операции flow — identity/api/ (requestSignInCode, signUpOrSignIn, confirmSignIn, verifySignInPassword, request/confirm 2FA/email/phone).
  • Витрина сессий пользователя — docs/06-services/user-activity/ (ADR-0054).
  • agent-membership.md — мульти-членство агента, SingleActivePerCompany, источник истины для валидации ActiveCompanyId (INV-S9).
  • ADR: ADR-0070 (durable ActiveCompanyId, nextAction = SelectActiveAgency, endpoint POST /api/v1/me/active-agency), ADR-0052 (TenantId = CompanyId, инвестор вне tenant), ADR-0072 (увольнение → сброс ActiveCompanyId).