Entity / Aggregate: SignInConfirmationRequest + RefreshSession
Дом полей сущностей аутентификационных сессий контекста identity: одноразовые запросы подтверждения
кодом (СМС-flow) и refresh-сессии.
Metadata
| Поле | Значение |
|---|---|
| Контекст | identity |
| Статус | draft |
| Owning team | PIN Platform Core |
Назначение
SignInConfirmationRequest— одноразовый запрос подтверждения действием-кодом. Он рождается вrequestSignInCode/signUpOrSignIn(результат содержитsignInConfirmationRequestIdиrealConfirmationCodeDispatched) и гасится вconfirmSignIn(signInConfirmationRequestId + confirmationCode ≤ 16 симв.). Той же механикой работают подтверждения 2FA (request/confirmEnableTwoFactor,request/confirmDisableTwoFactor), смены email (request/confirmEmailChange) и телефона (request/confirmPhoneChange) — сущность едина, различие — полеPurpose.RefreshSession— долгоживущая сессия входа: пара access JWT + rotating refresh-токен; основа списка устройств и отзыва сессий (user-activity.sessions.*— витрина в контекстеuser-activity, source of truth — здесь).
Классификация
| Поле | Значение |
|---|---|
| Контекст | identity |
| Kind | оба — AggregateRoot (короткоживущий и долгоживущий) |
| Source of truth | identity |
| Таблицы | identity.sign_in_confirmation_requests, identity.refresh_sessions |
| Tenant isolation | не tenanted; изоляция record-level по UserId/PhoneNumber (инвестор вне tenant — ADR-0052) |
1. SignInConfirmationRequest — поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | signInConfirmationRequestId, уходит клиенту в результате request-шага. |
Purpose | ConfirmationPurpose | Да | varchar(32), index ix_sicr_purpose | app | Назначение (см. enum). |
UserId | Guid? | Нет (обязателен для всех Purpose, кроме SignIn нового пользователя) | FK → identity.users(id), index ix_sicr_user_id | app | Субъект. При SignIn до регистрации — null, связка по PhoneNumber. |
PhoneNumber | string | Да | varchar(32), E.164, index ix_sicr_phone_number | app (форма) / user | Куда отправлен код: при PhoneChange — новый номер; при EmailChange код уходит на NewEmail, поле хранит текущий номер субъекта для rate-limit. |
NewEmail | string? | Нет (обязателен при Purpose = EmailChange) | varchar(256), формат email | app (форма смены email) | Целевой email смены. |
NewPhoneNumber | string? | Нет (обязателен при Purpose = PhoneChange) | varchar(32), E.164 | app (форма смены телефона) | Целевой телефон смены. |
CodeHash | string | Да | varchar(128); HMAC-SHA256(код, server-secret); сам код (4–6 цифр, генерация CSPRNG) не хранится | app | Хэш отправленного кода. |
RealCodeDispatched | bool | Да | default false | verifier | realConfirmationCodeDispatched: true — код реально ушёл через СМС-verifier; false — заглушка тест-контура. |
AttemptsCount | int | Да | >= 0, max 5 (INV-S2) | app | Счётчик неверных вводов кода. |
ExpiresAt | DateTimeOffset | Да | index ix_sicr_expires_at; = CreatedAt + 5 минут | app | TTL запроса. |
ConfirmedAt | DateTimeOffset? | Нет | заполняется вместе со Status = Confirmed | app | Момент успешного подтверждения. |
Status | ConfirmationRequestStatus | Да | varchar(32) | app | Pending / Confirmed / Expired / Exhausted / Superseded. |
IpAddress | string? | Нет | varchar(45) (IPv6) | middleware | IP инициатора (анти-фрод, rate-limit). |
UserAgent | string? | Нет | varchar(512) | middleware | UA инициатора. |
CreatedAt / UpdatedAt | DateTimeOffset | Да | index по CreatedAt (retention) | app | Штампы. |
Enum ConfirmationPurpose
| Значение | Request-операция | Confirm-операция | Эффект подтверждения |
|---|---|---|---|
SignIn | requestSignInCode / signUpOrSignIn (sendSmsCode) | confirmSignIn | Выдача JWT (+nextAction: FillName/FillPinCode/FillEmail/FillPassword), создание RefreshSession, lazy-заполнение имени (INV-11 user.md) |
EnableTwoFactor | requestEnableTwoFactor | confirmEnableTwoFactor | User.IsTwoFactorEnabled = true |
DisableTwoFactor | requestDisableTwoFactor | confirmDisableTwoFactor | User.IsTwoFactorEnabled = false |
EmailChange | requestEmailChange | confirmEmailChange (code) | User.Email = NewEmail, EmailConfirmedAt = now |
PhoneChange | requestPhoneChange | confirmPhoneChange | User.PhoneNumber = NewPhoneNumber, PhoneNumberConfirmedAt = now |
Enum ConfirmationRequestStatus
| Значение | Описание |
|---|---|
Pending | Код отправлен, ожидает ввода. |
Confirmed | Код верен, эффект применён; повторное использование невозможно. |
Expired | ExpiresAt истёк без подтверждения. |
Exhausted | 5 неверных попыток — запрос сожжён, нужен новый request-шаг. |
Superseded | Пользователь запросил новый код той же цели — предыдущий Pending гасится (одновременно активен один, INV-S1). |
2. RefreshSession — поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор сессии; кладётся в claim sid access-JWT (связь access ↔ session для отзыва). |
UserId | Guid | Да | FK → identity.users(id), index ix_refresh_sessions_user_id | app | Владелец сессии. |
ActiveCompanyId | Guid? | Нет (обязателен, когда у агента ≥ 1 активное членство и агентство выбрано) | FK → identity.companies(id), index ix_refresh_sessions_active_company_id | app | Durable-выбор активного агентства (ADR-0070): в контексте какого агентства агент сейчас действует. Переживает refresh и повторные входы (хранится на сессии, а не только в access-токене). При каждом refresh из него переиздаётся claim tenant_id (= ActiveCompanyId, контракт ADR-0052 TenantId = CompanyId). null — независимый агент (нет активного членства) либо агент с >1 членством ещё не выбрал агентство (тогда login/refresh отдаёт nextAction = SelectActiveAgency). Зеркалит/зеркалится с UserEntity.CompanyId (INV-S9). |
RefreshTokenHash | string | Да | varchar(128), unique ux_refresh_sessions_token_hash; SHA-256 от 256-битного CSPRNG-токена; сам токен отдаётся клиенту один раз | app | Текущий refresh-токен (rotating: каждый refresh выдаёт новый и запись переиздаётся). |
Status | RefreshSessionStatus | Да | varchar(32), index | app | Active / Rotated / Revoked / Expired. |
SignInMethod | SignInMethod | Да | varchar(32) | app | Чем создана: Sms / Password / PinCode / Refresh (ротация). |
Device | SessionDevice (owned) | Да | см. VO | middleware | Слепок устройства для списка сессий. |
IssuedAt | DateTimeOffset | Да | — | app | Выпуск. |
ExpiresAt | DateTimeOffset | Да | index ix_refresh_sessions_expires_at; = IssuedAt + 30 дней (sliding при ротации) | app | Абсолютный TTL refresh-токена. |
LastUsedAt | DateTimeOffset? | Нет | — | app | Последний успешный refresh. |
ParentSessionId | Guid? | Нет | self-FK → refresh_sessions(id) | app | Предок в цепочке ротаций (детект replay: повторное использование Rotated-токена → отзыв всей цепочки, INV-S6). |
RevokedAt / RevokedReason | DateTimeOffset? / SessionRevokeReason? | Нет | reason: UserLogout / UserRevokedAll / PasswordChanged / AdminBlock / ReplayDetected (varchar(32)) | app | Отзыв. |
CreatedAt / UpdatedAt | DateTimeOffset | Да | — | app | Штампы. |
Value objects
SessionDevice (owned в RefreshSession)
| Поле | Тип | Обязательность | Ограничения | Колонка |
|---|---|---|---|---|
UserAgent | string? | Нет | varchar(512) | device_user_agent |
IpAddress | string? | Нет | varchar(45) | device_ip_address |
Platform | string? | Нет | varchar(32): web / ios / android | device_platform |
DisplayName | string? | Нет | varchar(128), вычисляется из UA («Chrome, Windows») | device_display_name |
JSONB
JSONB-модели не используются.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
SignInConfirmationRequest → UserEntity | reference | UserId | confirm-шаг: по UserId либо по PhoneNumber (SignIn) | cascade (retention-job) |
RefreshSession → UserEntity | reference | UserId | refresh: по RefreshTokenHash → пользователь | cascade при архиве пользователя (отзыв) |
RefreshSession → CompanyEntity (активное агентство) | reference | ActiveCompanyId (nullable) | смена/ре-валидация active-agency: проверка активного членства (INV-S9) | restrict |
RefreshSession → RefreshSession | self-reference | ParentSessionId | цепочка при replay-детекте | restrict |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-S1 SingleActivePending | На (PhoneNumber, Purpose) — один Pending: новый request гасит предыдущий (Superseded) | request-операция (в одном согласованном изменении) | — |
| INV-S2 AttemptsLimit | После 5 неверных кодов — Exhausted | confirm-операция (инкремент до сравнения хэшей) | IDENTITY_CONFIRMATION_EXHAUSTED |
| INV-S3 NotExpired | Confirm только при Status = Pending и ExpiresAt > now | confirm-операция | IDENTITY_CONFIRMATION_EXPIRED |
| INV-S4 RateLimit | ≤ 1 отправки кода на номер в 60 сек и ≤ 5 в час (по PhoneNumber + IpAddress) | request-операция + счётчик rate-limit | IDENTITY_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 PurposeShape | NewEmail/NewPhoneNumber обязательны/запрещены согласно Purpose | фабрика SignInConfirmationRequest.Create | ValidationError |
| 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 без nextAction | login/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не про агентство, claimtenant_idне выдаётся. - 1 активное членство — активное агентство выбирается автоматически (единственный кандидат):
ActiveCompanyId= это агентство,nextActionпо агентству не выставляется, refresh переиздаётtenant_id. - >1 активного членства и
ActiveCompanyId == null(или ранее выбранное перестало быть активным — увольнение из него) — login/refresh возвращаетnextAction = SelectActiveAgency; до вызова смены активного агентства claimtenant_idне выдаётся, CRM-операции агентства недоступны (INV-S10).
- 0 активных членств — независимый агент (scope
- Смена активного агентства. 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/поля |
|---|---|---|---|
ConfirmationCodeRequestedEvent | IDomainEvent | Создание Pending-запроса | RequestId, Purpose, PhoneNumber (маскированный в логах); handler отправляет СМС через verifier-провайдера |
SignInConfirmedEvent | IDomainEvent | Purpose = SignIn подтверждён | UserId, SessionId, SignInMethod |
SessionRevokedEvent | IDomainEvent | Отзыв Active-сессии | SessionId, UserId, RevokedReason |
UserSignedInKafkaEvent | IKafkaEvent (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' |
| Партиционирование / retention | sicr: 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 — лента для расследований заботы |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: UserEntity
- API (24): DELETE /api/admin/v1/users/{userId} — Удаление пользователя: архив vs полное удаление (admin), Identity Admin API — реестр методов и общие правила области, Identity API — карта областей и реестр investor-области, POST /api/admin/v1/users/{userId}/block — Блокировка пользователя (admin), POST /api/admin/v1/users/{userId}/security/reset — Ручной сброс факторов входа (admin), POST /api/admin/v1/users/{userId}/sessions/revoke — Отзыв сессий пользователя (admin), POST /api/investors/auth/confirm-sign-in — Подтверждение входа (confirmSignIn), POST /api/investors/auth/request-sign-in-code — Первый шаг входа (requestSignInCode), POST /api/investors/auth/sign-up — Регистрация инвестора (signUp), POST /api/investors/auth/sign-up-or-sign-in — Войти или зарегистрироваться (signUpOrSignIn), POST /api/investors/auth/verify-sign-in-password — Проверка пароля при входе (verifySignInPassword), POST /api/investors/me/2fa/disable-confirm — Подтверждение выключения 2FA (confirmDisableTwoFactor) (+12)
- Use Cases (5): UC-CRM-014. Агент выбирает активное агентство перед созданием лида/сделки, UC-IDN-001. Вход/регистрация по СМС (с заполнением имени и фиксацией реферальной ссылки), UC-IDN-002. Вход по паролю вместо СМС, UC-IDN-003. Включение двухфакторной аутентификации, UC-IDN-004. Смена телефона / email
- События (2): Event Contract: pin.identity.user-blocked (UserBlockedKafkaEvent), Event Contract: pin.identity.user-signed-in (UserSignedInKafkaEvent)
- Консюмеры (2): Consumer: Identity Sign-Ins (UserActivitySignedInConsumer), Consumer: Talent Identity Sync (pin-identity-talent-sync)
- Фоновые задачи (3): Background Job: ExpireRoleAssignmentsJob, Background Job: ExpireSessionsJob, Background Job: SessionRetentionJob
- index.md (1): Identity Service (Pin.Identity)
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- 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, endpointPOST /api/v1/me/active-agency), ADR-0052 (TenantId = CompanyId, инвестор вне tenant), ADR-0072 (увольнение → сбросActiveCompanyId).