[CLAUDE] Phase5.1: Security headers + account lockout + Users management

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) <noreply@anthropic.com>
EOF
This commit is contained in:
pqhuy1987
2026-04-21 13:06:46 +07:00
parent 46a2cab788
commit 11e61c9c39
13 changed files with 909 additions and 33 deletions

View File

@ -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() {
<Route path="/master/suppliers" element={<SuppliersPage />} />
<Route path="/master/projects" element={<ProjectsPage />} />
<Route path="/master/departments" element={<DepartmentsPage />} />
<Route path="/system/users" element={<UsersPage />} />
<Route path="/system/permissions" element={<PermissionsPage />} />
<Route path="/forms" element={<FormsPage />} />
<Route path="/contracts" element={<ContractsListPage />} />

View File

@ -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<User | null>(null)
const [roleSelection, setRoleSelection] = useState<string[]>([])
const [resetModal, setResetModal] = useState<User | null>(null)
const [newPassword, setNewPassword] = useState('')
const list = useQuery({
queryKey: ['users', { page, search }],
queryFn: async () =>
(await api.get<Paged<User>>('/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<User>[] = [
{ key: 'email', header: 'Email', render: u => <span className="font-mono text-xs">{u.email}</span> },
{ key: 'fullName', header: 'Họ tên', render: u => u.fullName },
{
key: 'roles',
header: 'Vai trò',
render: u => (
<div className="flex flex-wrap gap-1">
{u.roles.length === 0 && <span className="text-xs text-slate-400"></span>}
{u.roles.map(r => (
<span key={r} className="rounded bg-brand-50 px-1.5 py-0.5 text-xs text-brand-700">
{RoleLabel[r] ?? r}
</span>
))}
</div>
),
},
{
key: 'isActive',
header: 'Active',
width: 'w-24',
align: 'center',
render: u =>
u.isActive ? (
<CheckCircle2 className="mx-auto h-4 w-4 text-emerald-600" />
) : (
<XCircle className="mx-auto h-4 w-4 text-slate-400" />
),
},
{
key: 'isLocked',
header: 'Locked',
width: 'w-24',
align: 'center',
render: u =>
u.isLocked ? (
<span className="inline-flex items-center gap-1 text-xs text-red-600">
<KeyRound className="h-3.5 w-3.5" />
Locked
</span>
) : (
<span className="text-xs text-slate-400"></span>
),
},
{ 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 => (
<div className="flex justify-end gap-1">
<PermissionGuard menuKey={MenuKeys.Users} action="Update">
<Button size="sm" variant="ghost" onClick={() => openRoles(u)} title="Gán role">
<Shield className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={() => { setResetModal(u); setNewPassword('') }} title="Reset password">
<KeyRound className="h-3.5 w-3.5" />
</Button>
{u.isLocked && (
<Button size="sm" variant="ghost" onClick={() => unlockMut.mutate(u.id)} title="Mở khóa">
<Unlock className="h-3.5 w-3.5 text-amber-600" />
</Button>
)}
<Button size="sm" variant="ghost" onClick={() => toggleActiveMut.mutate(u)} title={u.isActive ? 'Vô hiệu hóa' : 'Kích hoạt'}>
{u.isActive ? <XCircle className="h-3.5 w-3.5 text-red-500" /> : <CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />}
</Button>
</PermissionGuard>
</div>
),
},
]
return (
<div className="p-6">
<PageHeader
title={
<span className="flex items-center gap-2">
<Users className="h-5 w-5" />
Người dùng
</span>
}
description="Tạo user + gán role để test quyền với non-admin."
actions={
<PermissionGuard menuKey={MenuKeys.Users} action="Create">
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
Thêm user
</Button>
</PermissionGuard>
}
/>
<div className="mb-3 flex gap-2">
<Input
placeholder="Tìm email hoặc tên…"
value={search}
onChange={e => { setSearch(e.target.value); setPage(1) }}
className="max-w-sm"
/>
</div>
<DataTable columns={columns} rows={list.data?.items ?? []} getRowKey={u => u.id} isLoading={list.isLoading} />
<Pagination page={page} pageSize={20} total={list.data?.total ?? 0} onChange={setPage} />
{/* Create user */}
<Dialog
open={createOpen}
onClose={() => setCreateOpen(false)}
title="Thêm user mới"
size="md"
footer={
<>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Hủy</Button>
<Button onClick={(e: FormEvent) => { e.preventDefault(); createMut.mutate() }} disabled={createMut.isPending}>
{createMut.isPending ? 'Đang tạo…' : 'Tạo'}
</Button>
</>
}
>
<form className="space-y-4" onSubmit={e => { e.preventDefault(); createMut.mutate() }}>
<div className="space-y-1.5">
<Label>Email *</Label>
<Input type="email" value={createForm.email} onChange={e => setCreateForm(f => ({ ...f, email: e.target.value }))} required />
</div>
<div className="space-y-1.5">
<Label>Họ tên *</Label>
<Input value={createForm.fullName} onChange={e => setCreateForm(f => ({ ...f, fullName: e.target.value }))} required />
</div>
<div className="space-y-1.5">
<Label>Password *</Label>
<Input type="password" value={createForm.password} onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))} required minLength={8} />
<div className="text-xs text-slate-500">Tối thiểu 8 tự + chữ hoa + thường + số + tự đc biệt</div>
</div>
<div className="space-y-1.5">
<Label>Roles</Label>
<div className="grid grid-cols-2 gap-2">
{AVAILABLE_ROLES.map(r => (
<label key={r} className="flex cursor-pointer items-center gap-2 rounded border border-slate-200 px-2 py-1 text-xs hover:bg-slate-50">
<input
type="checkbox"
className="accent-brand-600"
checked={createForm.roles.includes(r)}
onChange={() => toggleCreateRole(r)}
/>
{RoleLabel[r]}
</label>
))}
</div>
</div>
</form>
</Dialog>
{/* Assign roles */}
<Dialog
open={!!rolesModal}
onClose={() => setRolesModal(null)}
title={`Gán role cho ${rolesModal?.fullName}`}
size="md"
footer={
<>
<Button variant="outline" onClick={() => setRolesModal(null)}>Hủy</Button>
<Button onClick={() => rolesMut.mutate()} disabled={rolesMut.isPending}>
{rolesMut.isPending ? 'Đang lưu…' : 'Lưu'}
</Button>
</>
}
>
<div className="grid grid-cols-2 gap-2">
{AVAILABLE_ROLES.map(r => (
<label key={r} className="flex cursor-pointer items-center gap-2 rounded border border-slate-200 px-2 py-1 text-xs hover:bg-slate-50">
<input
type="checkbox"
className="accent-brand-600"
checked={roleSelection.includes(r)}
onChange={() => toggleRole(r)}
/>
{RoleLabel[r]}
</label>
))}
</div>
</Dialog>
{/* Reset password */}
<Dialog
open={!!resetModal}
onClose={() => setResetModal(null)}
title={`Reset password: ${resetModal?.email}`}
size="sm"
footer={
<>
<Button variant="outline" onClick={() => setResetModal(null)}>Hủy</Button>
<Button
variant="danger"
onClick={() => resetMut.mutate()}
disabled={resetMut.isPending || newPassword.length < 8}
>
{resetMut.isPending ? 'Đang reset…' : 'Reset'}
</Button>
</>
}
>
<div className="space-y-3">
<div className="rounded bg-amber-50 px-3 py-2 text-xs text-amber-800">
User sẽ bị logout khỏi mọi session + phải login lại với password mới.
</div>
<div className="space-y-1.5">
<Label>Password mới</Label>
<Input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} minLength={8} />
</div>
</div>
</Dialog>
</div>
)
}

View File

@ -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<string, string> = {
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',
}