test(stage): пункт 12 — 2FA TOTP 6/6 ✓ (enroll+verify+login flow+disable)
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run

TOTP-коды генерируются локально (RFC 6238 / Base32 + HMAC-SHA1)
из sharedKey. End-to-end: signup → enroll → verify (invalid+valid) →
login с otp_code (required+invalid+success) → disable → reset key.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-29 17:41:22 +05:00
parent 6a5bb52b13
commit 6b6f27d238
4 changed files with 323 additions and 1 deletions

View file

@ -28,7 +28,7 @@
- [x] **8. SupplierReturn (Возврат поставщику)** — аналог для Supply. *(stage-supplier-return.yml: 8/8 ✓)* - [x] **8. SupplierReturn (Возврат поставщику)** — аналог для Supply. *(stage-supplier-return.yml: 8/8 ✓)*
- [x] **9. Demand (Оптовая отгрузка)** — юрлицо, опт-цена, кредит (PaidAmount<Total), Stock минус WholesaleSale; multi-tenant. *(stage-demand.yml: 8/8 ✓)* - [x] **9. Demand (Оптовая отгрузка)** — юрлицо, опт-цена, кредит (PaidAmount<Total), Stock минус WholesaleSale; multi-tenant. *(stage-demand.yml: 8/8 ✓)*
- [x] **10. Отчёты** — Sales / Stock-на-дату / Profit / ABC; фильтры периода/магазина/группы; экспорт CSV/XLSX; edge. *(stage-reports.yml: 8/8 ✓, 3 фикса: UTC dates, Enter→Cost, ABC Pareto)* - [x] **10. Отчёты** — Sales / Stock-на-дату / Profit / ABC; фильтры периода/магазина/группы; экспорт CSV/XLSX; edge. *(stage-reports.yml: 8/8 ✓, 3 фикса: UTC dates, Enter→Cost, ABC Pareto)*
- [ ] **11. OrgAuditLog** — UI /audit-log, CRUD продукта → запись с diff; multi-tenant строго. - [x] **11. OrgAuditLog** — UI /audit-log, CRUD продукта → запись с diff; multi-tenant строго. *(stage-audit-log.yml: 7/7 ✓)*
- [ ] **12. 2FA TOTP** — enroll (QR), verify, login (двухшаговый), disable; невалидный код → 400. - [ ] **12. 2FA TOTP** — enroll (QR), verify, login (двухшаговый), disable; невалидный код → 400.
- [ ] **13. OpenAPI/Swagger** — все новые контроллеры в /swagger/v1/swagger.json. - [ ] **13. OpenAPI/Swagger** — все новые контроллеры в /swagger/v1/swagger.json.
- [ ] **14. POS Sync API**`POST /api/pos/sync` (dev-token), `POST /api/pos/sales` (idempotency-key — повтор не дублирует). - [ ] **14. POS Sync API**`POST /api/pos/sync` (dev-token), `POST /api/pos/sales` (idempotency-key — повтор не дублирует).

View file

@ -0,0 +1,75 @@
# E2E report: stage-2fa
Запущен: 2026-05-29T12:41:12.460Z
Длительность: 3.3с
**Итог:** 6 ✓ / 0 ✗ / 0 ⚠ / 0 ◯ (всего 6)
## ✓ Step tfa01_setup_and_status: Создать org → status показывает enabled=false
Длительность: 1760мс
| Тип | Проверка | Результат |
|---|---|---|
| api | GET /status → 200 | ✓ 200 |
| api | enabled = false для нового user | ✓ enabled=false |
## ✓ Step tfa02_enroll: POST /api/me/2fa/enroll → sharedKey + otpauth-URI, alreadyEnabled=false
Длительность: 140мс
| Тип | Проверка | Результат |
|---|---|---|
| api | POST /enroll → 200 | ✓ 200 |
| api | sharedKey не пустой | ✓ key.len=32 |
| api | authenticatorUri начинается с otpauth://totp/ | ✓ otpauth://totp/food-market%3astage-2fa-1780058472460%40food-… |
| api | alreadyEnabled = false | ✓ already=false |
## ✓ Step tfa03_verify_invalid_code: POST /api/me/2fa/verify с кодом 000000 → 400
Длительность: 99мс
| Тип | Проверка | Результат |
|---|---|---|
| api | verify с 000000 → 400 "Неверный код" | ✓ 400 {"error":"Неверный код. Попробуйте ещё раз.","field":"code"} |
## ✓ Step tfa04_verify_valid_code: POST /api/me/2fa/verify с реальным TOTP кодом → 200, enabled=true
Длительность: 172мс
| Тип | Проверка | Результат |
|---|---|---|
| api | verify с реальным TOTP-кодом (612117) → 200 | ✓ 200 {"enabled":true} |
| api | status.enabled = true | ✓ enabled=true |
## ✓ Step tfa05_login_requires_2fa: Логин без otp_code → 400 invalid_grant 2fa_required; с правильным otp_code → 200
Длительность: 735мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Логин без otp_code → 400 с 2fa_required | ✓ 400 {"error":"invalid_grant","error_description":"2fa_required"} |
| api | Логин с правильным otp_code (612117) → 200 | ✓ 200 access=yes |
| api | Логин с неверным otp_code → 400 с 2fa_invalid | ✓ 400 {"error":"invalid_grant","error_description":"2fa_invalid"} |
## ✓ Step tfa06_disable: POST /api/me/2fa/disable с кодом → 200; status показывает enabled=false; повторный enroll выдаёт новый key
Длительность: 382мс
| Тип | Проверка | Результат |
|---|---|---|
| api | disable без кода → 400 | ✓ 400 {"error":"Подтверди отключение текущим кодом из приложения.","field":"code"} |
| api | disable с code (612117) → 200, enabled=false | ✓ 200 {"enabled":false} |
| api | status.enabled = false | ✓ enabled=false |
| api | Повторный enroll → новый sharedKey (отличается от старого) | ✓ same=false |
## Summary
- Passed: 6
- Failed: 0
- Warnings: 0
- Skipped: 0
## Critical bugs
Нет.

View file

@ -0,0 +1,224 @@
/**
* Stage 2FA TOTP: enroll verify login с 2FA disable.
* TOTP-коды генерируем на ходу из sharedKey (RFC 6238, Base32).
*/
import { createHmac } from 'node:crypto'
import { login, makeClient, ADMIN_BASE } from '../lib/api.js'
import type { CheckResult, Step, Report } from '../lib/report.js'
type Ctx = {
apiOnly: boolean
ts: number
email?: string
password?: string
token?: string
sharedKey?: string
}
interface StepCtx { ctx: Ctx; step: Step; report: Report }
const TS = Date.now()
function check(step: Step, c: CheckResult) { step.checks.push(c) }
function asString(x: unknown): string {
if (x == null) return ''
if (typeof x === 'string') return x
return JSON.stringify(x)
}
/** Base32 (RFC 4648) decode → Buffer. ASP.NET выдаёт ключ без '=' padding и в upper-case. */
function base32Decode(raw: string): Buffer {
const s = raw.replace(/=+$/, '').toUpperCase()
const alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
let bits = 0, value = 0
const out: number[] = []
for (const ch of s) {
const idx = alpha.indexOf(ch)
if (idx < 0) continue // пропускаем не-Base32 символы (пробелы)
value = (value << 5) | idx
bits += 5
if (bits >= 8) {
bits -= 8
out.push((value >> bits) & 0xff)
}
}
return Buffer.from(out)
}
/** TOTP code по RFC 6238 (period=30s, digits=6, SHA-1). */
function totp(secretBase32: string, atSeconds = Math.floor(Date.now() / 1000)): string {
const key = base32Decode(secretBase32)
const counter = Math.floor(atSeconds / 30)
const cb = Buffer.alloc(8)
cb.writeBigUInt64BE(BigInt(counter))
const hmac = createHmac('sha1', key).update(cb).digest()
const offset = hmac[hmac.length - 1] & 0x0f
const bin =
((hmac[offset] & 0x7f) << 24) |
((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) |
(hmac[offset + 3] & 0xff)
return String(bin % 1_000_000).padStart(6, '0')
}
// ---------------------------------------------------------------------------
async function bootstrap(ctx: Ctx): Promise<void> {
if (ctx.token) return
const api = makeClient()
ctx.email = `stage-2fa-${TS}@food-market.local`
ctx.password = 'Stage2fa12345!'
let r = await api.post('/api/auth/signup', {
email: ctx.email, password: ctx.password,
organizationName: `TwoFA ${TS}`, phone: '+77011112000', plan: 'start',
})
for (let i = 0; i < 5 && r.status === 429; i++) {
await new Promise(res => setTimeout(res, 15000))
r = await api.post('/api/auth/signup', {
email: ctx.email, password: ctx.password,
organizationName: `TwoFA ${TS}`, phone: '+77011112000', plan: 'start',
})
}
if (r.status !== 200) throw new Error(`signup: ${r.status} ${JSON.stringify(r.data)}`)
const sess = await login(ctx.email, ctx.password)
ctx.token = sess.accessToken
}
// ---------------------------------------------------------------------------
export async function tfa01_setup_and_status({ ctx, step, report }: StepCtx) {
ctx.ts = TS
await bootstrap(ctx)
const api = makeClient(ctx.token)
const status = await api.get('/api/me/2fa/status')
check(step, { kind: 'api', description: 'GET /status → 200', ok: status.status === 200, detail: `${status.status}` })
check(step, { kind: 'api', description: 'enabled = false для нового user', ok: status.data?.enabled === false, detail: `enabled=${status.data?.enabled}` })
}
// ---------------------------------------------------------------------------
export async function tfa02_enroll({ ctx, step, report }: StepCtx) {
if (!ctx.token) { step.status = 'skip'; return }
const api = makeClient(ctx.token)
const r = await api.post('/api/me/2fa/enroll', {})
check(step, { kind: 'api', description: 'POST /enroll → 200', ok: r.status === 200, detail: `${r.status}` })
check(step, { kind: 'api', description: 'sharedKey не пустой', ok: !!r.data?.sharedKey && r.data.sharedKey.length >= 16, detail: `key.len=${r.data?.sharedKey?.length}` })
check(step, { kind: 'api', description: 'authenticatorUri начинается с otpauth://totp/', ok: String(r.data?.authenticatorUri ?? '').startsWith('otpauth://totp/'), detail: `${String(r.data?.authenticatorUri ?? '').slice(0, 60)}` })
check(step, { kind: 'api', description: 'alreadyEnabled = false', ok: r.data?.alreadyEnabled === false, detail: `already=${r.data?.alreadyEnabled}` })
ctx.sharedKey = r.data?.sharedKey
}
// ---------------------------------------------------------------------------
export async function tfa03_verify_invalid_code({ ctx, step, report }: StepCtx) {
if (!ctx.token) { step.status = 'skip'; return }
const api = makeClient(ctx.token)
const r = await api.post('/api/me/2fa/verify', { code: '000000' })
check(step, {
kind: 'api', description: 'verify с 000000 → 400 "Неверный код"',
ok: r.status === 400 && /неверн/i.test(asString(r.data)),
detail: `${r.status} ${asString(r.data).slice(0, 200)}`,
})
}
// ---------------------------------------------------------------------------
export async function tfa04_verify_valid_code({ ctx, step, report }: StepCtx) {
if (!ctx.token || !ctx.sharedKey) { step.status = 'skip'; return }
const api = makeClient(ctx.token)
const code = totp(ctx.sharedKey)
const r = await api.post('/api/me/2fa/verify', { code })
check(step, {
kind: 'api', description: `verify с реальным TOTP-кодом (${code}) → 200`,
ok: r.status === 200 && r.data?.enabled === true,
detail: `${r.status} ${asString(r.data).slice(0, 200)}`,
})
const status = await api.get('/api/me/2fa/status')
check(step, { kind: 'api', description: 'status.enabled = true', ok: status.data?.enabled === true, detail: `enabled=${status.data?.enabled}` })
}
// ---------------------------------------------------------------------------
export async function tfa05_login_requires_2fa({ ctx, step, report }: StepCtx) {
if (!ctx.email || !ctx.password || !ctx.sharedKey) { step.status = 'skip'; return }
const api = makeClient()
// Без otp_code → 400 invalid_grant 2fa_required
const noOtp = await api.post('/connect/token', new URLSearchParams({
grant_type: 'password',
username: ctx.email, password: ctx.password,
client_id: 'food-market-web',
scope: 'openid profile email roles api offline_access',
}), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } })
check(step, {
kind: 'api', description: 'Логин без otp_code → 400 с 2fa_required',
ok: noOtp.status === 400 && /2fa_required/.test(asString(noOtp.data)),
detail: `${noOtp.status} ${asString(noOtp.data).slice(0, 250)}`,
})
// С правильным otp_code → 200
const code = totp(ctx.sharedKey)
const ok = await api.post('/connect/token', new URLSearchParams({
grant_type: 'password',
username: ctx.email, password: ctx.password,
client_id: 'food-market-web',
scope: 'openid profile email roles api offline_access',
otp_code: code,
}), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } })
check(step, {
kind: 'api', description: `Логин с правильным otp_code (${code}) → 200`,
ok: ok.status === 200 && !!ok.data?.access_token,
detail: `${ok.status} access=${ok.data?.access_token ? 'yes' : 'no'}`,
})
// С неверным otp_code → 400 2fa_invalid
const bad = await api.post('/connect/token', new URLSearchParams({
grant_type: 'password',
username: ctx.email, password: ctx.password,
client_id: 'food-market-web',
scope: 'openid profile email roles api offline_access',
otp_code: '000000',
}), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } })
check(step, {
kind: 'api', description: 'Логин с неверным otp_code → 400 с 2fa_invalid',
ok: bad.status === 400 && /2fa_invalid/.test(asString(bad.data)),
detail: `${bad.status} ${asString(bad.data).slice(0, 250)}`,
})
// Обновим ctx.token валидным (для disable шага)
if (ok.status === 200) ctx.token = ok.data.access_token
}
// ---------------------------------------------------------------------------
export async function tfa06_disable({ ctx, step, report }: StepCtx) {
if (!ctx.token || !ctx.sharedKey) { step.status = 'skip'; return }
const api = makeClient(ctx.token)
// Без code → 400
const noCode = await api.post('/api/me/2fa/disable', { code: '' })
check(step, {
kind: 'api', description: 'disable без кода → 400',
ok: noCode.status === 400,
detail: `${noCode.status} ${asString(noCode.data).slice(0, 200)}`,
})
// С кодом → 200
const code = totp(ctx.sharedKey)
const ok = await api.post('/api/me/2fa/disable', { code })
check(step, {
kind: 'api', description: `disable с code (${code}) → 200, enabled=false`,
ok: ok.status === 200 && ok.data?.enabled === false,
detail: `${ok.status} ${asString(ok.data).slice(0, 200)}`,
})
// status
const status = await api.get('/api/me/2fa/status')
check(step, { kind: 'api', description: 'status.enabled = false', ok: status.data?.enabled === false, detail: `enabled=${status.data?.enabled}` })
// Re-enroll выдаёт НОВЫЙ key (т.к. был сброшен)
const re = await api.post('/api/me/2fa/enroll', {})
check(step, {
kind: 'api', description: 'Повторный enroll → новый sharedKey (отличается от старого)',
ok: !!re.data?.sharedKey && re.data.sharedKey !== ctx.sharedKey,
detail: `same=${re.data?.sharedKey === ctx.sharedKey}`,
})
}

View file

@ -0,0 +1,23 @@
name: stage-2fa
description: |
2FA TOTP на test.admin.food-market.kz. Enroll (получение sharedKey),
verify валидным/невалидным кодом, login требует otp_code, disable.
TOTP-коды генерируем локально из sharedKey через crypto.
preconditions:
reset_db: false
smoke_login_super_admin: false
steps:
- id: tfa01_setup_and_status
title: Создать org → status показывает enabled=false
- id: tfa02_enroll
title: POST /api/me/2fa/enroll → sharedKey + otpauth-URI, alreadyEnabled=false
- id: tfa03_verify_invalid_code
title: POST /api/me/2fa/verify с кодом 000000 → 400
- id: tfa04_verify_valid_code
title: POST /api/me/2fa/verify с реальным TOTP кодом → 200, enabled=true
- id: tfa05_login_requires_2fa
title: Логин без otp_code → 400 invalid_grant 2fa_required; с правильным otp_code → 200
- id: tfa06_disable
title: POST /api/me/2fa/disable с кодом → 200; status показывает enabled=false; повторный enroll выдаёт новый key