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

POST /api/investors/auth/confirm-sign-in — Подтверждение входа (confirmSignIn)

Назначение

Завершающий шаг входа: подтверждение одним из способов — пин-код, пароль или пара signInConfirmationRequestId + СМС-код (в т.ч. первый вход после регистрации). Вызывается анонимным клиентом; на выходе — access-токен и подсказка следующего действия. Дозаполняет имя (lazy-регистрация, INV-11) и принимает referrerUrl для реферальной привязки.

Metadata

ПолеЗначение
Сервис/контекстidentity
API areapublic
Feature groupauth
Статусapproved
Документdocs/06-services/identity/api/investor/confirm-sign-in.md

Актор и доступ

ПроверкаЗначение
Actor typeAnonymous (токен выдаётся в результате)
Auth policy / Permissionsnone
Scope (RBAC)не применяется
Record-levelсвязка по PhoneNumber + владение SignInConfirmationRequest
Tenant isolationне применяется (инвестор вне tenant, ADR-0052)
Auditsecurity-sensitive: успешный/неуспешный вход логируется (маскированный номер, способ)

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/investors/auth/confirm-sign-in
Success status200
Idempotency headerне применяется; повторный confirm того же запроса блокируется статусом Confirmed + оптимистической блокировкой
Correlationсквозной trace через traceparent

Входные данные / параметры запроса (body ConfirmSignInForm)

Ровно один способ подтверждения: pinCode | password | (signInConfirmationRequestId + confirmationCode).

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
phoneNumberbodystringДаmin 1, max 32; E.164PII, маскирование
pinCodebodystring?Нет (способ 1)max 16никогда не логируется
passwordbodystring?Нет (способ 2)max 128никогда не логируется
signInConfirmationRequestIdbodystring (Guid)?Нет (способ 3, вместе с confirmationCode)Guid из request-шага
confirmationCodebodystring?Нет (способ 3)max 16никогда не логируется
firstNamebodystring?Нетmax 128; применяется только если имя пустое (INV-11)PII
referrerUrlbodystring?Нетmax 64; slug реферальной ссылки
IpAddress / UserAgentmiddlewarestring?Нет≤ 45 / ≤ 512анти-фрод, device-слепок

Модель ответа ConfirmSignInCommandResult

ПолеТипОбязательностьИсточникMaskingОписание
accessTokenJwtTokenModelДаissue: token (JWT, sub = user.Id), notBefore = now, type = JwtToken, expirationDateclaims без PIIAccess-токен (README.md).
nextActionAuthNextAction?Нетcomputed FillName → FillPinCode → FillEmail → FillPasswordПодсказка UI, не блокирует.

Алгоритм

  1. Контекст и доступ. Актор анонимный; из middleware известны IpAddress/UserAgent.
  2. Проверка входа. phoneNumber непустой ≤ 32; длины pinCode/password/confirmationCode/ firstName/referrerUrl в пределах; указан ровно один способ (pinCode | password | signInConfirmationRequestId + confirmationCode) — иначе ValidationError.
  3. Проверки сущностей. Найти пользователя по нормализованному номеру — не найден ⇒ IDENTITY_SIGN_IN_FAILED (без user enumeration). Состояние: Status ∈ {Active, Invited} — иначе IDENTITY_USER_BLOCKED; блокировка входа снята (LockoutUntil <= now, INV-8) — иначе IDENTITY_USER_LOCKED_OUT; для инвестора CompanyId = null — INV-9 не применяется. Для способа 3: найти запрос по signInConfirmationRequestId — не найден ⇒ IDENTITY_CONFIRMATION_INVALID; проверить цель SignIn, совпадение номера, статус Pending, срок (INV-S3 → IDENTITY_CONFIRMATION_EXPIRED).
  4. Правила и переходы.
    • способ pinCode: если пин-код не задан ⇒ IDENTITY_SIGN_IN_FAILED; при включённой 2FA пин-код сам по себе недостаточен ⇒ IDENTITY_TWO_FACTOR_REQUIRED (клиент идёт через СМС-код); сверка (Argon2id): неуспех ⇒ счётчик неудачных попыток увеличивается (10 ⇒ блокировка входа на 15 мин) и IDENTITY_SIGN_IN_FAILED;
    • способ password: аналогично по паролю (симметрично verify-sign-in-password.md, где 2FA-ветка создаёт СМС-запрос);
    • способ СМС: счётчик попыток увеличивается до сравнения (INV-S2); 5-я неверная ⇒ запрос Exhausted + IDENTITY_CONFIRMATION_EXHAUSTED; сравнение HMAC-SHA256(confirmationCode) с хэшем в постоянном времени (INV-S5) — неверно ⇒ IDENTITY_CONFIRMATION_INVALID; верно ⇒ запрос Confirmed, ConfirmedAt = now; если телефон ещё не подтверждён — установить PhoneNumberConfirmedAt;
    • успех любого способа: счётчик неудачных попыток обнуляется; Invited → Active (первый вход); LastSignInAt = now; имя дозаполняется, если было пустым (INV-11); вычисляется nextAction (первая применимая подсказка); фиксируется доменное событие UserSignedInEvent (способ, сессия); выпускается access-JWT (sub = user.Id, срок по конфигу).
  5. Что меняется. В одном согласованном изменении обновляются пользователь (счётчики, LastSignInAt, имя, статус) и, для способа СМС, запрос подтверждения; конкуренция двойного confirm разрешается оптимистической блокировкой — проигравший получает IDENTITY_CONFIRMATION_INVALID; неуспешные попытки тоже сохраняются (счётчики).
  6. События. UserSignedInKafkaEventpin.identity.sign-ins.v1 (ключ = UserId; UserId, SessionId, SignInMethod (Sms|Password|PinCode), Device.Platform, OccurredAt); потребители — user-activity (связывание anonymous_id), notifications (алерт о новом устройстве). Если вход дозаполнил имя (INV-11) — дополнительно UserUpsertedKafkaEventpin.identity.users.v1ReferrerUrl), по нему referral довязывает реферала, пришедшего по referrerUrl на этом шаге.
  7. Результат. accessToken (маппинг в таблице ответа), nextAction; хэши/коды в ответ и логи не попадают; HTTP 200.

Диаграмма

Побочные эффекты

ТипДетали
Интеграционные событияUserSignedInKafkaEvent / pin.identity.sign-ins.v1 / ключ = UserId; при INV-11 — UserUpsertedKafkaEvent / pin.identity.users.v1 (payload ReferrerUrl → referral)
Внешние вызовыnone (СМС уже отправлена request-шагом)
Проекции / поисксброс rate-limit счётчиков неуспешных попыток по номеру
Уведомленияопосредованно: потребитель notifications — алерт о входе с нового устройства
Окно согласованности

Access-токен и nextAction возвращаются синхронно — пользователь входит сразу. Следствия событий асинхронны: user-activity связывает предыдущий anonymous_id с UserId по UserSignedInKafkaEvent; notifications шлёт алерт о новом устройстве; при первом входе с дозаполнением имени (INV-11) публикуется UserUpsertedKafkaEvent с ReferrerUrl, по которому referral довязывает реферала — привязка появляется через секунды после успешного входа, а не в ответе. До обработки событий история действий и реферальная связь ещё не отражены, что не влияет на выданный доступ.

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
ValidationError400Нарушение форматов / не ровно один способСписок ошибок валидацииno retry
IDENTITY_SIGN_IN_FAILED400Номер не найден / неверный пароль / неверный пин-код (единый код против user enumeration)«Неверные данные входа»no retry
IDENTITY_CONFIRMATION_INVALID400Запрос не найден / чужой / неверный СМС-код / проигрыш гонки«Неверный код подтверждения»ввести код заново
IDENTITY_CONFIRMATION_EXPIRED400ExpiresAt <= now (INV-S3)«Код истёк, запросите новый»новый request-шаг
IDENTITY_CONFIRMATION_EXHAUSTED4005-я неверная попытка (INV-S2)«Превышено число попыток»новый request-шаг
IDENTITY_TWO_FACTOR_REQUIRED400Пин-код/пароль при включённой 2FA«Требуется подтверждение СМС-кодом»перейти к СМС-flow
IDENTITY_USER_BLOCKED400Status ∈ {Blocked, Archived}«Учётная запись заблокирована»no retry
IDENTITY_USER_LOCKED_OUT400LockoutUntil > now (INV-8)«Вход временно заблокирован»retry после LockoutUntil

Acceptance Criteria

  • Given активный пользователь с корректным пин-кодом и выключенной 2FA, When confirm-sign-in способом pinCode, Then выдаётся access-JWT (sub = user.Id), LastSignInAt = now, счётчик неудачных попыток обнуляется, уходит UserSignedInKafkaEvent, ответ 200 {accessToken, nextAction?}.
  • Given активный пользователь с верным паролем, When способ password, Then аналогично выдаётся токен; Given при этом 2FA включена, When способ pinCode/password, Then IDENTITY_TWO_FACTOR_REQUIRED (400) — клиент переходит на СМС-flow.
  • Given непросроченный Pending-запрос SignIn на номер и верный confirmationCode, When способ СМС, Then запрос → Confirmed, при неподтверждённом номере проставляется PhoneNumberConfirmedAt, Invited → Active (первый вход), выдаётся токен.
  • Given первый вход с firstName при пустом имени (INV-11) и referrerUrl, When confirm-sign-in успешен, Then имя дозаполняется, публикуется UserUpsertedKafkaEvent (ReferrerUrl), и referral довязывает реферала асинхронно.
  • Given неизвестный номер или неверный пароль/пин-код, When confirm-sign-in, Then единый IDENTITY_SIGN_IN_FAILED (400, без user enumeration); на 10-й подряд неудаче по паролю/пин-коду — блокировка входа на 15 минут.
  • Given способ СМС с неверным confirmationCode, When confirm, Then счётчик попыток растёт; на 5-й неверной — запрос → Exhausted и IDENTITY_CONFIRMATION_EXHAUSTED; до этого — IDENTITY_CONFIRMATION_INVALID; при истёкшем запросе — IDENTITY_CONFIRMATION_EXPIRED (400).
  • Given Status ∈ {Blocked, Archived} или LockoutUntil > now, When confirm-sign-in, Then IDENTITY_USER_BLOCKED / IDENTITY_USER_LOCKED_OUT (400) без выдачи токена.
  • Given передано не ровно один способ подтверждения (ноль или несколько), When confirm-sign-in, Then ValidationError (400).
  • Given два параллельных confirm одного СМС-запроса, When оба доходят до записи, Then один выдаёт токен, второй проигрывает по оптимистической блокировке (IDENTITY_CONFIRMATION_INVALID).

Совместимость и наблюдаемость

  • Неизвестное значение accessToken.type клиент трактует как JwtToken. Добавление refresh-токена — additive optional поле.
  • Logs: способ входа, маскированный номер, результат; metrics: identity_sign_in_total{method,result}, identity_lockouts_total; traces — OTel.

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

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

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