Подход 2 (Эпик 3): Восстановление доступа пайщика — CoopID #127

Closed
claude wants to merge 11 commits from feat/coopid-epic-3 into feat/coopid-epic-1
Owner

Подход 2 «Восстановление доступа» — Эпик 3 (CoopID, компонент 42)

Stacked-PR поверх feat/coopid-epic-1 (Подход 1, зонтичный PR #123 → dev). После merge #123 в dev база этого PR будет переключена на dev.

Источник AC: project 13 epics.md, Epic 3 (issue 105-6). Спеки реализации — _bmad-output/implementation-artifacts/spec-3-*.md.

Решение владельца 2026-06-10: базовый второй канал MVP = Google Authenticator (TOTP) (не magic-link/push — отменяет исходный AC 3.6). Recovery: пароль не хранить весь grace-период, запрашивать на финальном шаге; второй фактор подтверждения recovery = TOTP. Следствие: 3.6 сделана раньше 3.2 (TOTP-enrollment — пререквизит recovery-подтверждения).

Истории Эпика 3

  • 3.1 Magic-link recovery — endpoint + email (токен в Redis 5м, письмо через Центр уведомлений, 202 анти-enumeration, rate-limit 3/ч, код TooManyRecoveryAttempts)
  • 3.2 Двухканальное подтверждение recovery (magic-link + TOTP) — /coop/recovery/{confirm,cancel}: peek токена → проверка TOTP (TWO_FACTOR_VERIFIER) → атомарный consume (claim) → порт финализации → audit; пароль на финальном шаге и не хранится; cancel сжигает токен; код InvalidRecoveryToken(400); rate-limit per-IP/per-token. Финализация (ротация ключа) вынесена за RECOVERY_FINALIZATION_PORTсейм Story 3.3
  • 3.3 Auto key rotation при recovery (updateauth COOPOS) — реализует RECOVERY_FINALIZATION_PORT. ⚠️ открытый вопрос владельцу: источник admin-токена authentik для смены пароля (секреты сейчас vs провижн плейбуком)
  • 3.4 Альтернативный recovery — offline-код (таблица offline_recovery_code, keyed-hash HMAC, single-use → тот же recovery-токен → confirm 3.2; код InvalidOfflineCode(400))
  • 3.5 Переключение recovery-стратегии в настройках — настройка recovery_strategy (email_magic_link по умолчанию | offline_code | council) на пайщика гейтит входные каналы: requestByEmail (3.1) тихо no-op'ит, requestByOfflineCode (3.4) бросает InvalidOfflineCode, если их канал не выбран; council отключает оба (восстановление только через approval-flow 6.9). GET/POST /coop/recovery/strategy под JWT-guard; смена — step-up TOTP (вместо пароля: в authentik нет password-verify, паттерн 2FA-disable) + audit coopid.recovery.strategy_changed; таблица recovery_strategy (V2.4.4); 19 unit-тестов зелёные
  • 3.6 Второй фактор TOTP (Google Authenticator) — движок RFC 6238 (node:crypto, проверен тест-векторами), two_factor в coop_domain_db (секрет шифрован server-key), enroll/activate/disable под JWT-guard, TWO_FACTOR_VERIFIER для recovery/входа, коды 401/400
  • 3.7 Просмотр и отзыв активных сессий — сессия = persistent refresh-токен платформенного стора (им финализирует вход verify-timestamp 1.7, его же отзывает logout 1.10); GET/DELETE /coop/sessions под JWT-guard; отзыв = deleteById строки токена (реально инвалидирует refresh). Метаданные device/IP — Redis side-store за SESSION_METADATA_PORT (ключ sha256(refresh), TTL=refresh lifetime, сам токен не хранится), запись best-effort в verify-timestamp. Дрейф AC↔код: authentik oauth_tokens//oauth/revoke → наш refresh-токен ⇒ история НЕ заблокирована на authentik admin-токене (в отличие от 3.3/3.10/3.12). Отступления: geo по IP отложено (нет провайдера), lastSeenAt=createdAt до шва refresh-touch. 27 unit-тестов зелёные
  • 3.8 Device tracking при каждом входе — на этапе 2 (verify-timestamp, финализация входа) пишется audit coopid.login.successful (subject_id, ip, user_agent, accept_language, признак нового устройства) + обновляется Redis-набор известных устройств пайщика (coopid:devices:<subjectId>, HASH, sliding-TTL). Fingerprint = sha256(user_agent + Accept-Language); isNewDevice — основа уведомления 3.9. Tracking best-effort (сбой не валит вход). Отступления: screen-resolution (нет в контракте входа) и geo-IP (нет провайдера) отложены. 17 unit-тестов зелёные
  • 3.9 Уведомление о входе с нового устройства — триггер в DeviceTrackingService при isNewDevice → workflow new-device-login (email + in-app) в @coopenomics/notifications; bundling NFR10 = Redis-замок SET NX EX 12h (порт NEW_DEVICE_NOTIFICATION_THROTTLE), занимается до notify (защита от гонки); получатель по subjectId, email только подтверждённый, in-app по subscriber_id; всё best-effort (сбой не валит вход). Отступления: ссылка «это не я» → /settings/security (выделенный one-click endpoint — Story 3.10), каналы email+in_app (push/Matrix отложены). 11 unit-тестов зелёные
  • 3.10 Флаг «Это не я» — массовый отзыв сессий — SecurityIncidentService.report/reportByToken переиспользует SessionsService.revokeAll (3.7) + audit coopid.security.suspicious_login_reported. Два входа: POST /coop/security/not-me (JWT, из настроек) и not-me/:token (one-click из письма, без auth — своя сессия скомпрометирована; авторизация single-use 256-бит токеном NOT_ME_TOKEN_STORE, TTL 7д, consume Lua GET→DEL). Письмо new-device (3.9) теперь несёт notMeUrlзакрыт отложенный one-click endpoint. Отложено в 3.3 (owner-blocked authentik-admin): force password change при следующем входе + ротация ключа COOPOS (AC сам относит rotation к 3.3; флаг без enforcement = мёртвое состояние). 15 unit-тестов зелёные
  • 3.11 Уведомления о критичных событиях безопасности — единый workflow security-event (email + in-app) в @coopenomics/notifications + SecurityEventNotificationService (best-effort, без троттла — каждое событие уведомляет); enum SecurityEventKind (5 событий) + карта заголовков. Подключены 3 live-события: 2FA enabled/disabled (Story 3.6), recovery.strategy_changed (Story 3.5). PasswordChanged/KeyRotated в enum, проводка отложена до Story 3.3 (нет live-события ротации). Отступления как в 3.9 (каналы email+in_app, ссылка → /settings/security). 23 unit-теста зелёные
  • 3.12 Escalating account lockout (включает 24h-cooldown recovery) — расширение rate-limit-контура (Story 9.1): Lua incrementEscalating (персистентный страйк → тир блока 1ч→4ч→12ч→24ч, NFR13; память страйков 24ч, после простоя сброс) + IEscalatingRateLimitStorage; EscalationPolicy/ESCALATING_LOCKOUT. Guard идёт через escalation-путь при rule.escalating, пишет best-effort audit coopid.security.account_locked на переходе в блок (трекер хэшируется sha256 — recovery-токен/email не утекают), одноразово на окно; defensive-откат на increment. Пресет навешан на 4 recovery-эндпоинта (ip+account) — живой 24h-cooldown. Граница (owner-config authentik, без admin-токена): перебор пароля блокируется штатной brute-force-защитой authentik (наш этап-2 идёт ПОСЛЕ пароля — перебор не наблюдаем); NFR10 independent-channel для входа — authentik, для recovery email-magic-link уже штатный. 20 unit-тестов зелёные

Решения и поправки AC (дрейф AC↔код — прав код)

  • Источник пайщика — user-домен coopback (UserDomainService); таблицы participants из AC в brownfield-mono нет.
  • Notifications-stack — готовый Центр уведомлений (DC v3): email/in-app/web-push; Matrix отсутствует → MVP без него.
  • Rate-limit — переиспользуем RedisThrottlerStorage/guard (Story 9.1); recovery — отдельный код TooManyRecoveryAttempts.
  • TOTP — собственный движок на node:crypto (RFC 6238), без npm-зависимости; секрет шифрован server-key (не ключ пайщика — инвариант vault цел).
  • Recovery статeless — пароль не хранится grace-период (решение владельца); единственное состояние — magic-link токен (Redis 5м). Частичная финализация запрещена (рассинхрон ключа блокирует пайщика) → ротация целиком в 3.3 за портом.

🤖 Generated with Claude Code

## Подход 2 «Восстановление доступа» — Эпик 3 (CoopID, компонент 42) Stacked-PR поверх `feat/coopid-epic-1` (Подход 1, зонтичный PR #123 → dev). После merge #123 в dev база этого PR будет переключена на dev. **Источник AC:** project 13 `epics.md`, Epic 3 (issue 105-6). Спеки реализации — `_bmad-output/implementation-artifacts/spec-3-*.md`. > **Решение владельца 2026-06-10:** базовый второй канал MVP = **Google Authenticator (TOTP)** (не magic-link/push — отменяет исходный AC 3.6). Recovery: пароль не хранить весь grace-период, запрашивать на финальном шаге; второй фактор подтверждения recovery = TOTP. Следствие: **3.6 сделана раньше 3.2** (TOTP-enrollment — пререквизит recovery-подтверждения). ### Истории Эпика 3 - [x] **3.1** Magic-link recovery — endpoint + email (токен в Redis 5м, письмо через Центр уведомлений, 202 анти-enumeration, rate-limit 3/ч, код `TooManyRecoveryAttempts`) - [x] **3.2** Двухканальное подтверждение recovery (magic-link + TOTP) — `/coop/recovery/{confirm,cancel}`: peek токена → проверка TOTP (`TWO_FACTOR_VERIFIER`) → атомарный consume (claim) → порт финализации → audit; пароль на финальном шаге и не хранится; cancel сжигает токен; код `InvalidRecoveryToken`(400); rate-limit per-IP/per-token. Финализация (ротация ключа) вынесена за `RECOVERY_FINALIZATION_PORT` — **сейм Story 3.3** - [ ] 3.3 Auto key rotation при recovery (updateauth COOPOS) — реализует `RECOVERY_FINALIZATION_PORT`. ⚠️ **открытый вопрос владельцу:** источник admin-токена authentik для смены пароля (секреты сейчас vs провижн плейбуком) - [x] **3.4** Альтернативный recovery — offline-код (таблица `offline_recovery_code`, keyed-hash HMAC, single-use → тот же recovery-токен → confirm 3.2; код `InvalidOfflineCode`(400)) - [x] **3.5** Переключение recovery-стратегии в настройках — настройка `recovery_strategy` (email_magic_link по умолчанию | offline_code | council) на пайщика гейтит входные каналы: `requestByEmail` (3.1) тихо no-op'ит, `requestByOfflineCode` (3.4) бросает `InvalidOfflineCode`, если их канал не выбран; `council` отключает оба (восстановление только через approval-flow 6.9). GET/POST `/coop/recovery/strategy` под JWT-guard; смена — **step-up TOTP** (вместо пароля: в authentik нет password-verify, паттерн 2FA-disable) + audit `coopid.recovery.strategy_changed`; таблица `recovery_strategy` (V2.4.4); 19 unit-тестов зелёные - [x] **3.6** Второй фактор TOTP (Google Authenticator) — движок RFC 6238 (node:crypto, проверен тест-векторами), `two_factor` в coop_domain_db (секрет шифрован server-key), enroll/activate/disable под JWT-guard, `TWO_FACTOR_VERIFIER` для recovery/входа, коды 401/400 - [x] **3.7** Просмотр и отзыв активных сессий — сессия = persistent refresh-токен платформенного стора (им финализирует вход `verify-timestamp` 1.7, его же отзывает logout 1.10); `GET/DELETE /coop/sessions` под JWT-guard; отзыв = `deleteById` строки токена (реально инвалидирует refresh). Метаданные device/IP — Redis side-store за `SESSION_METADATA_PORT` (ключ `sha256(refresh)`, TTL=refresh lifetime, сам токен не хранится), запись best-effort в verify-timestamp. **Дрейф AC↔код:** authentik `oauth_tokens`/`/oauth/revoke` → наш refresh-токен ⇒ история **НЕ заблокирована** на authentik admin-токене (в отличие от 3.3/3.10/3.12). Отступления: geo по IP отложено (нет провайдера), `lastSeenAt=createdAt` до шва refresh-touch. 27 unit-тестов зелёные - [x] **3.8** Device tracking при каждом входе — на этапе 2 (`verify-timestamp`, финализация входа) пишется audit `coopid.login.successful` (subject_id, ip, user_agent, accept_language, признак нового устройства) + обновляется Redis-набор известных устройств пайщика (`coopid:devices:<subjectId>`, HASH, sliding-TTL). Fingerprint = `sha256(user_agent + Accept-Language)`; `isNewDevice` — основа уведомления 3.9. Tracking best-effort (сбой не валит вход). Отступления: screen-resolution (нет в контракте входа) и geo-IP (нет провайдера) отложены. 17 unit-тестов зелёные - [x] **3.9** Уведомление о входе с нового устройства — триггер в `DeviceTrackingService` при `isNewDevice` → workflow `new-device-login` (email + in-app) в `@coopenomics/notifications`; bundling NFR10 = Redis-замок `SET NX EX 12h` (порт `NEW_DEVICE_NOTIFICATION_THROTTLE`), занимается до notify (защита от гонки); получатель по `subjectId`, email только подтверждённый, in-app по `subscriber_id`; всё best-effort (сбой не валит вход). Отступления: ссылка «это не я» → `/settings/security` (выделенный one-click endpoint — Story 3.10), каналы email+in_app (push/Matrix отложены). 11 unit-тестов зелёные - [x] **3.10** Флаг «Это не я» — массовый отзыв сессий — `SecurityIncidentService.report/reportByToken` переиспользует `SessionsService.revokeAll` (3.7) + audit `coopid.security.suspicious_login_reported`. Два входа: `POST /coop/security/not-me` (JWT, из настроек) и `not-me/:token` (one-click из письма, **без auth** — своя сессия скомпрометирована; авторизация single-use 256-бит токеном `NOT_ME_TOKEN_STORE`, TTL 7д, consume Lua GET→DEL). Письмо new-device (3.9) теперь несёт `notMeUrl` — **закрыт отложенный one-click endpoint**. **Отложено в 3.3** (owner-blocked authentik-admin): force password change при следующем входе + ротация ключа COOPOS (AC сам относит rotation к 3.3; флаг без enforcement = мёртвое состояние). 15 unit-тестов зелёные - [x] **3.11** Уведомления о критичных событиях безопасности — единый workflow `security-event` (email + in-app) в `@coopenomics/notifications` + `SecurityEventNotificationService` (best-effort, без троттла — каждое событие уведомляет); enum `SecurityEventKind` (5 событий) + карта заголовков. Подключены 3 live-события: 2FA `enabled`/`disabled` (Story 3.6), `recovery.strategy_changed` (Story 3.5). `PasswordChanged`/`KeyRotated` в enum, проводка отложена до Story 3.3 (нет live-события ротации). Отступления как в 3.9 (каналы email+in_app, ссылка → `/settings/security`). 23 unit-теста зелёные - [x] **3.12** Escalating account lockout (включает 24h-cooldown recovery) — расширение rate-limit-контура (Story 9.1): Lua `incrementEscalating` (персистентный страйк → тир блока **1ч→4ч→12ч→24ч**, NFR13; память страйков 24ч, после простоя сброс) + `IEscalatingRateLimitStorage`; `EscalationPolicy`/`ESCALATING_LOCKOUT`. Guard идёт через escalation-путь при `rule.escalating`, пишет best-effort audit `coopid.security.account_locked` на переходе в блок (трекер **хэшируется** sha256 — recovery-токен/email не утекают), одноразово на окно; defensive-откат на `increment`. Пресет навешан на 4 recovery-эндпоинта (ip+account) — **живой 24h-cooldown**. **Граница (owner-config authentik, без admin-токена):** перебор **пароля** блокируется штатной brute-force-защитой authentik (наш этап-2 идёт ПОСЛЕ пароля — перебор не наблюдаем); NFR10 independent-channel для входа — authentik, для recovery email-magic-link уже штатный. 20 unit-тестов зелёные ### Решения и поправки AC (дрейф AC↔код — прав код) - **Источник пайщика** — user-домен coopback (`UserDomainService`); таблицы `participants` из AC в brownfield-mono нет. - **Notifications-stack** — готовый Центр уведомлений (DC v3): email/in-app/web-push; Matrix отсутствует → MVP без него. - **Rate-limit** — переиспользуем `RedisThrottlerStorage`/guard (Story 9.1); recovery — отдельный код `TooManyRecoveryAttempts`. - **TOTP** — собственный движок на `node:crypto` (RFC 6238), без npm-зависимости; секрет шифрован server-key (не ключ пайщика — инвариант vault цел). - **Recovery статeless** — пароль не хранится grace-период (решение владельца); единственное состояние — magic-link токен (Redis 5м). Частичная финализация запрещена (рассинхрон ключа блокирует пайщика) → ротация целиком в 3.3 за портом. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
claude added 1 commit 2026-06-10 18:04:12 +00:00
Эндпоинт POST /coop/recovery/request: одноразовый токен в Redis (5 мин),
письмо через готовый Центр уведомлений (workflow reset-key), константный
202 (анти-enumeration), rate-limit 3/час по email и IP с отдельным кодом
TooManyRecoveryAttempts. Источник пайщика — user-домен (таблицы participants
из AC в brownfield нет). Открывает Подход 2 (Эпик 3, восстановление доступа).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-06-10 18:36:19 +00:00
Собственный TOTP-движок (RFC 6238, node:crypto, без npm-зависимости, проверен
тест-векторами RFC), таблица two_factor в coop_domain_db (секрет зашифрован
server-key — не ключ пайщика, инвариант vault цел), enroll/activate/disable
под JWT-guard, узкий TWO_FACTOR_VERIFIER для recovery (3.2) и 2FA-входа.
Решение владельца: TOTP — базовый 2-й канал MVP вместо magic-link-toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-06-10 19:10:43 +00:00
claude added 1 commit 2026-06-10 19:27:09 +00:00
claude added 1 commit 2026-06-10 19:50:25 +00:00
Настройка recovery_strategy (email_magic_link по умолчанию | offline_code | council)
на пайщика гейтит входные каналы восстановления: requestByEmail (3.1) тихо no-op'ит,
requestByOfflineCode (3.4) бросает InvalidOfflineCode, если их канал не выбран; council
отключает оба (восстановление только через approval-flow 6.9). Смена — под JWT-guard со
step-up TOTP (вместо пароля: в authentik нет password-verify, паттерн 2FA-disable) + audit.
GET/POST /coop/recovery/strategy; таблица recovery_strategy (V2.4.4); 19 unit-тестов зелёные.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-06-10 20:21:28 +00:00
На финализации входа (этап 2, verify-timestamp) фиксируем устройство: audit-событие
coopid.login.successful (subject_id, ip, user_agent, accept_language, признак нового
устройства) + обновление Redis-набора известных устройств пайщика. Fingerprint =
sha256(user_agent + Accept-Language) — server-side; признак isNewDevice — основа
уведомления о новом устройстве (Story 3.9). Tracking best-effort: сбой не валит вход.
Отступления: screen-resolution (нет в контракте входа) и geo-IP (нет провайдера) отложены.
17 unit-тестов зелёные.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-06-10 20:53:12 +00:00
claude added 1 commit 2026-06-10 21:18:32 +00:00
claude added 1 commit 2026-06-10 21:53:39 +00:00
Сессия = persistent refresh-токен платформенного стора (им финализирует вход
verify-timestamp); GET/DELETE coop/sessions под JWT-guard; отзыв = удаление строки
токена (реально инвалидирует refresh). Метаданные device/IP — Redis side-store за
SESSION_METADATA_PORT (ключ sha256(refresh), TTL=refresh, токен не хранится), запись
best-effort на входе. Дрейф AC↔код: authentik oauth_tokens/oauth-revoke → наш
refresh-токен, история не заблокирована на authentik admin-токене. 27 unit-тестов.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-06-10 22:19:52 +00:00
SecurityIncidentService.report/reportByToken переиспользует SessionsService.revokeAll
(3.7) + пишет audit coopid.security.suspicious_login_reported. Два входа: POST
coop/security/not-me (JWT, из настроек) и not-me/:token (one-click из письма, без auth —
своя сессия скомпрометирована, авторизация single-use 256-бит токеном NOT_ME_TOKEN_STORE,
TTL 7д, consume Lua GET-DEL). Письмо new-device (3.9) теперь несёт notMeUrl — закрыт
отложенный one-click endpoint. Force password change + ротация ключа отложены в 3.3
(AC относит rotation к 3.3; флаг без enforcement = мёртвое состояние). 15 unit-тестов.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-06-10 22:53:49 +00:00
ant added this to the CoopID project 2026-06-15 13:17:41 +00:00
Author
Owner

Схлопнут в единый PR #148 (coopID → dev). Ветка сохранена как резерв, ничего не потеряно.

Схлопнут в единый PR #148 (coopID → dev). Ветка сохранена как резерв, ничего не потеряно.
claude closed this pull request 2026-06-15 13:26:45 +00:00

Pull request closed

Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: C9S/mono#127