From 11e61c9c39a0b1e2706fb2c35d19ccd0c30696d3 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Tue, 21 Apr 2026 13:06:46 +0700 Subject: [PATCH] [CLAUDE] Phase5.1: Security headers + account lockout + Users management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security hardening: - Api/Middleware/SecurityHeadersMiddleware MOI: remove server fingerprint (Server, X-Powered-By, ...), add X-Content-Type-Options:nosniff, X-Frame-Options:DENY, Referrer-Policy:strict-origin-when-cross-origin, Permissions-Policy (disable geolocation/mic/cam/payment), X-Permitted-Cross-Domain-Policies:none, CSP (default-src 'self' + img data: + style inline for Tailwind + frame-ancestors 'none'). Skip CSP tren /swagger (dung inline script). - Program.cs wire UseMiddleware SecurityHeadersMiddleware first in pipeline - Infrastructure/DependencyInjection Identity options: - Password.RequiredLength config-driven (Identity:Password:RequiredLength, default 8 dev, override 12+ prod) - Lockout: DefaultLockoutTimeSpan (15min), MaxFailedAccessAttempts (5), AllowedForNewUsers=true — all config-driven - LoginCommandHandler: IsLockedOutAsync check truoc → throw voi deadline message, AccessFailedAsync khi sai password, ResetAccessFailedCountAsync khi login thanh cong Users management: - Application/Users/UserFeatures.cs: 8 CQRS (ListUsersQuery paging+search, GetUserQuery, CreateUserCommand + Validator, UpdateUserCommand voi self-disable protection, AssignRolesCommand voi self-demote protection (khong tu go Admin), ResetPasswordCommand (invalidate refresh token + unlock), UnlockUserCommand) - UserDto: Id, Email, FullName, IsActive, IsLocked (computed tu LockoutEnd), CreatedAt, Roles - Api/Controllers/UsersController: 7 endpoint (Users.Read/Create/Update policies): - GET / (list paged), GET /{id}, POST /, PUT /{id}, PUT /{id}/roles, POST /{id}/reset-password, POST /{id}/unlock - using alias ValidationException = Application.Common.Exceptions.ValidationException (fix ambiguity voi FluentValidation) Frontend fe-admin: - types/users.ts MOI: User type + AVAILABLE_ROLES 12 role (match BE AppRoles.cs) + RoleLabel Vietnamese - pages/system/UsersPage.tsx MOI: - DataTable columns: Email (mono), FullName, Roles (badge chips voi Vietnamese label), IsActive (CheckCircle/XCircle), IsLocked (KeyRound red), CreatedAt - Actions per row (PermissionGuard Users.Update wrap): Gan role (Shield icon → Dialog grid 12 checkbox), Reset password (KeyRound → Dialog voi warning user se bi logout), Unlock (Unlock icon, chi hien khi isLocked), Toggle active (XCircle/CheckCircle) - Create user dialog: email + fullName + password (min 8) + grid 12 role checkbox - Route /system/users vao App.tsx E2E verified: - Security headers present tren moi response (check qua curl -I) - POST /api/users voi roles: [Drafter] → 201 + id - GET /api/users → paged voi 2 user (admin + new test.drafter) - TS check fe-admin → pass - dotnet build → 0 errors Docs: - docs/STATUS.md: Phase 5.1 xong, cumulative BE 3700 LOC, 42 endpoints, 17 FE pages - docs/HANDOFF.md: phase table update row Phase 5.1, last updated timestamp - docs/changelog/migration-todos.md: tick 6 items Phase 5.1 + 4 items remaining (IDOR, deps scan, admin warning, Roles CRUD) - docs/changelog/sessions/2026-04-21-1630-phase5-1-security-users.md: session log Co-Authored-By: Claude Opus 4.7 (1M context) EOF --- docs/HANDOFF.md | 6 +- docs/STATUS.md | 19 +- docs/changelog/migration-todos.md | 14 +- ...2026-04-21-1630-phase5-1-security-users.md | 118 +++++++ fe-admin/src/App.tsx | 2 + fe-admin/src/pages/system/UsersPage.tsx | 327 ++++++++++++++++++ fe-admin/src/types/users.ts | 47 +++ .../Controllers/UsersController.cs | 68 ++++ .../Middleware/SecurityHeadersMiddleware.cs | 42 +++ src/Backend/SolutionErp.Api/Program.cs | 1 + .../Auth/Commands/Login/LoginCommand.cs | 39 ++- .../Users/UserFeatures.cs | 248 +++++++++++++ .../DependencyInjection.cs | 11 +- 13 files changed, 909 insertions(+), 33 deletions(-) create mode 100644 docs/changelog/sessions/2026-04-21-1630-phase5-1-security-users.md create mode 100644 fe-admin/src/pages/system/UsersPage.tsx create mode 100644 fe-admin/src/types/users.ts create mode 100644 src/Backend/SolutionErp.Api/Controllers/UsersController.cs create mode 100644 src/Backend/SolutionErp.Api/Middleware/SecurityHeadersMiddleware.cs create mode 100644 src/Backend/SolutionErp.Application/Users/UserFeatures.cs diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index d457ed5..759b2ce 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 15:30 (cuối Phase 5 Prep) +**Last updated:** 2026-04-21 16:30 (cuối Phase 5.1 Security + Users Mgmt) ## Ở đâu rồi? @@ -15,9 +15,9 @@ | 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 Prep (infra + scripts + guides + refresh token) | ✅ Done | +| **5.1 Security + Users Mgmt (headers, lockout, Users CRUD)** | ✅ Done (IDOR + deps scan còn) | | 5 Deploy production (cần Gitea URL) | 📋 Next | -| 5.1 Security hardening (headers, lockout, IDOR) | 📋 Queue | ## Run nhanh diff --git a/docs/STATUS.md b/docs/STATUS.md index 4c2d650..9e1dd31 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 15:30 +**Last updated:** 2026-04-21 16:30 -## 📍 Phase hiện tại: **Phase 5 Prep xong** (infra + scripts + docs) — chờ Gitea URL để deploy thật +## 📍 Phase hiện tại: **Phase 5.1 Security + Users Mgmt xong** — chờ Gitea URL để deploy Phase 5 prod ## 🔥 In Progress @@ -14,7 +14,8 @@ _(không có)_ | Ngày | Ai | Task | Commit | |---|---|---|---| -| 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 5.1 Security + Users Mgmt** — Security headers middleware (CSP, X-Frame-Options, Referrer-Policy, Permissions-Policy) + Identity account lockout (5 fail → 15min) + LoginHandler check IsLockedOut + AccessFailedAsync. BE Users CQRS 8 feature + UsersController 7 endpoint. FE admin `/system/users` — list + create + gán role + reset password + unlock + toggle active | (sắp commit) | +| 2026-04-21 | Claude | **Phase 5 Prep** — BE rate limit + health check + Serilog file + HSTS + scripts deploy-iis/backup-sql + .gitea/workflows/deploy.yml + 4 guides + FE refresh token queue pattern | `46a2cab` | | 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` | @@ -46,14 +47,16 @@ Session logs: [P0](changelog/sessions/2026-04-21-1045-phase0-scaffold.md) · [P1 - [ ] Smoke test end-to-end prod - [ ] UAT 1 tuần 2-3 user thật -### Phase 5.1 Security hardening +### Phase 5.1 Security — xong gần hết -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 +- [x] Security headers middleware (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, CSP) +- [x] Identity account lockout (5 fail → 15min, config-driven) +- [x] Password policy config-driven (default 8 dev, override prod `Identity:Password:RequiredLength`) +- [x] LoginHandler check lockout + AccessFailedAsync + reset on success +- [x] BE Users management + FE admin UsersPage (tạo user test permission non-admin) - [ ] IDOR check ContractsController (user không xem HĐ không liên quan) - [ ] Dependencies scan CI (`dotnet list package --vulnerable` + `npm audit`) +- [ ] Admin mặc định warning log force đổi password ### Polish iterations diff --git a/docs/changelog/migration-todos.md b/docs/changelog/migration-todos.md index a9a1efa..d5664a7 100644 --- a/docs/changelog/migration-todos.md +++ b/docs/changelog/migration-todos.md @@ -211,14 +211,18 @@ - [ ] UAT production 1 tuần với 2-3 user thật - [ ] Go-live checklist: backup, rollback plan, on-call contact -### Phase 5.1 Security hardening +### Phase 5.1 Security hardening + Users Mgmt -- [ ] Security headers middleware (X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy) -- [ ] Identity account lockout (5 fail → 15min lock) — config trong `DependencyInjection.cs` -- [ ] Password policy min 12 chars production +- [x] Security headers middleware (X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy, Permissions-Policy) +- [x] Identity account lockout (5 fail → 15min, config-driven) +- [x] Password policy config-driven (default 8 dev, override `Identity:Password:RequiredLength` prod) +- [x] LoginCommand: check IsLockedOutAsync + AccessFailedAsync + reset on success +- [x] BE Users management: CQRS 8 feature + UsersController 7 endpoint (Users.Read/Create/Update policies) +- [x] FE admin `/system/users`: list + create + assign roles + reset password + unlock + toggle active - [ ] IDOR check ContractsController — user Drafter chỉ xem HĐ mình tạo hoặc có role giữ phase - [ ] Dependencies scan vào CI (`dotnet list package --vulnerable --include-transitive`, `npm audit --audit-level=high`) -- [ ] Admin mặc định: đổi password prod hoặc disable sau setup user thật +- [ ] Admin mặc định: warning log force đổi password +- [ ] BE Roles CRUD (Create/Rename/Delete custom role) + FE `/system/roles` ## Post-launch (Phase 6+ — future) diff --git a/docs/changelog/sessions/2026-04-21-1630-phase5-1-security-users.md b/docs/changelog/sessions/2026-04-21-1630-phase5-1-security-users.md new file mode 100644 index 0000000..67ffef1 --- /dev/null +++ b/docs/changelog/sessions/2026-04-21-1630-phase5-1-security-users.md @@ -0,0 +1,118 @@ +# Session 2026-04-21 16:30 — Phase 5.1 Security + Users Management + +**Dev:** Claude (Opus 4.7) +**Duration:** ~1h +**Base commit:** `46a2cab` + +## Làm được + +### Chunk T — Phase 5.1 Security hardening + +- `Api/Middleware/SecurityHeadersMiddleware.cs` MỚI: + - Remove server fingerprint: `Server`, `X-Powered-By`, `X-AspNet-Version`, `X-AspNetMvc-Version` + - Security headers: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-Permitted-Cross-Domain-Policies: none`, `Permissions-Policy` (disable geolocation/mic/cam/payment) + - CSP: `default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'` — skip ở `/swagger` (dùng inline script/style) +- `Program.cs` wire `app.UseMiddleware()` đầu pipeline + +- `Infrastructure/DependencyInjection.cs` Identity options: + - Password `RequiredLength` đọc config (`Identity:Password:RequiredLength`) — default 8 dev, override 12+ prod + - Lockout: `DefaultLockoutTimeSpan` (default 15 phút), `MaxFailedAccessAttempts` (default 5), `AllowedForNewUsers = true` + - Override qua `appsettings.Production.json` Identity section + +- `LoginCommand` handler update: + - Check `IsLockedOutAsync` trước khi verify password → throw với deadline message + - `AccessFailedAsync` khi sai password (tăng counter, auto-lock) + - `ResetAccessFailedCountAsync` khi login thành công + +### Chunk U — BE Users management (8 features) + +- `Application/Users/UserFeatures.cs`: + - `UserDto` (Id, Email, FullName, IsActive, IsLocked, CreatedAt, Roles) + - `ListUsersQuery` với paging + search (email / fullName) + - `GetUserQuery` + - `CreateUserCommand` + Validator + Handler (check email unique, CreateAsync + AddToRoleAsync) + - `UpdateUserCommand` (FullName + IsActive, với self-disable protection) + - `AssignRolesCommand` (replace full set, diff add/remove, self-demote protection — không tự gỡ Admin) + - `ResetPasswordCommand` (admin flow — Remove + Add password, invalidate refresh token, unlock) + - `UnlockUserCommand` +- `Api/Controllers/UsersController.cs` — 7 endpoint với policy `Users.Read` / `Users.Create` / `Users.Update` + +### Chunk V — FE admin Users page + +- `types/users.ts`: User type + `AVAILABLE_ROLES` (12 role từ BE AppRoles.cs) + `RoleLabel` tiếng Việt +- `pages/system/UsersPage.tsx`: + - DataTable list users với Email / Họ tên / Roles (badge chips) / Active / Locked / Created + - Nút **Gán role** (icon Shield) → Dialog checkbox 12 role → PUT `/roles` + - Nút **Reset password** (icon KeyRound) → Dialog với warning "user sẽ bị logout" + - Nút **Unlock** (hiện khi isLocked=true) + - Nút **Toggle Active** (XCircle / CheckCircle) + - Dialog **Thêm user mới**: email / fullName / password / grid roles checkbox → POST `/users` + - `` wrap các button +- Route `/system/users` vào App.tsx + +## E2E verified + +```bash +# Security headers +GET /api/users (với Bearer) → 200 + Response headers: + X-Content-Type-Options: nosniff ✅ + X-Frame-Options: DENY ✅ + Referrer-Policy: strict-origin-when-cross-origin ✅ + Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=() ✅ + Content-Security-Policy: default-src 'self'; ... ✅ + +# Users CRUD +POST /api/users {email: test.drafter@..., roles: ["Drafter"]} → 201 + id +GET /api/users → Paged[1 admin only initially, now 2] + +# TS check fe-admin → pass +``` + +## Bug gặp + fix + +| Bug | Fix | +|---|---| +| `ValidationException` ambiguous FluentValidation vs Custom | `using ValidationException = ...Custom.ValidationException;` alias | +| Edit tool "File not read" tiếp tục bị hit sau system-reminder | Read lại → Edit — pattern đã quen | + +## Tác động lên STATUS/HANDOFF/migration-todos/gotchas + +- `migration-todos.md` Phase 5.1: tick 5 items done (security headers, account lockout, password policy, — còn IDOR check + dependencies scan) +- `gotchas.md`: không thêm mới (các issue lần này không có bẫy mới) +- `STATUS.md`: cumulative BE LOC 3300 → 3700 (+UserFeatures + SecurityHeaders) +- `HANDOFF.md`: thêm Phase 5.1 status row, FE pages 16 → 17 (+UsersPage) + +## Handoff cho session tiếp theo + +### Phase 5.1 còn + +- [ ] IDOR check ContractsController: user Drafter chỉ thấy HĐ mình tạo hoặc có role giữ phase hiện tại (cần add helper `IsInvolvedInContract(userId, contractId)`) +- [ ] Dependencies scan CI: `dotnet list package --vulnerable --include-transitive` + `npm audit --audit-level=high` +- [ ] Admin mặc định: thêm warning log lần đầu start nếu admin password = `Admin@123456` (force đổi) + +### Roles CRUD (quick win còn lại) + +- [ ] BE: `CreateRoleCommand`, `UpdateRoleCommand` (rename + description), `DeleteRoleCommand` (block nếu còn user/permission) +- [ ] FE: `/system/roles` page + +### Phase 3 iter 2 (big — optional) + +- SlaExpiryJob BackgroundService +- Email/in-app notification +- Upload attachment endpoint + FE + +### Blocker + +- ⏳ Gitea URL — user sẽ cấp + +## Thông số cumulative + +| | Phase 5 prep | **Phase 5.1 Security + Users** | +|---|---:|---:| +| BE LOC | ~3300 | **~3700** (+UserFeatures ~250 + SecurityHeaders ~40 + LoginHandler update) | +| DB tables | 19 | 19 | +| API endpoints | ~35 | **~42** (+7 users) | +| FE pages | 16 | **17** (+UsersPage) | +| Middleware | 2 | **3** (+SecurityHeaders) | +| Commits | 9 | **10** (sắp) | diff --git a/fe-admin/src/App.tsx b/fe-admin/src/App.tsx index b0ee3a4..df881f5 100644 --- a/fe-admin/src/App.tsx +++ b/fe-admin/src/App.tsx @@ -13,6 +13,7 @@ import { FormsPage } from '@/pages/forms/FormsPage' import { ContractsListPage } from '@/pages/contracts/ContractsListPage' import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage' import { ReportsPage } from '@/pages/ReportsPage' +import { UsersPage } from '@/pages/system/UsersPage' function App() { return ( @@ -31,6 +32,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/fe-admin/src/pages/system/UsersPage.tsx b/fe-admin/src/pages/system/UsersPage.tsx new file mode 100644 index 0000000..0d7f7f3 --- /dev/null +++ b/fe-admin/src/pages/system/UsersPage.tsx @@ -0,0 +1,327 @@ +import { useState, type FormEvent } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { KeyRound, Plus, Shield, Unlock, Users, CheckCircle2, XCircle } from 'lucide-react' +import { toast } from 'sonner' +import { PageHeader } from '@/components/PageHeader' +import { DataTable, Pagination, type Column } from '@/components/DataTable' +import { PermissionGuard } from '@/components/PermissionGuard' +import { Button } from '@/components/ui/Button' +import { Input } from '@/components/ui/Input' +import { Label } from '@/components/ui/Label' +import { Dialog } from '@/components/ui/Dialog' +import { api } from '@/lib/api' +import { getErrorMessage } from '@/lib/apiError' +import { MenuKeys } from '@/lib/menuKeys' +import type { Paged } from '@/types/master' +import { AVAILABLE_ROLES, RoleLabel, type User } from '@/types/users' + +const fmtDate = (s: string) => new Date(s).toLocaleDateString('vi-VN') + +export function UsersPage() { + const qc = useQueryClient() + const [page, setPage] = useState(1) + const [search, setSearch] = useState('') + + const [createOpen, setCreateOpen] = useState(false) + const [createForm, setCreateForm] = useState({ email: '', fullName: '', password: '', roles: [] as string[] }) + + const [rolesModal, setRolesModal] = useState(null) + const [roleSelection, setRoleSelection] = useState([]) + + const [resetModal, setResetModal] = useState(null) + const [newPassword, setNewPassword] = useState('') + + const list = useQuery({ + queryKey: ['users', { page, search }], + queryFn: async () => + (await api.get>('/users', { params: { page, pageSize: 20, search: search || undefined } })).data, + }) + + const createMut = useMutation({ + mutationFn: async () => { + await api.post('/users', createForm) + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['users'] }) + toast.success('Đã tạo user') + setCreateOpen(false) + setCreateForm({ email: '', fullName: '', password: '', roles: [] }) + }, + onError: err => toast.error(getErrorMessage(err)), + }) + + const rolesMut = useMutation({ + mutationFn: async () => { + if (!rolesModal) return + await api.put(`/users/${rolesModal.id}/roles`, { roles: roleSelection }) + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['users'] }) + toast.success('Đã cập nhật role') + setRolesModal(null) + }, + onError: err => toast.error(getErrorMessage(err)), + }) + + const resetMut = useMutation({ + mutationFn: async () => { + if (!resetModal) return + await api.post(`/users/${resetModal.id}/reset-password`, { newPassword }) + }, + onSuccess: () => { + toast.success(`Đã reset password. User phải login lại.`) + setResetModal(null) + setNewPassword('') + }, + onError: err => toast.error(getErrorMessage(err)), + }) + + const unlockMut = useMutation({ + mutationFn: (id: string) => api.post(`/users/${id}/unlock`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['users'] }) + toast.success('Đã mở khóa') + }, + onError: err => toast.error(getErrorMessage(err)), + }) + + const toggleActiveMut = useMutation({ + mutationFn: (u: User) => api.put(`/users/${u.id}`, { id: u.id, fullName: u.fullName, isActive: !u.isActive }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['users'] }) + toast.success('Đã cập nhật trạng thái') + }, + onError: err => toast.error(getErrorMessage(err)), + }) + + function openRoles(u: User) { + setRolesModal(u) + setRoleSelection([...u.roles]) + } + + function toggleRole(r: string) { + setRoleSelection(sel => (sel.includes(r) ? sel.filter(x => x !== r) : [...sel, r])) + } + + function toggleCreateRole(r: string) { + setCreateForm(f => ({ + ...f, + roles: f.roles.includes(r) ? f.roles.filter(x => x !== r) : [...f.roles, r], + })) + } + + const columns: Column[] = [ + { key: 'email', header: 'Email', render: u => {u.email} }, + { key: 'fullName', header: 'Họ tên', render: u => u.fullName }, + { + key: 'roles', + header: 'Vai trò', + render: u => ( +
+ {u.roles.length === 0 && } + {u.roles.map(r => ( + + {RoleLabel[r] ?? r} + + ))} +
+ ), + }, + { + key: 'isActive', + header: 'Active', + width: 'w-24', + align: 'center', + render: u => + u.isActive ? ( + + ) : ( + + ), + }, + { + key: 'isLocked', + header: 'Locked', + width: 'w-24', + align: 'center', + render: u => + u.isLocked ? ( + + + Locked + + ) : ( + + ), + }, + { key: 'createdAt', header: 'Ngày tạo', width: 'w-28', render: u => fmtDate(u.createdAt) }, + { + key: 'actions', + header: '', + align: 'right', + width: 'w-52', + render: u => ( +
+ + + + {u.isLocked && ( + + )} + + +
+ ), + }, + ] + + return ( +
+ + + Người dùng + + } + description="Tạo user + gán role để test quyền với non-admin." + actions={ + + + + } + /> + +
+ { setSearch(e.target.value); setPage(1) }} + className="max-w-sm" + /> +
+ + u.id} isLoading={list.isLoading} /> + + + {/* Create user */} + setCreateOpen(false)} + title="Thêm user mới" + size="md" + footer={ + <> + + + + } + > +
{ e.preventDefault(); createMut.mutate() }}> +
+ + setCreateForm(f => ({ ...f, email: e.target.value }))} required /> +
+
+ + setCreateForm(f => ({ ...f, fullName: e.target.value }))} required /> +
+
+ + setCreateForm(f => ({ ...f, password: e.target.value }))} required minLength={8} /> +
Tối thiểu 8 ký tự + chữ hoa + thường + số + ký tự đặc biệt
+
+
+ +
+ {AVAILABLE_ROLES.map(r => ( + + ))} +
+
+
+
+ + {/* Assign roles */} + setRolesModal(null)} + title={`Gán role cho ${rolesModal?.fullName}`} + size="md" + footer={ + <> + + + + } + > +
+ {AVAILABLE_ROLES.map(r => ( + + ))} +
+
+ + {/* Reset password */} + setResetModal(null)} + title={`Reset password: ${resetModal?.email}`} + size="sm" + footer={ + <> + + + + } + > +
+
+ User sẽ bị logout khỏi mọi session + phải login lại với password mới. +
+
+ + setNewPassword(e.target.value)} minLength={8} /> +
+
+
+
+ ) +} diff --git a/fe-admin/src/types/users.ts b/fe-admin/src/types/users.ts new file mode 100644 index 0000000..7973c88 --- /dev/null +++ b/fe-admin/src/types/users.ts @@ -0,0 +1,47 @@ +export type User = { + id: string + email: string + fullName: string + isActive: boolean + isLocked: boolean + createdAt: string + roles: string[] +} + +export type CreateUserInput = { + email: string + fullName: string + password: string + roles: string[] +} + +// 12 role seed trong BE (AppRoles.cs) +export const AVAILABLE_ROLES = [ + 'Admin', + 'Drafter', + 'DeptManager', + 'ProjectManager', + 'Procurement', + 'CostControl', + 'Finance', + 'Accounting', + 'Equipment', + 'Director', + 'AuthorizedSigner', + 'HrAdmin', +] as const + +export const RoleLabel: Record = { + Admin: 'Quản trị', + Drafter: 'Người soạn', + DeptManager: 'Trưởng phòng ban', + ProjectManager: 'Giám đốc dự án', + Procurement: 'Cung ứng', + CostControl: 'Kiểm soát chi phí', + Finance: 'Tài chính', + Accounting: 'Kế toán', + Equipment: 'Thiết bị', + Director: 'Ban Giám đốc', + AuthorizedSigner: 'Người ủy quyền ký', + HrAdmin: 'Nhân sự / Đóng dấu', +} diff --git a/src/Backend/SolutionErp.Api/Controllers/UsersController.cs b/src/Backend/SolutionErp.Api/Controllers/UsersController.cs new file mode 100644 index 0000000..4ecb138 --- /dev/null +++ b/src/Backend/SolutionErp.Api/Controllers/UsersController.cs @@ -0,0 +1,68 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SolutionErp.Application.Common.Models; +using SolutionErp.Application.Users; + +namespace SolutionErp.Api.Controllers; + +[ApiController] +[Route("api/users")] +[Authorize(Policy = "Users.Read")] +public class UsersController(IMediator mediator) : ControllerBase +{ + [HttpGet] + public async Task>> List( + [FromQuery] int page = 1, [FromQuery] int pageSize = 20, + [FromQuery] string? search = null, [FromQuery] bool sortDesc = true, + CancellationToken ct = default) + => Ok(await mediator.Send(new ListUsersQuery { Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct)); + + [HttpGet("{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + => Ok(await mediator.Send(new GetUserQuery(id), ct)); + + [HttpPost] + [Authorize(Policy = "Users.Create")] + public async Task> Create([FromBody] CreateUserCommand cmd, CancellationToken ct) + { + var id = await mediator.Send(cmd, ct); + return CreatedAtAction(nameof(Get), new { id }, new { id }); + } + + [HttpPut("{id:guid}")] + [Authorize(Policy = "Users.Update")] + public async Task Update(Guid id, [FromBody] UpdateUserCommand cmd, CancellationToken ct) + { + if (id != cmd.Id) return BadRequest(new { detail = "ID không khớp" }); + await mediator.Send(cmd, ct); + return NoContent(); + } + + [HttpPut("{id:guid}/roles")] + [Authorize(Policy = "Users.Update")] + public async Task AssignRoles(Guid id, [FromBody] AssignRolesBody body, CancellationToken ct) + { + await mediator.Send(new AssignRolesCommand(id, body.Roles), ct); + return NoContent(); + } + + [HttpPost("{id:guid}/reset-password")] + [Authorize(Policy = "Users.Update")] + public async Task ResetPassword(Guid id, [FromBody] ResetPasswordBody body, CancellationToken ct) + { + await mediator.Send(new ResetPasswordCommand(id, body.NewPassword), ct); + return NoContent(); + } + + [HttpPost("{id:guid}/unlock")] + [Authorize(Policy = "Users.Update")] + public async Task Unlock(Guid id, CancellationToken ct) + { + await mediator.Send(new UnlockUserCommand(id), ct); + return NoContent(); + } +} + +public record AssignRolesBody(List Roles); +public record ResetPasswordBody(string NewPassword); diff --git a/src/Backend/SolutionErp.Api/Middleware/SecurityHeadersMiddleware.cs b/src/Backend/SolutionErp.Api/Middleware/SecurityHeadersMiddleware.cs new file mode 100644 index 0000000..84b0141 --- /dev/null +++ b/src/Backend/SolutionErp.Api/Middleware/SecurityHeadersMiddleware.cs @@ -0,0 +1,42 @@ +namespace SolutionErp.Api.Middleware; + +// Thêm security headers theo OWASP + Mozilla Observatory best practices. +// Xem docs/guides/security-checklist.md §A05. +public class SecurityHeadersMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context) + { + var headers = context.Response.Headers; + + // Ẩn server fingerprint + headers.Remove("Server"); + headers.Remove("X-Powered-By"); + headers.Remove("X-AspNet-Version"); + headers.Remove("X-AspNetMvc-Version"); + + // Security headers + headers["X-Content-Type-Options"] = "nosniff"; + headers["X-Frame-Options"] = "DENY"; + headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; + headers["X-Permitted-Cross-Domain-Policies"] = "none"; + headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=(), payment=()"; + + // CSP chỉ áp dụng ở non-Swagger (Swagger dùng inline script/style) + var isSwagger = context.Request.Path.StartsWithSegments("/swagger"); + if (!isSwagger) + { + headers["Content-Security-Policy"] = + "default-src 'self'; " + + "img-src 'self' data:; " + + "style-src 'self' 'unsafe-inline'; " + // Tailwind cần inline styles ở runtime + "script-src 'self'; " + + "font-src 'self' data:; " + + "connect-src 'self'; " + + "frame-ancestors 'none'; " + + "base-uri 'self'; " + + "form-action 'self'"; + } + + await next(context); + } +} diff --git a/src/Backend/SolutionErp.Api/Program.cs b/src/Backend/SolutionErp.Api/Program.cs index 11d1ffc..3417d8c 100644 --- a/src/Backend/SolutionErp.Api/Program.cs +++ b/src/Backend/SolutionErp.Api/Program.cs @@ -146,6 +146,7 @@ builder.Services.AddSwaggerGen(c => var app = builder.Build(); // ---------- Pipeline ---------- +app.UseMiddleware(); app.UseMiddleware(); app.UseSerilogRequestLogging(); diff --git a/src/Backend/SolutionErp.Application/Auth/Commands/Login/LoginCommand.cs b/src/Backend/SolutionErp.Application/Auth/Commands/Login/LoginCommand.cs index 1d5e2e9..359090c 100644 --- a/src/Backend/SolutionErp.Application/Auth/Commands/Login/LoginCommand.cs +++ b/src/Backend/SolutionErp.Application/Auth/Commands/Login/LoginCommand.cs @@ -19,32 +19,39 @@ public class LoginCommandValidator : AbstractValidator } } -public class LoginCommandHandler : IRequestHandler +public class LoginCommandHandler( + UserManager userManager, + IJwtTokenService jwtTokenService) : IRequestHandler { - private readonly UserManager _userManager; - private readonly IJwtTokenService _jwtTokenService; - - public LoginCommandHandler(UserManager userManager, IJwtTokenService jwtTokenService) - { - _userManager = userManager; - _jwtTokenService = jwtTokenService; - } - public async Task Handle(LoginCommand request, CancellationToken cancellationToken) { - var user = await _userManager.FindByEmailAsync(request.Email); + var user = await userManager.FindByEmailAsync(request.Email); if (user is null || !user.IsActive) throw new UnauthorizedException("Email hoặc mật khẩu không đúng."); - if (!await _userManager.CheckPasswordAsync(user, request.Password)) - throw new UnauthorizedException("Email hoặc mật khẩu không đúng."); + // Check lockout trước + if (await userManager.IsLockedOutAsync(user)) + { + var until = user.LockoutEnd; + throw new UnauthorizedException($"Tài khoản bị khóa do nhập sai nhiều lần. Thử lại sau {until:HH:mm}."); + } - var roles = await _userManager.GetRolesAsync(user); - var tokens = await _jwtTokenService.GenerateTokensAsync(user, roles); + if (!await userManager.CheckPasswordAsync(user, request.Password)) + { + // Tăng AccessFailedCount + auto lock khi đủ ngưỡng + await userManager.AccessFailedAsync(user); + throw new UnauthorizedException("Email hoặc mật khẩu không đúng."); + } + + // Success → reset failed count + await userManager.ResetAccessFailedCountAsync(user); + + var roles = await userManager.GetRolesAsync(user); + var tokens = await jwtTokenService.GenerateTokensAsync(user, roles); user.RefreshToken = tokens.RefreshToken; user.RefreshTokenExpiresAt = tokens.RefreshTokenExpiresAt; - await _userManager.UpdateAsync(user); + await userManager.UpdateAsync(user); return new AuthResponseDto( tokens.AccessToken, diff --git a/src/Backend/SolutionErp.Application/Users/UserFeatures.cs b/src/Backend/SolutionErp.Application/Users/UserFeatures.cs new file mode 100644 index 0000000..a9f0cd4 --- /dev/null +++ b/src/Backend/SolutionErp.Application/Users/UserFeatures.cs @@ -0,0 +1,248 @@ +using FluentValidation; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Application.Common.Models; +using SolutionErp.Domain.Identity; +using ValidationException = SolutionErp.Application.Common.Exceptions.ValidationException; + +namespace SolutionErp.Application.Users; + +public record UserDto( + Guid Id, + string Email, + string FullName, + bool IsActive, + bool IsLocked, + DateTime CreatedAt, + List Roles); + +// ========== LIST ========== +public record ListUsersQuery : PagedRequest, IRequest>; + +public class ListUsersQueryHandler(UserManager userManager, IDateTime dateTime) + : IRequestHandler> +{ + public async Task> Handle(ListUsersQuery request, CancellationToken ct) + { + var query = userManager.Users.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(request.Search)) + { + var s = request.Search.Trim(); + query = query.Where(u => u.Email!.Contains(s) || u.FullName.Contains(s)); + } + query = request.SortDesc + ? query.OrderByDescending(u => u.CreatedAt) + : query.OrderBy(u => u.CreatedAt); + + var total = await query.CountAsync(ct); + var users = await query + .Skip((request.Page - 1) * request.PageSize) + .Take(request.PageSize) + .ToListAsync(ct); + + var items = new List(users.Count); + var now = dateTime.UtcNow; + foreach (var u in users) + { + var roles = await userManager.GetRolesAsync(u); + var isLocked = u.LockoutEnd.HasValue && u.LockoutEnd.Value.UtcDateTime > now; + items.Add(new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList())); + } + + return new PagedResult(items, total, request.Page, request.PageSize); + } +} + +// ========== GET ========== +public record GetUserQuery(Guid Id) : IRequest; + +public class GetUserQueryHandler(UserManager userManager, IDateTime dateTime) + : IRequestHandler +{ + public async Task Handle(GetUserQuery request, CancellationToken ct) + { + var u = await userManager.FindByIdAsync(request.Id.ToString()) + ?? throw new NotFoundException("User", request.Id); + var roles = await userManager.GetRolesAsync(u); + var isLocked = u.LockoutEnd.HasValue && u.LockoutEnd.Value.UtcDateTime > dateTime.UtcNow; + return new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList()); + } +} + +// ========== CREATE ========== +public record CreateUserCommand(string Email, string FullName, string Password, List Roles) + : IRequest; + +public class CreateUserCommandValidator : AbstractValidator +{ + public CreateUserCommandValidator() + { + RuleFor(x => x.Email).NotEmpty().EmailAddress().MaximumLength(100); + RuleFor(x => x.FullName).NotEmpty().MaximumLength(200); + RuleFor(x => x.Password).NotEmpty().MinimumLength(8); + RuleFor(x => x.Roles).NotNull(); + } +} + +public class CreateUserCommandHandler( + UserManager userManager, + RoleManager roleManager, + IDateTime dateTime) : IRequestHandler +{ + public async Task Handle(CreateUserCommand request, CancellationToken ct) + { + if (await userManager.FindByEmailAsync(request.Email) is not null) + throw new ConflictException($"Email '{request.Email}' đã tồn tại."); + + var user = new User + { + UserName = request.Email, + Email = request.Email, + FullName = request.FullName, + EmailConfirmed = true, + IsActive = true, + CreatedAt = dateTime.UtcNow, + }; + var result = await userManager.CreateAsync(user, request.Password); + if (!result.Succeeded) + throw new ValidationException(result.Errors.Select(e => new FluentValidation.Results.ValidationFailure("Password", e.Description))); + + // Assign roles + foreach (var roleName in request.Roles.Distinct()) + { + if (await roleManager.RoleExistsAsync(roleName)) + await userManager.AddToRoleAsync(user, roleName); + } + + return user.Id; + } +} + +// ========== UPDATE ========== +public record UpdateUserCommand(Guid Id, string FullName, bool IsActive) : IRequest; + +public class UpdateUserCommandValidator : AbstractValidator +{ + public UpdateUserCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.FullName).NotEmpty().MaximumLength(200); + } +} + +public class UpdateUserCommandHandler(UserManager userManager, ICurrentUser currentUser) + : IRequestHandler +{ + public async Task Handle(UpdateUserCommand request, CancellationToken ct) + { + var user = await userManager.FindByIdAsync(request.Id.ToString()) + ?? throw new NotFoundException("User", request.Id); + + // Self-disable protection: admin không thể tự deactivate + if (!request.IsActive && user.Id == currentUser.UserId) + throw new ForbiddenException("Không thể tự vô hiệu hóa tài khoản của mình."); + + user.FullName = request.FullName; + user.IsActive = request.IsActive; + await userManager.UpdateAsync(user); + } +} + +// ========== ASSIGN ROLES (replace full set) ========== +public record AssignRolesCommand(Guid Id, List Roles) : IRequest; + +public class AssignRolesCommandValidator : AbstractValidator +{ + public AssignRolesCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.Roles).NotNull(); + } +} + +public class AssignRolesCommandHandler( + UserManager userManager, + RoleManager roleManager, + ICurrentUser currentUser) : IRequestHandler +{ + public async Task Handle(AssignRolesCommand request, CancellationToken ct) + { + var user = await userManager.FindByIdAsync(request.Id.ToString()) + ?? throw new NotFoundException("User", request.Id); + + var current = await userManager.GetRolesAsync(user); + var target = request.Roles.Distinct().ToList(); + + // Self-demote protection: admin không thể tự gỡ role Admin khỏi mình + if (user.Id == currentUser.UserId + && current.Contains(AppRoles.Admin) + && !target.Contains(AppRoles.Admin)) + { + throw new ForbiddenException("Không thể tự gỡ role Admin khỏi mình."); + } + + var toRemove = current.Except(target).ToList(); + var toAdd = target.Except(current).ToList(); + + if (toRemove.Count > 0) await userManager.RemoveFromRolesAsync(user, toRemove); + foreach (var roleName in toAdd) + { + if (await roleManager.RoleExistsAsync(roleName)) + await userManager.AddToRoleAsync(user, roleName); + } + } +} + +// ========== RESET PASSWORD (admin) ========== +public record ResetPasswordCommand(Guid Id, string NewPassword) : IRequest; + +public class ResetPasswordCommandValidator : AbstractValidator +{ + public ResetPasswordCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8); + } +} + +public class ResetPasswordCommandHandler(UserManager userManager) + : IRequestHandler +{ + public async Task Handle(ResetPasswordCommand request, CancellationToken ct) + { + var user = await userManager.FindByIdAsync(request.Id.ToString()) + ?? throw new NotFoundException("User", request.Id); + + // Admin flow: remove + set password (không cần token) + await userManager.RemovePasswordAsync(user); + var result = await userManager.AddPasswordAsync(user, request.NewPassword); + if (!result.Succeeded) + throw new ValidationException(result.Errors.Select(e => new FluentValidation.Results.ValidationFailure("NewPassword", e.Description))); + + // Invalidate refresh token → force re-login + user.RefreshToken = null; + user.RefreshTokenExpiresAt = null; + await userManager.UpdateAsync(user); + + // Unlock nếu đang lock + await userManager.ResetAccessFailedCountAsync(user); + await userManager.SetLockoutEndDateAsync(user, null); + } +} + +// ========== UNLOCK ========== +public record UnlockUserCommand(Guid Id) : IRequest; + +public class UnlockUserCommandHandler(UserManager userManager) : IRequestHandler +{ + public async Task Handle(UnlockUserCommand request, CancellationToken ct) + { + var user = await userManager.FindByIdAsync(request.Id.ToString()) + ?? throw new NotFoundException("User", request.Id); + await userManager.SetLockoutEndDateAsync(user, null); + await userManager.ResetAccessFailedCountAsync(user); + } +} diff --git a/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs b/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs index 3a6a5f1..6d3f4d6 100644 --- a/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs +++ b/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs @@ -42,14 +42,23 @@ public static class DependencyInjection services.AddScoped(sp => sp.GetRequiredService()); + // Password policy + lockout — override prod qua appsettings.Production.json Identity section + var minLen = configuration.GetValue("Identity:Password:RequiredLength") ?? 8; + var lockoutMinutes = configuration.GetValue("Identity:Lockout:Minutes") ?? 15; + var lockoutMaxFail = configuration.GetValue("Identity:Lockout:MaxFailedAttempts") ?? 5; + services.AddIdentityCore(options => { options.Password.RequireDigit = true; options.Password.RequireLowercase = true; options.Password.RequireUppercase = true; options.Password.RequireNonAlphanumeric = true; - options.Password.RequiredLength = 8; + options.Password.RequiredLength = minLen; options.User.RequireUniqueEmail = true; + + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(lockoutMinutes); + options.Lockout.MaxFailedAccessAttempts = lockoutMaxFail; + options.Lockout.AllowedForNewUsers = true; }) .AddRoles() .AddEntityFrameworkStores();