POST /api/investors/auth/confirm-sign-in — Подтверждение входа (confirmSignIn)
Назначение
Завершающий шаг входа: подтверждение одним из способов — пин-код, пароль или пара
signInConfirmationRequestId + СМС-код (в т.ч. первый вход после регистрации). Вызывается анонимным
клиентом; на выходе — access-токен и подсказка следующего действия. Дозаполняет имя (lazy-регистрация,
INV-11) и принимает referrerUrl для реферальной привязки.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | identity |
| API area | public |
| Feature group | auth |
| Статус | approved |
| Документ | docs/06-services/identity/api/investor/confirm-sign-in.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | Anonymous (токен выдаётся в результате) |
| Auth policy / Permissions | none |
| Scope (RBAC) | не применяется |
| Record-level | связка по PhoneNumber + владение SignInConfirmationRequest |
| Tenant isolation | не применяется (инвестор вне tenant, ADR-0052) |
| Audit | security-sensitive: успешный/неуспешный вход логируется (маскированный номер, способ) |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/investors/auth/confirm-sign-in |
| Success status | 200 |
| Idempotency header | не применяется; повторный confirm того же запроса блокируется статусом Confirmed + оптимистической блокировкой |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса (body ConfirmSignInForm)
Ровно один способ подтверждения: pinCode | password | (signInConfirmationRequestId +
confirmationCode).
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
phoneNumber | body | string | Да | min 1, max 32; E.164 | PII, маскирование |
pinCode | body | string? | Нет (способ 1) | max 16 | никогда не логируется |
password | body | string? | Нет (способ 2) | max 128 | никогда не логируется |
signInConfirmationRequestId | body | string (Guid)? | Нет (способ 3, вместе с confirmationCode) | Guid из request-шага | — |
confirmationCode | body | string? | Нет (способ 3) | max 16 | никогда не логируется |
firstName | body | string? | Нет | max 128; применяется только если имя пустое (INV-11) | PII |
referrerUrl | body | string? | Нет | max 64; slug реферальной ссылки | — |
IpAddress / UserAgent | middleware | string? | Нет | ≤ 45 / ≤ 512 | анти-фрод, device-слепок |
Модель ответа ConfirmSignInCommandResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
accessToken | JwtTokenModel | Да | issue: token (JWT, sub = user.Id), notBefore = now, type = JwtToken, expirationDate | claims без PII | Access-токен (README.md). |
nextAction | AuthNextAction? | Нет | computed FillName → FillPinCode → FillEmail → FillPassword | — | Подсказка UI, не блокирует. |
Алгоритм
- Контекст и доступ. Актор анонимный; из middleware известны
IpAddress/UserAgent. - Проверка входа.
phoneNumberнепустой ≤ 32; длиныpinCode/password/confirmationCode/firstName/referrerUrlв пределах; указан ровно один способ (pinCode|password|signInConfirmationRequestId + confirmationCode) — иначеValidationError. - Проверки сущностей. Найти пользователя по нормализованному номеру — не найден ⇒
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). - Правила и переходы.
- способ
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, срок по конфигу).
- способ
- Что меняется. В одном согласованном изменении обновляются пользователь (счётчики,
LastSignInAt, имя, статус) и, для способа СМС, запрос подтверждения; конкуренция двойного confirm разрешается оптимистической блокировкой — проигравший получаетIDENTITY_CONFIRMATION_INVALID; неуспешные попытки тоже сохраняются (счётчики). - События.
UserSignedInKafkaEvent→pin.identity.sign-ins.v1(ключ =UserId;UserId,SessionId,SignInMethod (Sms|Password|PinCode),Device.Platform,OccurredAt); потребители —user-activity(связывание anonymous_id),notifications(алерт о новом устройстве). Если вход дозаполнил имя (INV-11) — дополнительноUserUpsertedKafkaEvent→pin.identity.users.v1(сReferrerUrl), по немуreferralдовязывает реферала, пришедшего поreferrerUrlна этом шаге. - Результат.
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 |
|---|---|---|---|---|
ValidationError | 400 | Нарушение форматов / не ровно один способ | Список ошибок валидации | no retry |
IDENTITY_SIGN_IN_FAILED | 400 | Номер не найден / неверный пароль / неверный пин-код (единый код против user enumeration) | «Неверные данные входа» | no retry |
IDENTITY_CONFIRMATION_INVALID | 400 | Запрос не найден / чужой / неверный СМС-код / проигрыш гонки | «Неверный код подтверждения» | ввести код заново |
IDENTITY_CONFIRMATION_EXPIRED | 400 | ExpiresAt <= now (INV-S3) | «Код истёк, запросите новый» | новый request-шаг |
IDENTITY_CONFIRMATION_EXHAUSTED | 400 | 5-я неверная попытка (INV-S2) | «Превышено число попыток» | новый request-шаг |
IDENTITY_TWO_FACTOR_REQUIRED | 400 | Пин-код/пароль при включённой 2FA | «Требуется подтверждение СМС-кодом» | перейти к СМС-flow |
IDENTITY_USER_BLOCKED | 400 | Status ∈ {Blocked, Archived} | «Учётная запись заблокирована» | no retry |
IDENTITY_USER_LOCKED_OUT | 400 | LockoutUntil > 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, ThenIDENTITY_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, ThenIDENTITY_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.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Правила реферальной привязки (attach-rules) — дом доменных правил
- API (10): GET /internal/v1/referral-links/resolve — Internal: резолв реферальной ссылки по slug, Identity API — карта областей и реестр investor-области, 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/enable-confirm — Подтверждение включения 2FA (confirmEnableTwoFactor), POST /api/investors/me/pin-code/clear — Удаление пин-кода (clearPinCode), POST /api/investors/me/pin-code/set — Установка/обновление пин-кода (setPinCode), POST /api/v1/me/active-agency — Выбор активного агентства
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: user.md (INV-8/9/11), user-session.md
(INV-S2/S3/S5, enum
ConfirmationPurpose = SignIn). - События: user-session.md (
UserSignedInKafkaEvent); реферальная привязка —docs/06-services/referral/. - Пред-шаги: request-sign-in-code.md, sign-up-or-sign-in.md, sign-up.md.