# 12 — Bảo mật, xác thực & cấu hình hệ thống

> File tổng hợp cách hệ thống xác thực người dùng, mã hoá mật khẩu, các validator tuỳ biến, cấu hình `services.yaml`, biến môi trường, và tech stack đầy đủ. Đọc kèm file 06 (API) và 09 (tích hợp) để có bức tranh đầy đủ về bảo mật.

## 1. Cơ chế xác thực — KHÔNG dùng Symfony Security firewall

Đây là điểm kiến trúc quan trọng nhất cần nắm: `config/packages/security.yaml` **chỉ** khai báo hasher mật khẩu (`'auto'`) và 1 entity user provider, **không khai báo firewall/access_control thật nào** cho `/api` hay `/ecom`. Toàn bộ xác thực/phân quyền được lập trình **thủ công trong từng action controller**, đúng như mô tả trong `CLAUDE.md`.

### 1.1. Luồng đăng nhập (`UserService::checkLogin`)

1. Tra `User` theo `username` (`isActive=1`, `isVerify=1`).
2. Xác thực mật khẩu qua `UserPasswordHasherInterface::isPasswordValid()`.
3. **Chặn tài khoản `ROLE_CHANNEL`** đăng nhập qua luồng thường (họ **bắt buộc** dùng OTP — xem mục 1.3), và chặn tài khoản `lockForOnlyIntegration` trừ khi đăng nhập từ nguồn `integration`.
4. Tạo token mới: `bin2hex(openssl_random_pseudo_bytes(16))` → chuỗi hex 32 ký tự.
5. Lưu vào `User.accessToken`, đặt `dateAccessTokenExpired = hiện tại + 1 ngày`, cập nhật `dateLastLogin`.
6. Trả token + thông tin hồ sơ (id, username, email, role, brand, danh sách kênh) **trực tiếp trong response JSON** — không dùng cookie/session.

### 1.2. Định dạng token

Chuỗi hex thường **32 ký tự** (`^[0-9a-z]{32}$`) — **không phải JWT, không ký số**, chỉ là chuỗi ngẫu nhiên lưu thẳng trong cột `users.access_token`. Mỗi lần đăng nhập mới sẽ **ghi đè token cũ** (vì chỉ có 1 cột lưu 1 token/user, không có bảng token riêng) — hệ quả: đăng nhập ở thiết bị mới sẽ **tự động vô hiệu hoá phiên đăng nhập cũ**.

### 1.3. Đăng nhập bằng OTP (tài khoản Channel/khách hàng B2B)

`loginForChannelResetOtp()` sinh mã OTP 5 ký tự (bảng chữ `ABCDEFGHJKMNPQRSTUVWXYZ23456789` — loại bỏ ký tự dễ nhầm như `O`/`0`/`I`/`1`), hiệu lực **3 phút**, gửi qua email (`MailService::sendChannelUserLoginOtp`). `checkLoginByOtp()` xác thực mã + hạn dùng, sau đó cấp `accessToken` giống luồng thường (hiệu lực 24h).

### 1.4. Kiểm tra token trên mỗi request — `ApiService::getToken()`

Đây là **cổng bảo vệ duy nhất** của API, được gọi thủ công ở đầu mỗi action:

```php
getToken($authorization, $mustLogin = true, $fromIntegration = false, $getUser = false)
```

Thứ tự kiểm tra: (1) nếu `$mustLogin=false` → trả về `'no_login'` ngay (dùng cho vài endpoint public); (2) header phải đúng dạng `Bearer <token>`; (3) token phải khớp regex 32-hex; (4) tra `User` theo `accessToken`; (5) kiểm tra `dateAccessTokenExpired`; (6) nếu user có `lockForOnlyIntegration=true` mà request không đến từ nguồn tích hợp (`$fromIntegration=false`) → từ chối; (7) trả về token/`User`/`null`. **Mọi exception đều bị nuốt và trả về `null`** (fail-closed — an toàn theo hướng từ chối khi có lỗi bất thường).

**Không có endpoint đăng xuất/thu hồi token tường minh** — token chỉ hết hiệu lực theo thời gian (24h) hoặc bị ghi đè bởi lần đăng nhập mới.

### 1.5. Mã hoá mật khẩu

`security.yaml` cấu hình `password_hashers: PasswordAuthenticatedUserInterface: 'auto'` — Symfony tự chọn thuật toán (Argon2id nếu có `sodium`, ngược lại bcrypt qua `password_hash()`), không tuỳ chỉnh cost riêng.

Quy tắc mật khẩu ở tầng ứng dụng (`UserService::validatePassword`): **8–16 ký tự**, phải có ít nhất 1 chữ số, 1 chữ hoa, 1 chữ thường, 1 ký tự đặc biệt, không chứa khoảng trắng.

**Ngoại lệ:** tài khoản `ROLE_CHANNEL` khi tạo mới **bỏ qua** kiểm tra quy tắc trên và được gán **mật khẩu mặc định cố định** (`default123$456#`) — vì nhóm tài khoản này luôn đăng nhập bằng OTP (mục 1.3), mật khẩu thực tế không được dùng tới.

### 1.6. Quên/đặt lại mật khẩu

`forgotPassword()` sinh token reset 20-byte hex (`resetPasswordToken`, **không có hạn dùng** — khác với `accessToken`/OTP là điểm cần lưu ý khi rà soát bảo mật), gửi email chứa link reset. `resetPassword()` xác thực token, áp lại quy tắc mật khẩu, hash lại, xoá token sau khi dùng.

### 1.7. Phân quyền theo Role

`User::getRoles()` trả về **mảng 1 phần tử** (`[$this->role]`) — mỗi user chỉ có **đúng 1 role** tại một thời điểm, không phải hệ thống multi-role. 4 role: `ROLE_SUPER_ADMIN`, `ROLE_CHANNEL`, `ROLE_PRODUCTION`, `ROLE_INTEGRATION`. Việc kiểm tra quyền theo role được lập trình **rải rác trong từng Service** (VD `ApiService::dashboard()` giới hạn số liệu theo kênh cho `ROLE_CHANNEL`), không có 1 lớp middleware/voter tập trung.

### 1.8. Session vs Stateless

`config/packages/framework.yaml` bật `session: true` (chỉ khởi tạo khi thực sự đọc/ghi) — session **chỉ dùng cho phần Webshop Bundle** (giỏ hàng, luồng huỷ đơn — xem file 07), **không** dùng cho phần API (API hoàn toàn stateless, xác thực bằng Bearer token mỗi request).

### 1.9. CORS như một lớp phòng vệ bổ sung

`config/packages/nelmio_cors.yaml`: `allow_origin` là regex lấy từ biến môi trường `CORS_ALLOW_ORIGIN` (áp dụng cho toàn bộ path `^/`), method cho phép `GET, OPTIONS, POST, PUT, PATCH, DELETE`, header `Content-Type, Authorization`. Ở môi trường dev hiện tại, giá trị này giới hạn `localhost` — **cần xác nhận giá trị production được set đúng domain frontend thật**, không để mở rộng (`.*`).

---

## 2. Validator tuỳ biến

### `UniqueProductSku` / `UniqueProductSkuValidator`

Constraint mức class (`#[UniqueProductSku]`) áp lên toàn bộ entity `Product` (`src/Entity/Product.php`) — không phải constraint mức field thông thường. Logic đầy đủ đã trình bày ở file `03-du-lieu-san-pham-san-xuat.md` mục 2: SKU unique **theo từng kênh** sản phẩm thuộc về; slug bắt buộc + unique toàn cục **chỉ khi** `showOnEcom=true`.

---

## 3. Cấu hình (`config/services.yaml`) — nhóm tham số theo tích hợp

| Nhóm | Tham số | Nguồn giá trị |
|---|---|---|
| Email/thông báo | `email_from`, `email_admin`, `email_mandrill_key` | ⚠️ Giá trị thật hard-code trực tiếp trong file |
| FTP Garp | `garp_ftp_host`, `garp_ftp_username`, `garp_ftp_password`, `garp_ftp_local_csv_path` | ⚠️ Giá trị thật hard-code trực tiếp trong file |
| SFTP xuất tồn kho Woo | `woo_ftp_host`, `woo_ftp_username`, `woo_ftp_password`, `woo_ftp_port`, `woo_ftp_remote_path`, `woo_stock_local_path` | Hiện đang để rỗng (chờ credential thật từ đối tác) |
| Đơn hàng/mặc định | `order_status_complete_id`, `order_status_web_id`, `tax_default`, `default_customer_nr_for_guest_order` | Giá trị cấu hình cứng |
| Svea | `svea_merchant_id`, `svea_merchant_secret` | ✅ **Duy nhất nhóm này** lấy từ biến môi trường thật `%env(SVEA_MERCHANT_ID/SECRET)%` |
| Swish | `swish_dir`, `swish_payee_phone` | Đường dẫn tương đối `%kernel.project_dir%` + giá trị cứng |
| PostNord | `postnord_api_key`, `postnord_api_url`, `postnord_api_pdf_url`, `postnord_consignor_party_id` | ⚠️ Giá trị thật hard-code; bộ "Test" đang active, bộ "Live" bị comment sẵn |

### ⚠️ Cảnh báo bảo mật cần xử lý

**Phần lớn credential tích hợp (FTP Garp, Mandrill API key, PostNord API key) đang lưu dưới dạng chuỗi thật, hard-code trực tiếp trong file `config/services.yaml` đã commit vào git** — chỉ riêng Svea đi qua biến môi trường đúng chuẩn (`%env(...)%`). Ngoài ra, thông tin đăng nhập (S)FTP cho các luồng nhập đơn đối tác trong `FileService.php` (Nakata, Boltic, Blavitt...) cũng hard-code trực tiếp trong code, không qua `services.yaml`/`ParameterBagInterface`.

**Khuyến nghị:** chuyển toàn bộ các credential này sang biến môi trường (theo đúng mẫu đã làm với Svea), và **xoay vòng (rotate) các secret đã từng bị commit vào lịch sử git** nếu repo này từng được chia sẻ ra ngoài phạm vi cần thiết.

### Các file cấu hình khác đáng chú ý

| File | Ghi chú |
|---|---|
| `doctrine.yaml` | DBAL URL từ `DATABASE_URL`; đăng ký hàm DQL tuỳ biến `ILIKE` (tìm kiếm không phân biệt hoa/thường kiểu Postgres); môi trường `prod` tắt tự sinh proxy và bật cache pool |
| `messenger.yaml` | Transport `async` dùng **Doctrine** (bảng trong Postgres, `auto_setup=0`) — **không phải** RabbitMQ/Redis dù có sẵn cấu hình mẫu bị comment |
| `mailer.yaml` | Chỉ `dsn: '%env(MAILER_DSN)%'` — trong `.env` hiện đang **comment** (`# MAILER_DSN=null://null`), vì email nghiệp vụ thật sự đi qua Mailchimp Transactional (`MailService`), không qua Symfony Mailer |
| `nelmio_api_doc.yaml` | Sinh OpenAPI chỉ cho `^/api/v1` — Webshop Bundle không có tài liệu API tự động (đúng bản chất vì nó trả HTML, không phải JSON API) |

---

## 4. Tech stack đầy đủ (từ `composer.json`)

| Nhóm | Thư viện |
|---|---|
| Runtime | PHP `>=8.2` |
| Framework | Symfony `7.1.*` (đầy đủ: console, framework-bundle, security-bundle, mailer, notifier, validator, serializer, messenger...) |
| ORM | `doctrine/orm` ^3.2, `doctrine/dbal` ^3, `doctrine/doctrine-bundle` ^2.12, `doctrine/doctrine-migrations-bundle` ^3.3 |
| PDF | `dompdf/dompdf` ^3.0, `iio/libmergepdf` ^4.0 |
| Excel | `phpoffice/phpspreadsheet` ^2.2 |
| Email | `mailchimp/transactional` ^1.0 |
| Thanh toán | `sveaekonomi/checkout` ^1.5 |
| SFTP | `phpseclib/phpseclib` ^3.0 |
| Phân trang | `knplabs/knp-paginator-bundle` ^6.6 |
| API docs | `nelmio/api-doc-bundle` ^5.4 |
| CORS | `nelmio/cors-bundle` ^2.5 |
| Test | `phpunit/phpunit` ^9.5, `symfony/browser-kit`, `symfony/phpunit-bridge` |

**Không có SDK Shopify chính thức** — tích hợp Shopify tự viết bằng GraphQL + cURL (xem file 09).

---

## 5. Biến môi trường

### `.env` (development — chứa giá trị thật, không public ra ngoài tài liệu này)

`APP_ENV`, `APP_SECRET`, `SVEA_MERCHANT_ID`, `SVEA_MERCHANT_SECRET`, `DATABASE_URL`, `MESSENGER_TRANSPORT_DSN`, `MAILER_DSN` (đang comment), `CORS_ALLOW_ORIGIN`.

### `.env.test`

`KERNEL_CLASS`, `APP_SECRET`, `SYMFONY_DEPRECATIONS_HELPER`, `PANTHER_APP_ENV`, `PANTHER_ERROR_SCREENSHOT_DIR`, `SVEA_MERCHANT_ID`, `SVEA_MERCHANT_SECRET`.

**Không có `.env.dist`** trong repo. **Không có** biến môi trường nào cho Mailchimp/PostNord/Swish/Garp FTP/Woo FTP — như đã nêu ở mục 3, các credential này đang nằm cứng trong `services.yaml` thay vì qua biến môi trường.

> ⚠️ **Lưu ý vận hành:** tại thời điểm khảo sát, `git status` cho thấy file `.env` đang có thay đổi **chưa commit** trong working directory. Cần kiểm tra kỹ nội dung thay đổi này trước khi commit/push — đảm bảo không vô tình đưa secret thật vào lịch sử git nếu `.env` chưa từng (hoặc không nên) được track.

---

## 6. Checklist bảo mật khi rà soát/mở rộng hệ thống

- [ ] Chuyển toàn bộ credential hard-code trong `services.yaml` (Mandrill, Garp FTP, PostNord) sang biến môi trường.
- [x] Chuyển credential SFTP tài khoản `integration` hard-code trong `FileService.php` (Nakata/Boltic/Olamerlin/377 Sport) sang cấu hình `INTEGRATION_FTP_*` (TSHIRTORDER-1627). Lưu ý: mật khẩu cũ vẫn còn trong lịch sử git nên nên đổi mật khẩu tài khoản này (giờ chỉ cần sửa `INTEGRATION_FTP_PASSWORD` ở từng môi trường).
- [ ] Chuyển credential (S)FTP hard-code còn lại sang `ParameterBagInterface`/biến môi trường: Blavitt (`blavitt_integration`, `FileService.php`) và `DownPaymentFileService` (`sveasftp`).
- [ ] Rà soát `CORS_ALLOW_ORIGIN` ở môi trường production — đảm bảo không mở rộng quá phạm vi cần thiết.
- [ ] Cân nhắc bổ sung cơ chế thu hồi token tường minh (hiện chỉ hết hạn theo thời gian).
- [ ] Cân nhắc thêm hạn dùng cho `resetPasswordToken` (hiện không có, khác với `accessToken`/OTP).
- [ ] Xác nhận `.env` không bị track trong git (hoặc nếu có, các secret trong đó đã được xoay vòng).
- [ ] Xoá/chặn các route debug (`/test1221/*` — xem file 06 mục 12) khỏi môi trường production.
