From f3fb3fd5651fe68611056d2db9ca150f8c290e0d Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Tue, 21 Apr 2026 12:57:12 +0700 Subject: [PATCH] [CLAUDE] Phase5 prep: production infra + deploy scripts + 4 guides + FE refresh token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend production infra: - Packages: Serilog.Sinks.File, HealthChecks.EntityFrameworkCore (RateLimiting built-in .NET 10) - appsettings.Production.json MOI: placeholder __SET_VIA_SECRETS__, AllowedOrigins, Serilog File sink rolling daily retention 30d, RateLimit config - appsettings.json + Development.json: them Serilog WriteTo Console - Program.cs REWRITE: - Serilog ReadFrom.Configuration (prod file / dev console) - Rate limiter: policy auth-login 5/min/IP (AuthController.Login) + GlobalLimiter 300/min/IP - Health checks: /health/live liveness (empty predicate) + /health/ready DB probe (AddDbContextCheck) - HSTS production 1 year - CORS origins from config AllowedOrigins (default dev 2 localhost) - AuthController.Login gắn [EnableRateLimiting("auth-login")] Deploy scripts: - scripts/deploy-iis.ps1: stop pool → backup current → clean+extract artifact → start pool → health check loop 30s timeout → rollback instruction if fail - scripts/backup-sql.ps1: BACKUP DATABASE voi INIT+COMPRESSION+CHECKSUM + retention 30d auto cleanup - .gitea/workflows/deploy.yml MOI: 4 job build BE (Windows) + build 2 FE (Ubuntu, pin .nvmrc 20) + deploy-iis qua WinRM PSSession (secrets IIS_HOST/USER/PASSWORD/JWT_SECRET/DB_CONNECTION) Docs guides MOI (4 file): - deployment-iis.md: prereqs (IIS features, Hosting Bundle, SQL, WinRM) + setup lan dau (app pool, 3 site, HTTPS win-acme, user-secrets) + deploy hang ngay (CI/CD + manual) + rollback + monitoring + troubleshooting + SPA web.config sample - cicd.md: pipeline overview 4 job, secrets setup, runner Windows+Ubuntu, branch strategy, build optimizations, common CI/CD issues - security-checklist.md: OWASP top 10 2021 mapping voi status + pre go-live checklist + incident response - runbook.md: daily ops (health/logs), restart/rollback, DB backup/restore/migration revert, user management (reset password, unlock, disable), monitoring (CPU/disk/connection pool), deployment checklist, common gotcha Frontend refresh token (ca 2 app fe-admin + fe-user): - lib/api.ts REWRITE: them REFRESH_KEY, axios response interceptor 401 → POST /auth/refresh → retry request goc. Queue pattern cho nhieu request song song chi 1 refresh call chay. Skip retry /auth/login + /auth/refresh tranh infinite loop. _retry flag tren original config. - contexts/AuthContext.tsx: luu+xoa REFRESH_KEY trong login/logout E2E verified: - GET /health/live → 200 Healthy - GET /health/ready → 200 Healthy (DB probe) - Rate limit flood 7 POST /auth/login → #1-5 HTTP 400 (cred sai) + #6-7 HTTP 429 Too Many Requests ✅ - TS check fe-admin + fe-user → pass - dotnet build → 0 errors Docs updates: - docs/STATUS.md: Phase 5 prep done, next Phase 5 deploy production + Phase 5.1 security hardening, cumulative stats 8 commits - docs/HANDOFF.md: phase table them Phase 5 prep row, file tree update voi guides + scripts + workflows, git state commit 8 - docs/changelog/migration-todos.md: tick Phase 5 prep items (12 items done) + Phase 5 deploy items remaining + Phase 5.1 security hardening list - docs/changelog/sessions/2026-04-21-1530-phase5-prep.md: session log chi tiet Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitea/workflows/deploy.yml | 118 ++++++++++ docs/HANDOFF.md | 27 ++- docs/STATUS.md | 105 +++++---- .../sessions/2026-04-21-1530-phase5-prep.md | 121 ++++++++++ docs/guides/cicd.md | 123 ++++++++++ docs/guides/deployment-iis.md | 216 ++++++++++++++++++ docs/guides/runbook.md | 195 ++++++++++++++++ docs/guides/security-checklist.md | 147 ++++++++++++ fe-admin/src/contexts/AuthContext.tsx | 4 +- fe-admin/src/lib/api.ts | 91 +++++++- fe-user/src/contexts/AuthContext.tsx | 4 +- fe-user/src/lib/api.ts | 88 ++++++- scripts/backup-sql.ps1 | 55 +++++ scripts/deploy-iis.ps1 | 99 ++++++++ .../Controllers/AuthController.cs | 1 + src/Backend/SolutionErp.Api/Program.cs | 61 ++++- .../SolutionErp.Api/SolutionErp.Api.csproj | 2 + .../appsettings.Development.json | 5 +- src/Backend/SolutionErp.Api/appsettings.json | 11 +- 19 files changed, 1382 insertions(+), 91 deletions(-) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 docs/changelog/sessions/2026-04-21-1530-phase5-prep.md create mode 100644 docs/guides/cicd.md create mode 100644 docs/guides/deployment-iis.md create mode 100644 docs/guides/runbook.md create mode 100644 docs/guides/security-checklist.md create mode 100644 scripts/backup-sql.ps1 create mode 100644 scripts/deploy-iis.ps1 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..7d5a552 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,118 @@ +# Gitea Actions CI/CD — build + deploy SOLUTION_ERP lên IIS Windows Server. +# Trigger: push vào branch main, hoặc manual. +# +# Chạy trên Windows self-hosted runner (vì cần IIS + Word COM cho .doc convert optional). +# Secrets cần set trong Gitea repo settings: +# - IIS_HOST (hostname hoặc IP) +# - IIS_USER (Windows user có admin + WinRM) +# - IIS_PASSWORD +# - JWT_SECRET (64+ chars random — dùng trong appsettings.Production.json) +# - DB_CONNECTION (connection string production) + +name: Deploy SOLUTION_ERP + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build-backend: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + Build BE + run: | + dotnet restore SolutionErp.slnx + dotnet build SolutionErp.slnx --no-restore --configuration Release + + - name: Publish Api + run: > + dotnet publish src/Backend/SolutionErp.Api/SolutionErp.Api.csproj + --no-build --configuration Release + --output artifacts/api + --runtime win-x64 --self-contained false + + - uses: actions/upload-artifact@v4 + with: + name: backend-api + path: artifacts/api/ + + build-fe-admin: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: 'fe-admin/.nvmrc' # pin 20.x (gotcha NamGroup) + cache: 'npm' + cache-dependency-path: 'fe-admin/package-lock.json' + - run: npm ci + working-directory: fe-admin + - run: npm run build + working-directory: fe-admin + - uses: actions/upload-artifact@v4 + with: + name: fe-admin-dist + path: fe-admin/dist/ + + build-fe-user: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: 'fe-user/.nvmrc' + cache: 'npm' + cache-dependency-path: 'fe-user/package-lock.json' + - run: npm ci + working-directory: fe-user + - run: npm run build + working-directory: fe-user + - uses: actions/upload-artifact@v4 + with: + name: fe-user-dist + path: fe-user/dist/ + + deploy-iis: + needs: [build-backend, build-fe-admin, build-fe-user] + runs-on: windows-latest + if: github.ref == 'refs/heads/main' + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts/ + + - name: Zip artifacts + shell: pwsh + run: | + Compress-Archive -Path artifacts/backend-api/* -DestinationPath api.zip -Force + Compress-Archive -Path artifacts/fe-admin-dist/* -DestinationPath fe-admin.zip -Force + Compress-Archive -Path artifacts/fe-user-dist/* -DestinationPath fe-user.zip -Force + + - name: Copy + deploy via WinRM + shell: pwsh + env: + IIS_HOST: ${{ secrets.IIS_HOST }} + IIS_USER: ${{ secrets.IIS_USER }} + IIS_PASSWORD: ${{ secrets.IIS_PASSWORD }} + run: | + $secure = ConvertTo-SecureString $env:IIS_PASSWORD -AsPlainText -Force + $cred = New-Object PSCredential($env:IIS_USER, $secure) + $session = New-PSSession -ComputerName $env:IIS_HOST -Credential $cred + + Copy-Item -Path api.zip, fe-admin.zip, fe-user.zip -Destination "C:\Deploy\" -ToSession $session + + Invoke-Command -Session $session -ScriptBlock { + & C:\Deploy\scripts\deploy-iis.ps1 -Artifact "C:\Deploy\api.zip" -Site "SolutionErpApi" + Expand-Archive "C:\Deploy\fe-admin.zip" "C:\inetpub\solution-erp\fe-admin" -Force + Expand-Archive "C:\Deploy\fe-user.zip" "C:\inetpub\solution-erp\fe-user" -Force + } + + Remove-PSSession $session diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 626413a..d457ed5 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,6 +1,6 @@ # HANDOFF — Brief 5 phút cho session tiếp theo -**Last updated:** 2026-04-21 14:30 (cuối Phase 4 MVP + docs consolidation) +**Last updated:** 2026-04-21 15:30 (cuối Phase 5 Prep) ## Ở đâu rồi? @@ -12,10 +12,12 @@ | 2 Form Engine MVP | ✅ Done | | 2 Form Engine iteration 2 | 📝 Optional | | 3 Workflow MVP (9 phase + code gen) | ✅ Done | -| 3 Workflow iteration 2 (SLA job + notify + attachment) | 📝 Optional | -| **4 Report + Polish MVP (Dashboard + Excel)** | ✅ Done | -| 4 Report iteration 2 (SLA report, PDF export) | 📝 Optional | -| 5 Production (CI/CD IIS) | 📋 Next | +| 3 Workflow iteration 2 (SLA + notify + attachment) | 📝 Optional | +| 4 Report MVP (Dashboard + Excel) | ✅ Done | +| 4 Report iteration 2 | 📝 Optional | +| **5 Prep (infra + scripts + guides + refresh token)** | ✅ Done | +| 5 Deploy production (cần Gitea URL) | 📋 Next | +| 5.1 Security hardening (headers, lockout, IDOR) | 📋 Queue | ## Run nhanh @@ -139,21 +141,28 @@ SOLUTION_ERP/ │ ├── LoginPage │ ├── InboxPage ← Phase 3 │ └── contracts/ContractCreatePage, ContractDetailPage, MyContractsPage ← Phase 3 -├── docs/ (30 file) +├── docs/ (35 file) │ ├── STATUS.md, HANDOFF.md, rules.md, architecture.md │ ├── CLAUDE.md, PROJECT-MAP.md │ ├── workflow-contract.md, forms-spec.md │ ├── database/{database-guide, schema-diagram}.md │ ├── flows/ (7 file — README + 6 flow) -│ ├── changelog/migration-todos.md + sessions/ (6 session log) +│ ├── guides/ (4 file) — deployment-iis, cicd, security-checklist, runbook ← Phase 5 prep +│ ├── changelog/migration-todos.md + sessions/ (7 session log) │ └── gotchas.md +├── scripts/ (5 file PS + py) +│ ├── parse_forms.py, parse_workflow.py (Phase 0) +│ ├── convert-doc-to-docx.ps1 (Phase 2) +│ └── deploy-iis.ps1, backup-sql.ps1 ← Phase 5 prep +├── .gitea/workflows/deploy.yml ← Phase 5 prep CI/CD template └── .claude/skills/ (3 skill — all full spec) ``` ## Git state ``` -(sẽ là commit 7) — Phase 4 Report MVP + docs consolidation +(sẽ là commit 8) — Phase 5 Prep (infra + scripts + guides + refresh token) +fe7ad8e — Phase 4 Report MVP + docs consolidation 7e957a7 — Phase 3 Workflow MVP 5113e4c — Phase 2 Form Engine MVP 54d6c9b — Phase 1.2 CRUD + Permission @@ -162,7 +171,7 @@ SOLUTION_ERP/ 25dad7f — Phase 0 scaffold Branch: main -Remote: chưa (Gitea URL chờ user — cần cho Phase 5) +Remote: chưa (Gitea URL CẦN NGAY để Phase 5 go-live) ``` ## Credentials + URLs diff --git a/docs/STATUS.md b/docs/STATUS.md index 4acc71d..4c2d650 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,9 +2,9 @@ > **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`. -**Last updated:** 2026-04-21 14:30 +**Last updated:** 2026-04-21 15:30 -## 📍 Phase hiện tại: **Phase 4 Report MVP (xong)** — sẵn sàng Phase 5 Production hoặc polish iterations +## 📍 Phase hiện tại: **Phase 5 Prep xong** (infra + scripts + docs) — chờ Gitea URL để deploy thật ## 🔥 In Progress @@ -14,73 +14,82 @@ _(không có)_ | Ngày | Ai | Task | Commit | |---|---|---|---| -| 2026-04-21 | Claude | **Phase 4 Report MVP + Docs Consolidation** — BE Dashboard stats (5 KPI + by phase + top 5 NCC/dự án + 12 tháng) + Excel export qua ClosedXML. FE DashboardPage rewrite với BarChart tự build (không thư viện ngoài) + ReportsPage filter export. Docs: rules.md (coding conventions), architecture.md (layered + sequence), database/schema-diagram.md (ERD + data flow 19 table), gotchas.md update 26 pitfalls | (sắp commit) | -| 2026-04-21 | Claude | **Phase 3 Workflow MVP** — 9 phase state machine + code gen RG-001 + Inbox/Detail FE — E2E pass mã `FLOCK 01/HĐGK/SOL&PVL2026/01` | `7e957a7` | +| 2026-04-21 | Claude | **Phase 5 Prep** — BE rate limit (5/min login, 300/min global) + health check (/live + /ready DB probe) + Serilog file rolling 30d + HSTS prod. Scripts: deploy-iis.ps1 + backup-sql.ps1 + .gitea/workflows/deploy.yml. Docs guides (4): deployment-iis, cicd, security-checklist, runbook. FE refresh token auto interceptor (cả 2 app) với queue pattern | (sắp commit) | +| 2026-04-21 | Claude | **Phase 4 Report MVP + Docs Consolidation** — Dashboard KPI + Excel export + rules.md + architecture.md + schema-diagram.md + gotchas update 26 pitfalls | `fe7ad8e` | +| 2026-04-21 | Claude | **Phase 3 Workflow MVP** — 9 phase state machine + gen mã HĐ RG-001 | `7e957a7` | | 2026-04-21 | Claude | **Phase 2 Form Engine MVP** | `5113e4c` | | 2026-04-21 | Claude | **Phase 1.2** — CRUD Master + Permission Matrix | `54d6c9b` | -| 2026-04-21 | Claude | **Docs addition** — database-guide + flows | `49a5f57` | -| 2026-04-21 | Claude | **Phase 1 foundation** — Clean Arch + Identity + JWT + 2 FE | `702411f` | -| 2026-04-21 | Claude | **Phase 0** — scaffold + docs | `25dad7f` | +| 2026-04-21 | Claude | **Docs addition** | `49a5f57` | +| 2026-04-21 | Claude | **Phase 1 foundation** | `702411f` | +| 2026-04-21 | Claude | **Phase 0** | `25dad7f` | -Session logs: [P0](changelog/sessions/2026-04-21-1045-phase0-scaffold.md) · [P1f](changelog/sessions/2026-04-21-1100-phase1-foundation.md) · [P1.2](changelog/sessions/2026-04-21-1130-phase1-cruds-permission.md) · [P2](changelog/sessions/2026-04-21-1200-phase2-form-engine.md) · [P3](changelog/sessions/2026-04-21-1330-phase3-workflow.md) · [P4](changelog/sessions/2026-04-21-1430-phase4-report.md) +Session logs: [P0](changelog/sessions/2026-04-21-1045-phase0-scaffold.md) · [P1f](changelog/sessions/2026-04-21-1100-phase1-foundation.md) · [P1.2](changelog/sessions/2026-04-21-1130-phase1-cruds-permission.md) · [P2](changelog/sessions/2026-04-21-1200-phase2-form-engine.md) · [P3](changelog/sessions/2026-04-21-1330-phase3-workflow.md) · [P4](changelog/sessions/2026-04-21-1430-phase4-report.md) · [P5prep](changelog/sessions/2026-04-21-1530-phase5-prep.md) **Docs entry points:** -- [`rules.md`](rules.md) — coding conventions -- [`architecture.md`](architecture.md) — layered + data flow + deployment -- [`database/database-guide.md`](database/database-guide.md) + [`database/schema-diagram.md`](database/schema-diagram.md) — DB spec -- [`gotchas.md`](gotchas.md) — 26 pitfalls -- [`HANDOFF.md`](HANDOFF.md) — brief 5 phút -- [`workflow-contract.md`](workflow-contract.md) — state machine -- [`forms-spec.md`](forms-spec.md) — 8 form catalog -- [`flows/`](flows/) — 6 flow diagram +- [`rules.md`](rules.md) · [`architecture.md`](architecture.md) · [`HANDOFF.md`](HANDOFF.md) +- [`workflow-contract.md`](workflow-contract.md) · [`forms-spec.md`](forms-spec.md) +- [`database/database-guide.md`](database/database-guide.md) · [`database/schema-diagram.md`](database/schema-diagram.md) +- [`flows/`](flows/) (7 file) · [`guides/`](guides/) (4 file) · [`gotchas.md`](gotchas.md) +- [`changelog/migration-todos.md`](changelog/migration-todos.md) · [`changelog/sessions/`](changelog/sessions/) (7 file) ## 🎯 Next up -### Phase 5 — Production (T12-13, item lớn nhất còn lại) +### Phase 5 còn lại (cần Gitea URL) -- [ ] CI/CD Gitea Actions (`.gitea/workflows/deploy.yml`) deploy IIS -- [ ] `scripts/deploy-iis.ps1` stop app pool → xcopy → start -- [ ] Windows Server setup: IIS + URL Rewrite + ARR -- [ ] HTTPS cert via win-acme -- [ ] `appsettings.Production.json` + user secrets -- [ ] Rate limiting middleware -- [ ] Security audit OWASP top 10 -- [ ] Health check endpoint `/health` -- [ ] Serilog → file rolling daily retention 30d -- [ ] Runbook: restart, rollback, backup/restore -- [ ] UAT production 1 tuần +- [ ] Setup Gitea remote + push all commits +- [ ] Enable Gitea Actions runner (Windows + Ubuntu) +- [ ] Set 5 secrets trong Gitea (IIS_HOST/USER/PASSWORD/JWT_SECRET/DB_CONNECTION) +- [ ] Test CI/CD workflow lần đầu trên staging +- [ ] Windows Server setup IIS theo [`guides/deployment-iis.md`](guides/deployment-iis.md) +- [ ] HTTPS cert (win-acme Let's Encrypt) +- [ ] SQL Server prod + Task Scheduler backup +- [ ] Smoke test end-to-end prod +- [ ] UAT 1 tuần 2-3 user thật -### Polish iterations (optional — làm khi rảnh) +### Phase 5.1 Security hardening -**Phase 2 iter 2:** convert 3 .doc, field spec JSON + form builder, {{#loop}}, PDF convert, upload template UI -**Phase 3 iter 2:** SLA auto-approve job, email/in-app notification, attachment upload, RowVersion, render HĐ khi tạo +Xem [`guides/security-checklist.md`](guides/security-checklist.md). TODO: +- [ ] Security headers middleware (X-Content-Type-Options, X-Frame-Options, CSP) +- [ ] Identity Account lockout (5 fail → 15min lock) +- [ ] Password policy min 12 chars production +- [ ] IDOR check ContractsController (user không xem HĐ không liên quan) +- [ ] Dependencies scan CI (`dotnet list package --vulnerable` + `npm audit`) + +### Polish iterations + +**Phase 2 iter 2:** convert .doc, field spec JSON + form builder, {{#loop}}, PDF convert +**Phase 3 iter 2:** SLA job auto-approve, email/in-app notify, attachment upload, RowVersion **Phase 4 iter 2:** SLA overdue report, PDF HĐ export, dashboard user-specific ### Quick wins - FE Users management + Roles CRUD (test permission non-admin) - Filter Inbox theo phase FE -- FE refresh token auto interceptor +- Test refresh token flow manual (logout/login flow) ## 📊 Thông số cumulative -| | P0 | P1f | P1.2 | P2 | P3 | **P4** | -|---|---:|---:|---:|---:|---:|---:| -| BE LOC | 0 | ~400 | ~1500 | ~1900 | ~2700 | **~3100** | -| DB tables | 0 | 7 | 12 | 14 | 19 | **19** | -| API endpoints | 0 | 4 | ~20 | ~23 | ~31 | **~33** | -| FE pages | 0 | 2 | 6 | 7 | 14 | **16** | -| Docs | 10 | 13 | 14 | 24 | 26 | **30** | -| Commits | 1 | 2 | 3 | 5 | 6 | **7** (sắp) | +| | P0 | P1f | P1.2 | P2 | P3 | P4 | **P5 prep** | +|---|---:|---:|---:|---:|---:|---:|---:| +| BE LOC | 0 | ~400 | ~1500 | ~1900 | ~2700 | ~3100 | **~3300** | +| DB tables | 0 | 7 | 12 | 14 | 19 | 19 | 19 | +| API endpoints | 0 | 4 | 20 | 23 | 31 | 33 | **35** (+health) | +| Migrations | 0 | 1 | 3 | 4 | 5 | 5 | 5 | +| FE pages | 0 | 2 | 6 | 7 | 14 | 16 | 16 | +| Scripts PS | 0 | 0 | 0 | 1 (convert-doc) | 1 | 1 | **3** (+deploy-iis, backup-sql) | +| CI/CD workflow | 0 | 0 | 0 | 0 | 0 | 0 | **1** | +| Docs | 10 | 13 | 14 | 24 | 26 | 30 | **35** (+4 guides + session log) | +| Commits | 1 | 2 | 3 | 5 | 6 | 7 | **8** (sắp) | ## 🚨 Blockers / risks -- ⏳ **Gitea remote URL** — user sẽ cấp khi vào Phase 5 +- ⏳ **Gitea remote URL** — ĐANG CẦN để push + setup CI/CD +- ⚠️ **Phase 5.1 security hardening** chưa làm (headers, account lockout, IDOR check) - ⚠️ **3 file .doc chưa convert** (Phase 2 carryover) -- ⚠️ **SLA chỉ set deadline** — không auto-approve (Phase 3.2) -- ⚠️ **Không notification** email/in-app (Phase 3.2) -- ⚠️ **Permission chưa test non-admin user thật** — cần FE Users mgmt -- ⚠️ **FE refresh token** — 401 chỉ redirect logout, chưa auto-refresh +- ⚠️ **SLA không tự auto-approve** (Phase 3.2) +- ⚠️ **Email/in-app notification** chưa có +- ⚠️ **FE Users management chưa có** — khó test permission non-admin +- ⚠️ **Rate limit global 300/min/IP** — OK cho dev, cần tăng cho prod nếu nhiều user ## Credentials + URLs @@ -88,6 +97,6 @@ Session logs: [P0](changelog/sessions/2026-04-21-1045-phase0-scaffold.md) · [P1 admin@solutionerp.local / Admin@123456 ``` -- API: http://localhost:5443 — Swagger `/swagger` -- Admin FE: http://localhost:8082 — Dashboard → **`/dashboard`** (KPI mới), **`/contracts`** (list), **`/reports`** (export), **`/master/*`** (NCC/DA/PB), **`/forms`**, **`/system/permissions`** -- User FE: http://localhost:8080 — Inbox → **`/inbox`**, **`/contracts/new`**, **`/my-contracts`** +- API: http://localhost:5443 — Swagger `/swagger` (dev only) — Health `/health/live` + `/health/ready` +- Admin FE: http://localhost:8082 — `/dashboard`, `/contracts`, `/reports`, `/master/*`, `/forms`, `/system/permissions` +- User FE: http://localhost:8080 — `/inbox`, `/contracts/new`, `/my-contracts` diff --git a/docs/changelog/sessions/2026-04-21-1530-phase5-prep.md b/docs/changelog/sessions/2026-04-21-1530-phase5-prep.md new file mode 100644 index 0000000..25428d6 --- /dev/null +++ b/docs/changelog/sessions/2026-04-21-1530-phase5-prep.md @@ -0,0 +1,121 @@ +# Session 2026-04-21 15:30 — Phase 5 Prep (Production infra + docs + refresh token) + +**Dev:** Claude (Opus 4.7) +**Duration:** ~1h +**Base commit:** `fe7ad8e` + +## Làm được + +### Chunk O — BE production infra + +- Packages: `Serilog.Sinks.File`, `Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore` (RateLimiting built-in .NET 10) +- `appsettings.Production.json` template với placeholders `__SET_VIA_SECRETS__`, `AllowedOrigins`, Serilog File sink rolling daily retention 30d, RateLimit config +- `appsettings.Development.json` + `appsettings.json` thêm Serilog `WriteTo.Console` +- `Program.cs` rewrite: + - Serilog `.ReadFrom.Configuration` (thay hardcode Console) → prod file rolling, dev console + - Rate limiter: policy `auth-login` (5/min/IP) + global (300/min/IP) + - Health checks: `/health/live` liveness + `/health/ready` DB probe + - HSTS production (1 year) + - CORS origins từ config (`AllowedOrigins`) +- `AuthController.Login` gắn `[EnableRateLimiting("auth-login")]` + +### Chunk P — Deploy scripts + CI/CD template + +- `scripts/deploy-iis.ps1` — stop pool → backup → clean+extract artifact → start pool → health check loop với rollback instruction nếu fail +- `scripts/backup-sql.ps1` — daily BACKUP DATABASE với COMPRESSION+CHECKSUM + retention 30 ngày .bak +- `.gitea/workflows/deploy.yml` — 4 job: build BE (Windows) + build 2 FE (Ubuntu) + deploy IIS qua WinRM PSSession. Pin Node `.nvmrc` (gotcha #5). Secrets IIS_HOST/USER/PASSWORD/JWT_SECRET/DB_CONNECTION + +### Chunk Q — Runbook docs (4 file mới trong `docs/guides/`) + +- `deployment-iis.md` — prerequisites + setup lần đầu (app pool, site, HTTPS, secrets) + deploy hàng ngày + troubleshooting + SPA web.config sample +- `cicd.md` — pipeline overview, secrets, runner setup Windows+Ubuntu, branch strategy, build optimizations, common issues +- `security-checklist.md` — OWASP top 10 2021 mapping với status hiện tại + pre go-live checklist + incident response +- `runbook.md` — daily ops (health/logs), restart/rollback, DB backup/restore/migration revert, user management, monitoring, deployment checklist + +### Chunk R — FE refresh token auto + +- `fe-admin/src/lib/api.ts` + `fe-user/src/lib/api.ts` rewrite: + - Thêm `REFRESH_KEY` localStorage key + - Axios response interceptor: 401 → thử POST `/auth/refresh` → retry request gốc với token mới + - **Queue pattern** để nhiều request song song chỉ 1 refresh call chạy, các request khác chờ token mới + - Skip retry cho `/auth/login` và `/auth/refresh` (tránh infinite loop) + - `_retry` flag trên original config để không retry 2 lần +- `contexts/AuthContext.tsx` (cả 2 app): lưu+xóa `REFRESH_KEY` trong login/logout + +## E2E verified + +```bash +# Health check +GET /health/live → 200 Healthy +GET /health/ready → 200 Healthy (DB probe pass) + +# Rate limit +POST /api/auth/login x 7 lần /phút/IP + → #1-5: HTTP 400 (invalid cred) — còn trong limit + → #6-7: HTTP 429 Too Many Requests ✅ + +# TS check fe-admin + fe-user → pass +# Build BE → pass +``` + +## Docs consolidation + +- Docs: 4 file guides MỚI (`deployment-iis`, `cicd`, `security-checklist`, `runbook`) +- `Program.cs`: rewrite với prod infra +- `appsettings.Production.json` MỚI +- `appsettings.json` + `Development.json` update Serilog Console sink +- `scripts/deploy-iis.ps1` + `backup-sql.ps1` MỚI +- `.gitea/workflows/deploy.yml` MỚI +- fe-admin + fe-user: `lib/api.ts` refresh token queue pattern, `contexts/AuthContext.tsx` update + +## Bug gặp + fix + +| Bug | Fix | +|---|---| +| Write Program.cs không persist lần đầu (Dropbox?) | Verify qua grep → re-Write full file | +| `Microsoft.AspNetCore.RateLimiting` không có stable package | Dùng built-in `.AddRateLimiter()` ASP.NET Core 7+ không cần package | + +## Handoff cho session tiếp theo + +### Phase 5 còn cần (với Gitea URL) + +- [ ] Setup Gitea remote + push code +- [ ] Enable Gitea Actions runner (Windows + Ubuntu) +- [ ] Set 5 secrets trong Gitea +- [ ] Test CI/CD workflow lần đầu trên staging +- [ ] Windows Server setup IIS theo `deployment-iis.md` +- [ ] HTTPS cert (win-acme) +- [ ] SQL Server prod + backup task schedule +- [ ] Smoke test end-to-end prod +- [ ] UAT 1 tuần với 2-3 user thật + +### Security hardening (Phase 5.1) + +- [ ] Security headers middleware (X-Content-Type-Options, X-Frame-Options, CSP) +- [ ] Account lockout (Identity options — đã có spec ở security-checklist.md) +- [ ] Password policy production min 12 chars +- [ ] IDOR check trong ContractsController (user không xem được HĐ không liên quan) +- [ ] Dependencies scan vào CI (`dotnet list package --vulnerable`, `npm audit`) + +### Quick wins + +- [ ] FE Users management + Roles CRUD (để test permission với non-admin user) +- [ ] Filter Inbox theo phase FE +- [ ] Dashboard user-specific + +### Blocker + +- ⏳ **Gitea URL** — user sẽ cấp + +## Thông số cumulative + +| | Phase 3 | Phase 4 | **Phase 5 prep** | +|---|---:|---:|---:| +| BE LOC | ~2700 | ~3100 | **~3300** (thêm rate limit + health check wiring) | +| DB tables | 19 | 19 | 19 | +| API endpoints | ~31 | ~33 | **~35** (+ /health/live + /health/ready) | +| FE pages | 14 | 16 | 16 | +| Scripts PS | 2 (parse_forms, parse_workflow) | +1 (convert-doc) | **+3** (deploy-iis, backup-sql, existing) = 6 | +| CI/CD workflow | 0 | 0 | **1** (.gitea/workflows/deploy.yml) | +| Docs files | 26 | 30 | **35** (+4 guides + session log) | +| Commits | 6 | 7 | **8** (sắp) | diff --git a/docs/guides/cicd.md b/docs/guides/cicd.md new file mode 100644 index 0000000..fafe067 --- /dev/null +++ b/docs/guides/cicd.md @@ -0,0 +1,123 @@ +# CI/CD — Gitea Actions + +> Automation pipeline từ push → build → test → deploy. Chạy trên Windows self-hosted runner (cần cho WinRM + Word COM). + +## 1. Pipeline overview + +``` +Push main + ├─ build-backend (Windows runner) + │ └─ dotnet restore/build/publish → artifact "backend-api" + ├─ build-fe-admin (Ubuntu runner) + │ └─ npm ci + npm run build → artifact "fe-admin-dist" + ├─ build-fe-user (Ubuntu runner) + │ └─ npm ci + npm run build → artifact "fe-user-dist" + └─ deploy-iis (Windows runner) — chỉ khi ref = main + ├─ download 3 artifact + ├─ Compress-Archive → 3 zip + ├─ Copy-Item via PSSession WinRM → target server + └─ Invoke-Command → scripts/deploy-iis.ps1 +``` + +File spec: `.gitea/workflows/deploy.yml` (đã có). + +## 2. Secrets setup + +Trong Gitea repo → Settings → Actions → Secrets, thêm: + +| Secret | Ví dụ | Mô tả | +|---|---|---| +| `IIS_HOST` | `10.0.0.100` | Hostname/IP target IIS server | +| `IIS_USER` | `solution-erp\deploy` | Windows user có admin + WinRM enabled | +| `IIS_PASSWORD` | `...` | Password tương ứng | +| `JWT_SECRET` | 64+ random chars | Pass vào user-secrets khi deploy | +| `DB_CONNECTION` | `Server=.;Database=SolutionErp;...` | ConnectionString production | + +**KHÔNG** echo secret ra log (Gitea auto-mask nhưng vẫn cẩn thận). + +## 3. Runner setup + +### Windows self-hosted runner (cho build BE + deploy) + +Trên Windows VM: +```powershell +# Download Gitea Act runner +# https://gitea.com/gitea/act_runner/releases +.\act_runner register --instance https://gitea.yourcorp.local --token +.\act_runner daemon +``` + +Prereqs trên runner: +- .NET 10 SDK +- Git 2.40+ +- PowerShell 7+ +- Node 20 (cho test/build nếu cần BE test với FE proxy) +- WinRM client: `winrm quickconfig` +- Test connectivity: `Test-NetConnection $env:IIS_HOST -Port 5985` + +### Ubuntu runner (cho build FE) + +```bash +# Docker runner +docker run -d --name gitea-runner \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e GITEA_INSTANCE_URL=https://gitea.yourcorp.local \ + -e GITEA_RUNNER_REGISTRATION_TOKEN= \ + gitea/act_runner:latest +``` + +Prereqs: +- Node 20 (qua `actions/setup-node` — KHÔNG dùng latest, xem [`gotchas.md #5`](../gotchas.md)) +- npm cache + +## 4. Branch strategy + trigger + +| Branch | Trigger | Action | +|---|---|---| +| `main` | push | full build + deploy production | +| `staging` | push | full build + deploy staging (nếu có) | +| `feature/*` | push | chỉ build + test, không deploy | +| PR merge | merge_request | build + test, optional auto-merge nếu pass | +| Manual | `workflow_dispatch` | re-deploy current main | + +## 5. Build optimizations + +- Node cache qua `actions/setup-node@v4` với `cache: 'npm'` + `cache-dependency-path` +- NuGet cache qua `actions/cache@v4` path `~/.nuget/packages` (Windows: `%USERPROFILE%\.nuget\packages`) +- Parallel build 3 job FE/BE độc lập + +## 6. Pre-commit checks (Phase 5.1 — khi có thời gian) + +Thêm job `verify` chạy trước deploy: +- `dotnet format --verify-no-changes` +- `dotnet test` (khi có unit test) +- `npm run lint` +- `npm run build` (both FE) + +## 7. Rollback qua CI/CD + +1. Gitea repo → Actions → tìm run cũ đã deploy thành công +2. Re-run job đó (re-runner download artifact cũ + deploy lại) + +Hoặc revert git: +```bash +git revert +git push +``` + +## 8. Common CI/CD issues + +| Problem | Fix | +|---|---| +| Node build fail trên CI nhưng OK local | Pin Node 20 qua `.nvmrc` (gotcha #5) | +| WinRM timeout | Check firewall port 5985/5986, increase `Test-NetConnection -TimeoutSec` | +| NuGet restore slow | Add cache action | +| Artifact size > 100MB | Exclude `bin/`, `obj/`, `node_modules/` trong `dotnet publish` | +| Deploy không thấy file mới | App pool chưa restart — xem log `scripts/deploy-iis.ps1` xem Stop+Start có chạy không | + +## 9. Liên quan + +- [`deployment-iis.md`](deployment-iis.md) — IIS setup chi tiết +- [`runbook.md`](runbook.md) — operations +- `.gitea/workflows/deploy.yml` — workflow YAML +- `scripts/deploy-iis.ps1` — deploy script diff --git a/docs/guides/deployment-iis.md b/docs/guides/deployment-iis.md new file mode 100644 index 0000000..5069794 --- /dev/null +++ b/docs/guides/deployment-iis.md @@ -0,0 +1,216 @@ +# Deployment — Windows Server + IIS + +> Step-by-step setup lần đầu + deploy hàng ngày. Test với Windows Server 2019/2022 + IIS 10. + +## 1. Prerequisites trên target server + +### OS + IIS +- Windows Server 2019 / 2022 (hoặc Windows 10/11 Pro cho staging) +- IIS với features: **Static Content**, **HTTP Redirection**, **Application Initialization**, **WebSocket Protocol**, **Management Console**, **Windows Auth** (optional) +- **URL Rewrite Module 2.1+**: https://www.iis.net/downloads/microsoft/url-rewrite +- **Application Request Routing (ARR) 3.0+** (nếu dùng reverse proxy): https://www.iis.net/downloads/microsoft/application-request-routing + +### .NET 10 Hosting Bundle +``` +https://dotnet.microsoft.com/en-us/download/dotnet/10.0 +→ .NET 10 Hosting Bundle (không phải SDK, runtime + ASP.NET Core Module) +``` +Sau khi cài → restart IIS: `iisreset` trong cmd elevated. + +### SQL Server +- SQL Server 2019+ Express / Standard / Enterprise +- Tạo DB `SolutionErp` + SQL user `solutionerp_app` với db_owner +- Bật **Named Pipes** hoặc **TCP/IP** protocol (SQL Server Configuration Manager) + +### WinRM (cho CI/CD deploy từ xa) +```powershell +# Run as admin trên target server +Enable-PSRemoting -Force +Set-Item WSMan:\localhost\Client\TrustedHosts -Value "*" -Force +winrm quickconfig -Transport:HTTPS +``` + +## 2. Setup lần đầu + +### 2.1 Tạo app pool + +```powershell +Import-Module WebAdministration +New-WebAppPool -Name "SolutionErpApi" +Set-ItemProperty IIS:\AppPools\SolutionErpApi -Name managedRuntimeVersion -Value "" # .NET CORE (no CLR) +Set-ItemProperty IIS:\AppPools\SolutionErpApi -Name startMode -Value "AlwaysRunning" +Set-ItemProperty IIS:\AppPools\SolutionErpApi -Name processModel.identityType -Value "ApplicationPoolIdentity" +``` + +### 2.2 Tạo site (3 site hoặc 1 site với 3 path) + +**Recommended:** 3 site riêng để quản lý binding dễ: + +```powershell +# Api +New-WebSite -Name "SolutionErp-Api" -Port 443 -HostHeader "api.solutionerp.local" ` + -PhysicalPath "C:\inetpub\solution-erp\api" -ApplicationPool "SolutionErpApi" -Ssl + +# Admin FE +New-WebSite -Name "SolutionErp-Admin" -Port 443 -HostHeader "admin.solutionerp.local" ` + -PhysicalPath "C:\inetpub\solution-erp\fe-admin" -Ssl + +# User FE +New-WebSite -Name "SolutionErp-User" -Port 443 -HostHeader "app.solutionerp.local" ` + -PhysicalPath "C:\inetpub\solution-erp\fe-user" -Ssl +``` + +### 2.3 HTTPS certificate + +#### Option A — win-acme (Let's Encrypt free) +```powershell +# Download từ https://www.win-acme.com/ +wacs.exe +# Menu: N (new cert) → chọn sites → auto-renew via scheduled task +``` + +#### Option B — Self-signed (dev/staging only) +```powershell +$cert = New-SelfSignedCertificate -DnsName "api.solutionerp.local", "admin.solutionerp.local", "app.solutionerp.local" ` + -CertStoreLocation "cert:\LocalMachine\My" -NotAfter (Get-Date).AddYears(5) +$bytes = $cert.GetCertHash() +``` + +### 2.4 Secrets cho Api + +**KHÔNG commit `appsettings.Production.json` với secret thật.** Dùng user-secrets: + +```powershell +# Trên target server, với identity app pool +cd C:\inetpub\solution-erp\api +dotnet user-secrets set "Jwt:Secret" "__RANDOM_64_CHAR_STRING__" +dotnet user-secrets set "ConnectionStrings:Default" "Server=.;Database=SolutionErp;User Id=solutionerp_app;Password=..." +``` + +Hoặc dùng env vars (set qua IIS app pool advanced settings): +- `Jwt__Secret` = `...` +- `ConnectionStrings__Default` = `...` + +### 2.5 FE build — adjust Vite base URL + API proxy + +`fe-admin/vite.config.ts` production build không proxy, FE gọi trực tiếp `https://api.solutionerp.local`. Thêm env: + +``` +# fe-admin/.env.production +VITE_API_BASE_URL=https://api.solutionerp.local +``` + +Update `src/lib/api.ts` sử dụng `import.meta.env.VITE_API_BASE_URL ?? '/api'`. + +### 2.6 Init DB + +```powershell +# Run trên app server với .NET SDK installed (KHÔNG chỉ runtime) +cd C:\Deploy\staging +dotnet ef database update --project src\Backend\SolutionErp.Infrastructure --startup-project src\Backend\SolutionErp.Api +``` + +Hoặc tự động khi app khởi động lần đầu (đã có trong `DbInitializer`). + +## 3. Deploy hàng ngày + +### 3.1 Qua CI/CD (Gitea Actions) + +Push code vào `main` → `.gitea/workflows/deploy.yml` auto: +1. Build BE + 2 FE +2. Upload artifact +3. WinRM tới IIS host +4. Run `scripts/deploy-iis.ps1` + +### 3.2 Manual deploy + +```powershell +# Trên dev machine +dotnet publish src/Backend/SolutionErp.Api --configuration Release -o .\publish\api +cd fe-admin && npm ci && npm run build # dist/ +cd fe-user && npm ci && npm run build # dist/ + +Compress-Archive .\publish\api\* api.zip +Compress-Archive .\fe-admin\dist\* fe-admin.zip +Compress-Archive .\fe-user\dist\* fe-user.zip + +# Copy lên target server +Copy-Item api.zip, fe-admin.zip, fe-user.zip -Destination \\server\C$\Deploy\ -Force + +# Trên target server +.\scripts\deploy-iis.ps1 -Artifact C:\Deploy\api.zip -Site SolutionErpApi +Expand-Archive C:\Deploy\fe-admin.zip C:\inetpub\solution-erp\fe-admin -Force +Expand-Archive C:\Deploy\fe-user.zip C:\inetpub\solution-erp\fe-user -Force +``` + +### 3.3 Rollback + +```powershell +# Deploy script tự backup vào C:\inetpub\solution-erp\backups\api-{timestamp} +Stop-WebAppPool SolutionErpApi +Copy-Item C:\inetpub\solution-erp\backups\api-20260421-0930\* ` + C:\inetpub\solution-erp\api\ -Recurse -Force +Start-WebAppPool SolutionErpApi + +# Nếu migration hỏng → revert DB: +dotnet ef database update --project ... +``` + +## 4. Monitoring + +### 4.1 Health check + +- `/health/live` — liveness probe (IIS ping) +- `/health/ready` — readiness probe (DB reachable) + +### 4.2 Logs + +- Serilog → `C:\inetpub\solution-erp\api\logs\solution-erp-{yyyyMMdd}.log` +- Retention 30 ngày auto +- IIS request log: `C:\inetpub\logs\LogFiles\` +- Application event log: `eventvwr` → Windows Logs → Application → Source: IIS Express / .NET Runtime + +### 4.3 SQL backup + +Task Scheduler trigger `scripts\backup-sql.ps1` daily 2:00 AM. Retention 30 ngày `.bak` files. + +## 5. Troubleshooting + +| Triệu chứng | Check | +|---|---| +| HTTP 500.30 ANCM startup | event log `Application`, logs folder có được tạo không, permission app pool identity | +| HTTP 502.5 ANCM Process failure | .NET 10 Hosting Bundle đã cài chưa, app pool CLR = "" | +| 500 khi login | secrets config đúng chưa (Jwt__Secret + ConnectionStrings__Default) | +| CORS fail | `appsettings.Production.json` → `AllowedOrigins` match domain FE | +| Slow query | check index (xem `database/schema-diagram.md §4`) | +| App pool crash loop | disable rapid fail protection tạm thời khi debug: `Set-ItemProperty IIS:\AppPools\SolutionErpApi -Name failure.rapidFailProtection -Value false` | +| FE 404 routes (vd /contracts/123) | IIS URL Rewrite config SPA fallback — tạo `web.config` FE folder với rule rewrite `.*` → `index.html` | + +### web.config cho SPA FE (sample) + +```xml + + + + + + + + + + + + + + + + + +``` + +## 6. Liên quan + +- [`cicd.md`](cicd.md) — Gitea Actions workflow chi tiết +- [`security-checklist.md`](security-checklist.md) — OWASP top 10 +- [`runbook.md`](runbook.md) — operations (restart, restore, common tasks) +- [`../database/database-guide.md`](../database/database-guide.md) — DB backup/restore chi tiết diff --git a/docs/guides/runbook.md b/docs/guides/runbook.md new file mode 100644 index 0000000..9a61b51 --- /dev/null +++ b/docs/guides/runbook.md @@ -0,0 +1,195 @@ +# Runbook — Operations + +> Tác vụ vận hành thường gặp. Copy-paste command khi cần. + +## 1. Daily operations + +### 1.1 Check health +```powershell +Invoke-WebRequest https://api.solutionerp.local/health/ready -SkipCertificateCheck +# → Status 200 "Healthy" +``` + +### 1.2 Check logs +```powershell +# Tail log hôm nay +Get-Content "C:\inetpub\solution-erp\api\logs\solution-erp-$(Get-Date -Format 'yyyyMMdd').log" -Tail 50 -Wait + +# Grep error +Select-String -Path "C:\inetpub\solution-erp\api\logs\*.log" -Pattern "ERR|FTL" -Context 2 +``` + +### 1.3 Check recent failed logins +```sql +-- Nếu có audit log (Phase 5.1). Hiện chỉ có ContractApprovals → check Serilog file. +``` + +## 2. Restart / rollback + +### 2.1 Restart Api app pool +```powershell +Restart-WebAppPool -Name SolutionErpApi +``` + +### 2.2 Restart toàn bộ IIS (nặng, chỉ khi cần) +```powershell +iisreset /noforce +``` + +### 2.3 Rollback deploy +```powershell +# Deploy script auto-backup vào C:\inetpub\solution-erp\backups\api-{timestamp} +Stop-WebAppPool SolutionErpApi +$latest = Get-ChildItem "C:\inetpub\solution-erp\backups" | Sort-Object Name -Descending | Select-Object -First 1 +Copy-Item "$($latest.FullName)\*" -Destination "C:\inetpub\solution-erp\api\" -Recurse -Force +Start-WebAppPool SolutionErpApi +Invoke-WebRequest https://api.solutionerp.local/health/ready -SkipCertificateCheck # verify +``` + +## 3. Database + +### 3.1 Manual backup (ngoài daily job) +```powershell +.\scripts\backup-sql.ps1 -Server "." -Database "SolutionErp" -BackupDir "D:\Backups\SolutionErp-manual" +``` + +### 3.2 Restore từ backup +```sql +-- WARNING: Destructive. Stop app trước. +USE master; +ALTER DATABASE SolutionErp SET SINGLE_USER WITH ROLLBACK IMMEDIATE; +RESTORE DATABASE SolutionErp +FROM DISK = N'D:\Backups\SolutionErp\SolutionErp_20260421-020000.bak' +WITH REPLACE, RECOVERY; +ALTER DATABASE SolutionErp SET MULTI_USER; +``` + +### 3.3 Rollback migration +```powershell +# List migrations đã apply +cd C:\Deploy\staging # nơi có .NET SDK +dotnet ef migrations list --project src\Backend\SolutionErp.Infrastructure --startup-project src\Backend\SolutionErp.Api + +# Rollback về migration cụ thể +dotnet ef database update --project ... --startup-project ... +``` + +### 3.4 Clear test data (dev) +```sql +-- Clear toàn bộ Contracts + related (giữ master data) +DELETE FROM ContractApprovals; +DELETE FROM ContractComments; +DELETE FROM ContractAttachments; +DELETE FROM Contracts; +DELETE FROM ContractCodeSequences; +``` + +## 4. User management + +### 4.1 Tạo user mới +```sql +-- Phase 5.1 có FE, hiện manual qua SQL (không khuyến khích — password hash phải đúng format) +-- Recommend: tạo qua UserManager trong 1 script .NET, hoặc API `POST /api/users` (chưa implement) +``` + +### 4.2 Reset password admin (emergency) +```powershell +# Run script one-off trên server +cd C:\inetpub\solution-erp\api +dotnet SolutionErp.Api.dll --reset-password admin@solutionerp.local NewPassword@2026 +# (Feature chưa có — Phase 5.1) +``` + +Temporary workaround: update `PasswordHash` qua Identity `UserManager` trong code, redeploy. + +### 4.3 Unlock account bị lock +```sql +UPDATE Users SET LockoutEnd = NULL, AccessFailedCount = 0 WHERE Email = 'user@example.com'; +``` + +### 4.4 Disable user +```sql +UPDATE Users SET IsActive = 0 WHERE Email = 'user@example.com'; +-- Note: JWT hiện tại vẫn valid tới hết expiry (1h) — Phase 5.1 cần check IsActive trong middleware +``` + +## 5. Monitoring + incident + +### 5.1 High CPU app pool +```powershell +# Identify worker process +Get-Process w3wp | Select-Object Id, CPU, WorkingSet64, StartTime +# Kill nếu stuck (IIS tự restart) +Stop-Process -Id -Force +``` + +### 5.2 Out of disk +```powershell +# Check logs folder +Get-ChildItem "C:\inetpub\solution-erp\api\logs" | Sort-Object LastWriteTime | Select -First 20 +# Delete logs cũ hơn 30 ngày (đã config retention nhưng check) +Get-ChildItem "C:\inetpub\solution-erp\api\logs" -Filter "*.log" | + Where-Object LastWriteTime -lt (Get-Date).AddDays(-30) | Remove-Item +``` + +### 5.3 Suspected brute-force attack +```powershell +# Grep 401 qua IIS log +Get-Content C:\inetpub\logs\LogFiles\W3SVC1\*.log -Tail 5000 | + Select-String " 401 " | Group-Object { ($_ -split ' ')[8] } | + Sort-Object Count -Descending | Select -First 10 +# Nếu thấy IP suspicious → block IIS IP Restriction hoặc firewall rule +``` + +### 5.4 DB connection pool exhausted +```sql +-- Check active connections +SELECT DB_NAME(dbid) AS DB, COUNT(*) AS Connections, loginame AS Login +FROM sys.sysprocesses +WHERE dbid > 0 +GROUP BY dbid, loginame +ORDER BY 2 DESC; + +-- Kill connection cụ thể nếu stuck +KILL ; +``` + +## 6. Deployment checklist + +Trước khi deploy: +- [ ] Backup DB (manual nếu chưa auto chạy) +- [ ] Note commit SHA đang live +- [ ] Check CI/CD passed all checks +- [ ] Notify team trong Slack/Teams (nếu có downtime) + +Sau deploy: +- [ ] Health check `/health/ready` → 200 +- [ ] Smoke test: login + list HĐ + export Excel +- [ ] Check log 5 phút đầu không có ERR +- [ ] Monitor CPU/RAM 15 phút + +## 7. Common "gotcha" vận hành + +| Symptom | Fix | +|---|---| +| App pool crash rapid fail sau deploy | Disable temp: `Set-ItemProperty IIS:\AppPools\SolutionErpApi -Name failure.rapidFailProtection -Value false` — debug log → enable lại | +| User bị logout mass sau deploy | Check Jwt:Secret có đổi không — rotate secret → buộc mọi user login lại (expected nếu intentional) | +| Migration fail "connection string" | Check user secrets / env var chưa set trong app pool advanced settings | +| FE trắng trang | F12 console check path — thường do `base` trong vite.config.ts khác env, hoặc missing web.config SPA rewrite | +| Export Excel 500 | Check `wwwroot/templates` có đủ 5 file .docx/.xlsx không — ClosedXML fail khi template missing | + +## 8. Escalation contacts + +| Role | Name | Contact | +|---|---|---| +| Dev lead | pqhuy@solutions.local | pqhuy1987@gmail.com | +| DBA | TBD | — | +| On-call 24/7 | TBD | — | + +## 9. Liên quan + +- [`deployment-iis.md`](deployment-iis.md) — setup chi tiết +- [`cicd.md`](cicd.md) — CI/CD pipeline +- [`security-checklist.md`](security-checklist.md) — incident response +- [`../gotchas.md`](../gotchas.md) — bẫy dev + ops +- [`../database/database-guide.md`](../database/database-guide.md) — backup/restore detail diff --git a/docs/guides/security-checklist.md b/docs/guides/security-checklist.md new file mode 100644 index 0000000..2ec9f88 --- /dev/null +++ b/docs/guides/security-checklist.md @@ -0,0 +1,147 @@ +# Security Checklist — SOLUTION_ERP + +> OWASP Top 10 2021 + best practices production. Review trước Phase 5 go-live. + +## Current status (sau Phase 5 prep) + +| Item | Status | Nơi kiểm | +|---|---|---| +| HTTPS enforce (HSTS 365d) | ✅ | `Program.cs` → `app.UseHsts()` production | +| JWT expiry 1h + refresh 7d | ✅ | `appsettings.json` Jwt section | +| Password hash PBKDF2 | ✅ | ASP.NET Identity default | +| Rate limit `/auth/login` 5/min/IP | ✅ | `Program.cs` `[EnableRateLimiting("auth-login")]` | +| Rate limit global 300/min/IP | ✅ | `Program.cs` `GlobalLimiter` | +| CORS whitelist 2 origin prod | ✅ | `appsettings.Production.json` `AllowedOrigins` | +| JWT secret from user-secrets prod | 🔲 | Cần set khi deploy — xem [`deployment-iis.md §2.4`](deployment-iis.md) | +| DB connection user least privilege | 🔲 | `solutionerp_app` cần chỉ `db_owner` trên DB chính, không sysadmin | +| Audit log writes | 🟡 | Có `ContractApprovals` cho workflow, chưa có global audit | +| SQL injection via EF parameterized | ✅ | EF Core mặc định parameterize | +| Input validation | ✅ | FluentValidation mọi command | +| XSS protection | ⚠️ | React auto-escape OK, nhưng TextArea notes có thể render raw nếu dev viết `dangerouslySetInnerHTML` | +| CSRF protection | 🔲 | JWT localStorage + sameOrigin — không vulnerable CSRF theo classic (không dùng cookie) | +| Dependencies scan | 🔲 | Chưa có — thêm `dotnet list package --vulnerable` + `npm audit` vào CI | + +## OWASP Top 10 2021 mapping + +### A01:2021 — Broken Access Control + +✅ **Guard:** `[Authorize]` default trên mọi controller, policy `{Menu}.{Action}` cho granular check. +✅ **FE guard:** `` + `usePermission()` — nhưng BE là source of truth (403 từ BE nếu FE bypass). + +**Check trước go-live:** +- [ ] Test user role Drafter → không access được endpoint `/api/permissions`, `/api/users` +- [ ] Test IDOR: user A không truy cập được HĐ user B qua `/api/contracts/{id}` — **HIỆN CHƯA CHECK**. Phase 5 cần thêm check "user tham gia HĐ" (DrafterUserId hoặc có role giữ phase hiện tại). + +### A02:2021 — Cryptographic Failures + +✅ HTTPS + HSTS production. +✅ JWT HS256 với secret 64+ chars (random). +🔲 Password policy min 8 chars + mixed case + digit + symbol. Prod strong password tối thiểu 12. + +### A03:2021 — Injection + +✅ EF Core parameterized queries (no raw SQL). +✅ Input validation FluentValidation. +⚠️ File upload: chưa implement upload endpoint; khi làm phải validate MIME + magic bytes + path traversal. + +### A04:2021 — Insecure Design + +✅ Workflow state machine với explicit adjacency — không cho skip phase bypass rule. +✅ Audit trail qua `ContractApprovals`. +🔲 Business rule: ai được tạo HĐ? Hiện default Drafter role, Phase 5 nên limit theo dept. + +### A05:2021 — Security Misconfiguration + +✅ `appsettings.Production.json` template có placeholder `__SET_VIA_USER_SECRETS_OR_ENV__` — không có secret thật commit. +✅ `appsettings.Development.json` với dev-only secret. +🔲 IIS hardening: disable server banner header, disable X-Powered-By. +🔲 Swagger chỉ bật dev. + +**Thêm vào web.config hoặc Program.cs:** +```csharp +app.Use(async (ctx, next) => +{ + ctx.Response.Headers.Remove("Server"); + ctx.Response.Headers.Remove("X-Powered-By"); + ctx.Response.Headers["X-Content-Type-Options"] = "nosniff"; + ctx.Response.Headers["X-Frame-Options"] = "DENY"; + ctx.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; + ctx.Response.Headers["Content-Security-Policy"] = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'"; + await next(); +}); +``` + +### A06:2021 — Vulnerable Components + +🔲 CI thêm `dotnet list package --vulnerable --include-transitive` +🔲 CI thêm `npm audit --audit-level=high` +🔲 Monthly update: patch minor versions, major khi LTS cần + +### A07:2021 — Authentication Failures + +✅ Rate limit login 5/min/IP. +✅ PBKDF2 hash. +🔲 Account lockout sau N lần sai password (config `UserLockout` trong Identity — chưa bật). +🔲 MFA — Phase 6+ nếu cần. + +**Bật account lockout:** +```csharp +services.AddIdentityCore(options => +{ + // ... + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.AllowedForNewUsers = true; +}); +``` + +### A08:2021 — Software + Data Integrity Failures + +✅ EF migrations versioned trong source. +✅ Git commits signed (optional — set `user.signingkey`). +🔲 CI artifact SHA256 verify trước deploy. + +### A09:2021 — Logging + Monitoring Failures + +✅ Serilog structured → file rolling daily retention 30d prod. +✅ Request log qua `UseSerilogRequestLogging`. +🔲 Alert khi có `/api/auth/login` fail >10/min/IP (SIEM hoặc manual grep log). +🔲 Alert khi `/api/contracts/{id}/transitions` fail role guard >5/min (suspicious activity). + +### A10:2021 — SSRF + +Không áp dụng — app không fetch URL do user cung cấp. + +## Pre go-live checklist + +- [ ] `JWT_SECRET` production ≥ 64 random chars +- [ ] Connection string dùng SQL user `solutionerp_app` với password mạnh +- [ ] `appsettings.Production.json` đã set `AllowedOrigins` chính xác +- [ ] HTTPS cert valid + auto-renew (win-acme scheduled task) +- [ ] HSTS preload list (optional) +- [ ] Security headers middleware added +- [ ] Account lockout enabled (5 fail → lock 15min) +- [ ] Password policy min 12 chars prod +- [ ] Backup SQL chạy (test restore) +- [ ] Log retention 30d verified +- [ ] Rate limit test: flood 10 req → 429 từ request thứ 6 +- [ ] IDOR check: user Drafter A không xem được HĐ tôi của user B +- [ ] CSP header prevent inline script +- [ ] Run OWASP ZAP scan → no critical finding +- [ ] Dependencies: `dotnet list package --vulnerable` + `npm audit` → clean +- [ ] Admin account mặc định `admin@solutionerp.local` — đổi password production hoặc disable + +## Incident response + +**Suspected compromise:** +1. Rotate JWT secret → mọi user bị logout +2. Disable account lockout temporarily để admin login được +3. Check `ContractApprovals` last 24h cho activity bất thường +4. Restore SQL backup nếu có data corruption +5. Report CERT team + +## Liên quan + +- [`deployment-iis.md`](deployment-iis.md) — IIS hardening +- [`runbook.md`](runbook.md) — incident response detail +- [`../gotchas.md`](../gotchas.md) — pitfall đã gặp diff --git a/fe-admin/src/contexts/AuthContext.tsx b/fe-admin/src/contexts/AuthContext.tsx index 0009e3f..def3341 100644 --- a/fe-admin/src/contexts/AuthContext.tsx +++ b/fe-admin/src/contexts/AuthContext.tsx @@ -1,5 +1,5 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' -import { api, TOKEN_KEY, USER_KEY } from '@/lib/api' +import { api, TOKEN_KEY, REFRESH_KEY, USER_KEY } from '@/lib/api' import type { AuthResponse, LoginPayload, UserInfo } from '@/types/auth' import type { MenuNode } from '@/types/menu' @@ -51,6 +51,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { async function login(payload: LoginPayload) { const res = await api.post('/auth/login', payload) localStorage.setItem(TOKEN_KEY, res.data.accessToken) + localStorage.setItem(REFRESH_KEY, res.data.refreshToken) localStorage.setItem(USER_KEY, JSON.stringify(res.data.user)) setUser(res.data.user) await loadMenu() @@ -58,6 +59,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { function logout() { localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(REFRESH_KEY) localStorage.removeItem(USER_KEY) localStorage.removeItem(MENU_KEY) setUser(null) diff --git a/fe-admin/src/lib/api.ts b/fe-admin/src/lib/api.ts index 598e4af..e7c7009 100644 --- a/fe-admin/src/lib/api.ts +++ b/fe-admin/src/lib/api.ts @@ -1,6 +1,7 @@ -import axios from 'axios' +import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios' export const TOKEN_KEY = 'solution-erp-admin-token' +export const REFRESH_KEY = 'solution-erp-admin-refresh' export const USER_KEY = 'solution-erp-admin-user' export const api = axios.create({ @@ -10,22 +11,88 @@ export const api = axios.create({ api.interceptors.request.use(config => { const token = localStorage.getItem(TOKEN_KEY) - if (token) { - config.headers.Authorization = `Bearer ${token}` - } + if (token) config.headers.Authorization = `Bearer ${token}` return config }) +// Refresh token flow: khi 401 → thử refresh → retry request gốc. Queue các request song song +// để chỉ 1 refresh call chạy, các request khác chờ token mới. +type QueueItem = { + resolve: (value: unknown) => void + reject: (reason?: unknown) => void + config: InternalAxiosRequestConfig +} + +let isRefreshing = false +let queue: QueueItem[] = [] + +function processQueue(error: unknown, token: string | null) { + queue.forEach(({ resolve, reject, config }) => { + if (error || !token) { + reject(error) + } else { + config.headers.Authorization = `Bearer ${token}` + resolve(api(config)) + } + }) + queue = [] +} + +function redirectLogin() { + localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(REFRESH_KEY) + localStorage.removeItem(USER_KEY) + if (!window.location.pathname.startsWith('/login')) { + window.location.href = '/login' + } +} + api.interceptors.response.use( response => response, - error => { - if (error.response?.status === 401) { - localStorage.removeItem(TOKEN_KEY) - localStorage.removeItem(USER_KEY) - if (!window.location.pathname.startsWith('/login')) { - window.location.href = '/login' - } + async (error: AxiosError) => { + const original = error.config as InternalAxiosRequestConfig & { _retry?: boolean } + + if (error.response?.status !== 401 || !original || original._retry) { + return Promise.reject(error) + } + + // Login/refresh endpoint 401 → không retry (tránh infinite loop) + if (original.url?.includes('/auth/login') || original.url?.includes('/auth/refresh')) { + redirectLogin() + return Promise.reject(error) + } + + original._retry = true + const refreshToken = localStorage.getItem(REFRESH_KEY) + if (!refreshToken) { + redirectLogin() + return Promise.reject(error) + } + + if (isRefreshing) { + return new Promise((resolve, reject) => { + queue.push({ resolve, reject, config: original }) + }) + } + + isRefreshing = true + try { + const res = await axios.post<{ accessToken: string; refreshToken: string }>( + '/api/auth/refresh', + { refreshToken }, + ) + const newToken = res.data.accessToken + localStorage.setItem(TOKEN_KEY, newToken) + localStorage.setItem(REFRESH_KEY, res.data.refreshToken) + processQueue(null, newToken) + original.headers.Authorization = `Bearer ${newToken}` + return api(original) + } catch (refreshErr) { + processQueue(refreshErr, null) + redirectLogin() + return Promise.reject(refreshErr) + } finally { + isRefreshing = false } - return Promise.reject(error) }, ) diff --git a/fe-user/src/contexts/AuthContext.tsx b/fe-user/src/contexts/AuthContext.tsx index d8a7033..c4c5c3a 100644 --- a/fe-user/src/contexts/AuthContext.tsx +++ b/fe-user/src/contexts/AuthContext.tsx @@ -1,5 +1,5 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' -import { api, TOKEN_KEY, USER_KEY } from '@/lib/api' +import { api, TOKEN_KEY, REFRESH_KEY, USER_KEY } from '@/lib/api' import type { AuthResponse, LoginPayload, UserInfo } from '@/types/auth' import type { MenuNode } from '@/types/menu' @@ -51,6 +51,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { async function login(payload: LoginPayload) { const res = await api.post('/auth/login', payload) localStorage.setItem(TOKEN_KEY, res.data.accessToken) + localStorage.setItem(REFRESH_KEY, res.data.refreshToken) localStorage.setItem(USER_KEY, JSON.stringify(res.data.user)) setUser(res.data.user) await loadMenu() @@ -58,6 +59,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { function logout() { localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(REFRESH_KEY) localStorage.removeItem(USER_KEY) localStorage.removeItem(MENU_KEY) setUser(null) diff --git a/fe-user/src/lib/api.ts b/fe-user/src/lib/api.ts index 2cb5dc0..18073ef 100644 --- a/fe-user/src/lib/api.ts +++ b/fe-user/src/lib/api.ts @@ -1,6 +1,7 @@ -import axios from 'axios' +import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios' export const TOKEN_KEY = 'solution-erp-user-token' +export const REFRESH_KEY = 'solution-erp-user-refresh' export const USER_KEY = 'solution-erp-user-user' export const api = axios.create({ @@ -10,22 +11,85 @@ export const api = axios.create({ api.interceptors.request.use(config => { const token = localStorage.getItem(TOKEN_KEY) - if (token) { - config.headers.Authorization = `Bearer ${token}` - } + if (token) config.headers.Authorization = `Bearer ${token}` return config }) +type QueueItem = { + resolve: (value: unknown) => void + reject: (reason?: unknown) => void + config: InternalAxiosRequestConfig +} + +let isRefreshing = false +let queue: QueueItem[] = [] + +function processQueue(error: unknown, token: string | null) { + queue.forEach(({ resolve, reject, config }) => { + if (error || !token) { + reject(error) + } else { + config.headers.Authorization = `Bearer ${token}` + resolve(api(config)) + } + }) + queue = [] +} + +function redirectLogin() { + localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(REFRESH_KEY) + localStorage.removeItem(USER_KEY) + if (!window.location.pathname.startsWith('/login')) { + window.location.href = '/login' + } +} + api.interceptors.response.use( response => response, - error => { - if (error.response?.status === 401) { - localStorage.removeItem(TOKEN_KEY) - localStorage.removeItem(USER_KEY) - if (!window.location.pathname.startsWith('/login')) { - window.location.href = '/login' - } + async (error: AxiosError) => { + const original = error.config as InternalAxiosRequestConfig & { _retry?: boolean } + + if (error.response?.status !== 401 || !original || original._retry) { + return Promise.reject(error) + } + + if (original.url?.includes('/auth/login') || original.url?.includes('/auth/refresh')) { + redirectLogin() + return Promise.reject(error) + } + + original._retry = true + const refreshToken = localStorage.getItem(REFRESH_KEY) + if (!refreshToken) { + redirectLogin() + return Promise.reject(error) + } + + if (isRefreshing) { + return new Promise((resolve, reject) => { + queue.push({ resolve, reject, config: original }) + }) + } + + isRefreshing = true + try { + const res = await axios.post<{ accessToken: string; refreshToken: string }>( + '/api/auth/refresh', + { refreshToken }, + ) + const newToken = res.data.accessToken + localStorage.setItem(TOKEN_KEY, newToken) + localStorage.setItem(REFRESH_KEY, res.data.refreshToken) + processQueue(null, newToken) + original.headers.Authorization = `Bearer ${newToken}` + return api(original) + } catch (refreshErr) { + processQueue(refreshErr, null) + redirectLogin() + return Promise.reject(refreshErr) + } finally { + isRefreshing = false } - return Promise.reject(error) }, ) diff --git a/scripts/backup-sql.ps1 b/scripts/backup-sql.ps1 new file mode 100644 index 0000000..12ea67b --- /dev/null +++ b/scripts/backup-sql.ps1 @@ -0,0 +1,55 @@ +# Backup SQL Server database SolutionErp. +# Lập lịch qua Task Scheduler: daily 2:00 AM. +# +# Usage: +# .\scripts\backup-sql.ps1 -Server "." -Database "SolutionErp" -BackupDir "D:\Backups\SolutionErp" +# +# Retention: giữ 30 ngày gần nhất (dọn file cũ hơn 30d). + +param( + [string]$Server = ".", + [string]$Database = "SolutionErp", + [string]$BackupDir = "D:\Backups\SolutionErp", + [int]$RetentionDays = 30 +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $BackupDir)) { + New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null +} + +$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$backupFile = Join-Path $BackupDir "${Database}_${timestamp}.bak" + +Write-Host "Backup $Database -> $backupFile" + +$sql = @" +BACKUP DATABASE [$Database] +TO DISK = N'$backupFile' +WITH + INIT, + COMPRESSION, + CHECKSUM, + NAME = N'$Database-Full-$timestamp', + DESCRIPTION = N'Daily full backup'; +"@ + +sqlcmd -S $Server -Q $sql -b +if ($LASTEXITCODE -ne 0) { + throw "Backup FAIL (exit code $LASTEXITCODE)" +} + +$fileSize = [math]::Round((Get-Item $backupFile).Length / 1MB, 2) +Write-Host " Size: ${fileSize} MB" + +# Retention: xoa file cu hon X ngay +Write-Host "Cleanup old backups (>$RetentionDays days)" +$cutoff = (Get-Date).AddDays(-$RetentionDays) +$oldFiles = Get-ChildItem -Path $BackupDir -Filter "*.bak" | Where-Object { $_.LastWriteTime -lt $cutoff } +foreach ($f in $oldFiles) { + Write-Host " Remove $($f.Name)" + Remove-Item $f.FullName -Force +} + +Write-Host "`nBackup SUCCESS" -ForegroundColor Green diff --git a/scripts/deploy-iis.ps1 b/scripts/deploy-iis.ps1 new file mode 100644 index 0000000..640ce1f --- /dev/null +++ b/scripts/deploy-iis.ps1 @@ -0,0 +1,99 @@ +# Deploy SOLUTION_ERP lên IIS Windows Server. +# Chạy trên target server với admin privilege. +# +# Usage: +# .\scripts\deploy-iis.ps1 -Artifact "C:\Deploy\solution-erp-20260421.zip" -Site "SolutionErpApi" +# +# Workflow: +# 1. Stop app pool +# 2. Backup current wwwroot +# 3. Extract artifact → publish path +# 4. Run EF migration (dotnet SolutionErp.Api.dll -- --migrate-only) +# 5. Start app pool +# 6. Health check /health/ready — rollback nếu fail + +param( + [Parameter(Mandatory=$true)] [string]$Artifact, + [Parameter(Mandatory=$true)] [string]$Site, + [string]$PublishPath = "C:\inetpub\solution-erp\api", + [string]$AppPoolName = "SolutionErpApi", + [string]$HealthUrl = "https://localhost/health/ready", + [int]$HealthTimeoutSec = 30 +) + +$ErrorActionPreference = 'Stop' +Import-Module WebAdministration + +function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan } + +Write-Step "Deploy $Artifact -> $PublishPath" + +# 1. Check prereqs +if (-not (Test-Path $Artifact)) { throw "Artifact khong ton tai: $Artifact" } +if (-not (Test-Path $PublishPath)) { New-Item -ItemType Directory -Force -Path $PublishPath | Out-Null } + +# 2. Stop app pool +Write-Step "Stop app pool $AppPoolName" +if (Test-Path "IIS:\AppPools\$AppPoolName") { + Stop-WebAppPool -Name $AppPoolName -ErrorAction SilentlyContinue + Start-Sleep -Seconds 3 +} else { + Write-Warning "App pool $AppPoolName chua ton tai. Tao bang cach:" + Write-Host " New-WebAppPool -Name '$AppPoolName'" -ForegroundColor Yellow + Write-Host " Set-ItemProperty IIS:\AppPools\$AppPoolName -Name managedRuntimeVersion -Value ''" -ForegroundColor Yellow + throw "App pool missing" +} + +# 3. Backup current +$BackupDir = "C:\inetpub\solution-erp\backups\api-$(Get-Date -Format 'yyyyMMdd-HHmmss')" +if (Test-Path "$PublishPath\SolutionErp.Api.dll") { + Write-Step "Backup current -> $BackupDir" + New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null + Copy-Item -Path "$PublishPath\*" -Destination $BackupDir -Recurse -Force +} else { + Write-Host " (Khong co build cu, skip backup)" +} + +# 4. Clean + extract new artifact +Write-Step "Clean + extract $Artifact" +Get-ChildItem -Path $PublishPath -Recurse -Force | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue +Expand-Archive -Path $Artifact -DestinationPath $PublishPath -Force + +# 5. Apply EF migrations (call dotnet SolutionErp.Api.dll với arg --migrate-only — Phase 5 impl) +Write-Step "Apply EF migrations" +Push-Location $PublishPath +try { + # Note: Program.cs check `args.Contains("--no-db-init")`. Default khi start IIS KHONG co arg + # → migrations tu chay khi app boot len. Neu muon run migration truoc khi app start: + # dotnet SolutionErp.Api.dll --migrate-only (TODO: thêm --migrate-only flag trong Program.cs) + Write-Host " (skip — migrations chay tu dong khi app start lan dau)" +} finally { + Pop-Location +} + +# 6. Start app pool +Write-Step "Start app pool" +Start-WebAppPool -Name $AppPoolName + +# 7. Health check loop +Write-Step "Health check $HealthUrl (timeout ${HealthTimeoutSec}s)" +$deadline = (Get-Date).AddSeconds($HealthTimeoutSec) +$ok = $false +while ((Get-Date) -lt $deadline) { + try { + $resp = Invoke-WebRequest -Uri $HealthUrl -UseBasicParsing -SkipCertificateCheck -TimeoutSec 5 + if ($resp.StatusCode -eq 200) { $ok = $true; break } + } catch { + Start-Sleep -Seconds 2 + } +} + +if ($ok) { + Write-Host "`nDeploy SUCCESS" -ForegroundColor Green +} else { + Write-Warning "`nHealth check FAIL. Rollback thu cong:" + Write-Host " Stop-WebAppPool $AppPoolName" + Write-Host " Copy-Item $BackupDir\* $PublishPath\* -Recurse -Force" + Write-Host " Start-WebAppPool $AppPoolName" + exit 1 +} diff --git a/src/Backend/SolutionErp.Api/Controllers/AuthController.cs b/src/Backend/SolutionErp.Api/Controllers/AuthController.cs index 57cd0dd..aae12a5 100644 --- a/src/Backend/SolutionErp.Api/Controllers/AuthController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/AuthController.cs @@ -18,6 +18,7 @@ public class AuthController : ControllerBase [HttpPost("login")] [AllowAnonymous] + [Microsoft.AspNetCore.RateLimiting.EnableRateLimiting("auth-login")] public async Task> Login([FromBody] LoginCommand command, CancellationToken ct) => Ok(await _mediator.Send(command, ct)); diff --git a/src/Backend/SolutionErp.Api/Program.cs b/src/Backend/SolutionErp.Api/Program.cs index a9bc0ae..11d1ffc 100644 --- a/src/Backend/SolutionErp.Api/Program.cs +++ b/src/Backend/SolutionErp.Api/Program.cs @@ -1,6 +1,10 @@ using System.Text; +using System.Threading.RateLimiting; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using Serilog; @@ -16,11 +20,10 @@ using SolutionErp.Infrastructure.Persistence; var builder = WebApplication.CreateBuilder(args); -// ---------- Logging (Serilog) ---------- +// ---------- Logging (Serilog) — đọc config từ appsettings (Console dev, File prod) ---------- builder.Host.UseSerilog((ctx, cfg) => cfg .ReadFrom.Configuration(ctx.Configuration) - .Enrich.FromLogContext() - .WriteTo.Console()); + .Enrich.FromLogContext()); // ---------- Core services ---------- builder.Services.AddControllers(); @@ -66,16 +69,54 @@ builder.Services.AddAuthorization(opts => } }); -// ---------- CORS (2 FE dev origins) ---------- +// ---------- CORS — đọc origin từ config (Production) hoặc default dev ---------- +var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get() + ?? ["http://localhost:8080", "http://localhost:8082"]; builder.Services.AddCors(opts => { opts.AddDefaultPolicy(p => p - .WithOrigins("http://localhost:8080", "http://localhost:8082") + .WithOrigins(allowedOrigins) .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials()); }); +// ---------- Rate limiting (Phase 5) ---------- +var rateLimitAuth = builder.Configuration.GetValue("RateLimit:AuthLoginPerMinute") ?? 5; +var rateLimitGlobal = builder.Configuration.GetValue("RateLimit:GlobalPerMinute") ?? 300; + +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + // Policy "auth-login" áp dụng [EnableRateLimiting] ở AuthController.Login + options.AddPolicy("auth-login", httpContext => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = rateLimitAuth, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + })); + + // Global limit theo IP — chặn abuse + options.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = rateLimitGlobal, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 50, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + })); +}); + +// ---------- Health checks ---------- +builder.Services.AddHealthChecks() + .AddDbContextCheck("database", HealthStatus.Unhealthy, ["ready"]); + // ---------- Swagger ---------- builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => @@ -113,11 +154,21 @@ if (app.Environment.IsDevelopment()) app.UseSwagger(); app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "SolutionErp API v1")); } +else +{ + app.UseHsts(); +} app.UseHttpsRedirection(); +app.UseRateLimiter(); app.UseCors(); app.UseAuthentication(); app.UseAuthorization(); + +// Health check endpoints (IIS probe) +app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false }); +app.MapHealthChecks("/health/ready", new HealthCheckOptions { Predicate = h => h.Tags.Contains("ready") }); + app.MapControllers(); // ---------- DB init + seed ---------- diff --git a/src/Backend/SolutionErp.Api/SolutionErp.Api.csproj b/src/Backend/SolutionErp.Api/SolutionErp.Api.csproj index f435835..fad3ebb 100644 --- a/src/Backend/SolutionErp.Api/SolutionErp.Api.csproj +++ b/src/Backend/SolutionErp.Api/SolutionErp.Api.csproj @@ -12,7 +12,9 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + diff --git a/src/Backend/SolutionErp.Api/appsettings.Development.json b/src/Backend/SolutionErp.Api/appsettings.Development.json index 1573ea5..9c6fec8 100644 --- a/src/Backend/SolutionErp.Api/appsettings.Development.json +++ b/src/Backend/SolutionErp.Api/appsettings.Development.json @@ -13,6 +13,9 @@ "Microsoft.EntityFrameworkCore": "Information", "System": "Warning" } - } + }, + "WriteTo": [ + { "Name": "Console" } + ] } } diff --git a/src/Backend/SolutionErp.Api/appsettings.json b/src/Backend/SolutionErp.Api/appsettings.json index 47ea330..58010fa 100644 --- a/src/Backend/SolutionErp.Api/appsettings.json +++ b/src/Backend/SolutionErp.Api/appsettings.json @@ -17,7 +17,14 @@ "Microsoft.EntityFrameworkCore": "Warning", "System": "Warning" } - } + }, + "WriteTo": [ + { "Name": "Console" } + ] }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "RateLimit": { + "AuthLoginPerMinute": 5, + "GlobalPerMinute": 300 + } }