Access Control theo Domain (`config/access-control.php` · `modules/General`)
Tài liệu mô tả cơ chế giới hạn truy cập route theo tên miền (domain) của Ecommer Now:
hệ thống chạy nhiều domain trên cùng một codebase (app, tracking, api, intl),
và cần đảm bảo mỗi domain chỉ chạm được đúng phần route của nó.
Ví dụ: domain tracking.Ecommer.vn là trang tra cứu đơn cho khách vãng lai —
nó chỉ được gọi /orders/tracking/search, không được đụng tới /api/* hay trang admin,
kể cả khi ai đó đoán đúng URL.
Tài liệu này viết theo hướng cầm tay chỉ việc: đọc xong bạn có thể tự gõ lại toàn bộ cơ chế từ đầu trong một dự án Laravel trống. Toàn bộ code thật của 4 file liên quan đều được chép nguyên văn ở phần Hướng dẫn dựng lại từ đầu.
1. Bản đồ file
| File | Vai trò |
|---|---|
config/access-control.php |
Toàn bộ cấu hình: danh sách domain, công tắc bật/tắt, 2 bảng luật |
modules/General/Traits/RouteDomainPatternMatcher.php |
Trait dùng chung: nhận diện domain + so khớp pattern route |
modules/General/Http/Middleware/RestrictDomainRoutes.php |
Chặn theo chiều domain → route |
modules/General/Http/Middleware/VerifyRouteDomain.php |
Chặn theo chiều route → domain |
modules/General/Helpers/Helper.php::normalizeDomain() |
Chuẩn hoá chuỗi domain trước khi so sánh |
app/Http/Kernel.php |
Đăng ký 2 middleware vào $middleware (global) |
.env / .env.example |
Giá trị domain thật theo từng môi trường |
2. Cấu hình domains — trái tim của cơ chế
/*
|--------------------------------------------------------------------------
| Domains
|--------------------------------------------------------------------------
|
| List of domains used in the system.
| The array key is a short identifier (e.g., app, tracking, api, intl).
| The value is loaded from .env or a default fallback.
|
*/
'domains' => [
'app' => env('APP_URL', 'Ecommer.vn'),
'tracking' => env('DOMAIN_TRACKING_URL', 'tracking.Ecommer.vn'),
'api' => env('DOMAIN_API_URL', 'api.Ecommer.vn'),
'intl' => env('DOMAIN_INTL_URL', 'intl.Ecommer.vn'),
],
Cách đọc mảng này:
- Key (
app,tracking,api,intl) là định danh ngắn — đây là thứ bạn dùng ở mọi nơi khác trong file config (restrict_domains,allow_routes,ACCESS_CONTROL_DOMAINS_ALLOW_ALL_ROUTES). Key không bao giờ đổi giữa các môi trường. - Value là domain thật của môi trường hiện tại, lấy từ
.env. Local làtracking.hasakinow.local, production làtracking.Ecommer.vn— nhưng key vẫn làtracking, nên luật không phải sửa khi deploy. - Tham số thứ hai của
env()là giá trị mặc định khi thiếu biến trong.env(fallback về domain production).
Đây chính là lý do phải tách key/value: luật viết theo ý nghĩa (tracking),
còn .env lo phần địa chỉ cụ thể (tracking.Ecommer.local).
Biến .env liên quan
# Domain của từng cổng vào (không cần http://, có cũng được — sẽ được chuẩn hoá)
APP_URL=https://Ecommer.local
DOMAIN_API_URL=api.Ecommer.local
DOMAIN_TRACKING_URL=tracking.Ecommer.local
DOMAIN_INTL_URL=intl.Ecommer.local
# Công tắc bật/tắt từng chiều kiểm tra
ACCESS_CONTROL_RESTRICT_DOMAIN_ENABLED=true
ACCESS_CONTROL_ALLOW_ROUTE_ENABLED=true
# Danh sách key domain được bỏ qua HOÀN TOÀN cả 2 middleware (ngăn cách bởi dấu phẩy)
ACCESS_CONTROL_DOMAINS_ALLOW_ALL_ROUTES=app
⚠️ Sửa
.envxong bắt buộc chạyphp artisan config:clear, nếu khôngconfig:cachecũ vẫn giữ domain cũ và bạn sẽ debug nhầm chỗ.
3. Hai chiều kiểm tra — đừng nhầm lẫn
Hệ thống có 2 bảng luật ngược chiều nhau, chạy nối tiếp. Một request phải qua cả hai.
┌──────────────────────┐ ┌────────────────────┐
Request ──────────►│ RestrictDomainRoutes │───────►│ VerifyRouteDomain │──────► Controller
(host + path) │ domain → route? │ │ route → domain? │
└──────────────────────┘ └────────────────────┘
"Domain này được đi "Route này cho phép
những route nào?" những domain nào?"
đọc: restrict_domains đọc: allow_routes
RestrictDomainRoutes |
VerifyRouteDomain |
|
|---|---|---|
| Công tắc | enabled_restrict_domain |
enabled_allow_route |
| Bảng luật | restrict_domains |
allow_routes |
| Key của bảng | định danh domain | pattern route |
| Value của bảng | danh sách pattern route | danh sách định danh domain |
| Mặc định khi không có trong bảng | cho qua (domain tự do) | cho qua (route tự do) |
Domain lạ (không có trong domains) |
cho qua | bị chặn nếu route có trong allow_routes |
Điểm mấu chốt: cả hai đều là whitelist "chỉ khi được nhắc tên".
Nếu một domain không xuất hiện trong restrict_domains thì nó đi được mọi route;
nếu một route không xuất hiện trong allow_routes thì mọi domain gọi được.
Chỉ khi bạn nêu tên nó ra thì nó mới bị khoá vào đúng danh sách đã khai báo.
3.1 restrict_domains — khoá domain vào một nhóm route
'restrict_domains' => [
'tracking' => [
'/orders/tracking/search',
],
'intl' => [
'/api/partner-party/*',
'/api/v2/partner-party/*',
'/api/Ecommer/*',
'/api/test-request',
],
'api' => [
'/api/*',
],
],
Đọc là: "domain tracking chỉ được vào /orders/Ecommer/search; mọi path khác → 403."
Domain app không có trong bảng nên không bị giới hạn ở bước này.
3.2 allow_routes — khoá route vào một nhóm domain
'allow_routes' => [
'/api/Ecommer/*' => ['intl', 'api'],
'/api/partner-party/Ecommer/*' => ['intl', 'api'],
'/api/v2/partner-party/Ecommer/*' => ['intl', 'api'],
'/api/partner-party/*' => ['intl'],
'/api/v2/partner-party/*' => ['intl'],
'/api/test-request' => ['intl', 'api'],
'/api/*' => ['api'],
],
Đọc là: "/api/Ecommer/* chỉ mở cho domain intl và api."
🔴 Thứ tự trong
allow_routesrất quan trọng. Middleware duyệt từ trên xuống và dừng ngay ở pattern đầu tiên khớp. Vì vậy/api/*(catch-all) phải nằm cuối cùng; luật hẹp (/api/partner-party/hskwork/*) phải nằm trên luật rộng (/api/partner-party/*). Đảo thứ tự = các luật phía dưới vĩnh viễn không bao giờ chạy.
3.3 domains_allow_all_routes — cửa thoát hiểm
'domains_allow_all_routes' => explode(',', env('ACCESS_CONTROL_DOMAINS_ALLOW_ALL_ROUTES', '')),
Domain nào có key trong danh sách này sẽ bỏ qua cả 2 middleware. Trong .env hiện tại là app
— cần thiết vì luật cuối '/api/*' => ['api'] sẽ chặn chính domain chính gọi API của mình.
4. Ma trận truy cập thực tế (theo config hiện tại)
| Path | app |
api |
intl |
tracking |
domain lạ |
|---|---|---|---|---|---|
/orders/tracking/search |
✅ | ❌¹ | ❌¹ | ✅ | ✅ |
/orders/... (khác) |
✅ | ❌¹ | ❌¹ | ❌¹ | ✅ |
/api/Ecommer/* |
✅ | ✅ | ✅ | ❌¹ | ❌² |
/api/partner-party/Ecommer/* |
✅ | ✅ | ✅ | ❌¹ | ❌² |
/api/partner-party/* (khác) |
✅ | ❌² | ✅ | ❌¹ | ❌² |
/api/test-request |
✅ | ✅ | ✅ | ❌¹ | ❌² |
/api/* (còn lại) |
✅ | ✅ | ❌¹ | ❌¹ | ❌² |
trang admin /admin/* |
✅ | ❌¹ | ❌¹ | ❌¹ | ✅ |
¹ chặn bởi RestrictDomainRoutes · ² chặn bởi VerifyRouteDomain
· cột app toàn ✅ vì nằm trong ACCESS_CONTROL_DOMAINS_ALLOW_ALL_ROUTES.
5. Luồng xử lý chi tiết
flowchart TD
A[Request tới] --> B{enabled_restrict_domain?}
B -- false --> F
B -- true --> C[getDomainName<br/>host → key domain]
C --> D{Domain có trong<br/>config domains?}
D -- không --> F
D -- có --> E{Key nằm trong<br/>domains_allow_all_routes?}
E -- có --> N[Cho qua tới controller]
E -- không --> G{restrict_domains có<br/>key này không?}
G -- không --> F
G -- có --> H{path khớp 1 pattern<br/>trong danh sách?}
H -- không --> X1[LogSlackQueue WARNING<br/>HttpException 403]
H -- có --> F
F{enabled_allow_route?} -- false --> N
F -- true --> I[getDomainName lần nữa]
I --> J{allow_routes rỗng?}
J -- rỗng --> N
J -- không --> K[Duyệt allow_routes từ trên xuống]
K --> L{Tìm được pattern<br/>khớp path?}
L -- không --> N
L -- có --> M{Domain hiện tại nằm trong<br/>danh sách cho phép?}
M -- có --> N
M -- không --> X2[LogSlackQueue WARNING<br/>HttpException 403]
5.1 Nhận diện domain — getDomainName()
$request->getHost() trả về TRACKING.Ecommer.vn/ hay https://www.tracking... tuỳ nguồn,
nên phải chuẩn hoá cả hai vế trước khi so sánh, rồi array_search để lấy ngược ra key:
protected function getDomainName(string $url): ?string
{
$domains = array_map(static function ($item) {
return Helper::normalizeDomain($item);
}, config('access-control.domains'));
return array_search(Helper::normalizeDomain($url), $domains) ?: null;
}
Helper::normalizeDomain() lần lượt: bỏ http:// / https:// → bỏ www. → bỏ / ở cuối →
cắt phần path phía sau → strtolower + trim.
Nhờ vậy APP_URL=https://hasakinow.local hay DOMAIN_API_URL=api-test.Ecommer.vn/
(có dấu / thừa như trong .env hiện tại) vẫn khớp đúng.
Không tìm thấy → trả null = domain lạ.
5.2 So khớp path — matchPattern()
| Dạng pattern | Ví dụ | Ý nghĩa |
|---|---|---|
| Chính xác | /api/test-request |
=== tuyệt đối |
* |
* |
khớp mọi path |
Kết thúc /* |
/api/Ecommer/* |
prefix — khớp mọi path bắt đầu bằng /api/Ecommer |
Bắt đầu */ |
*/export |
suffix — khớp mọi path kết thúc bằng /export |
⚠️ Cạm bẫy: prefix được tính bằng
rtrim($pattern, '/*'), tức/api/*→/api, rồistr_starts_with(). Nghĩa là/api/*cũng khớp/apixyz(không có dấu/ngăn cách). Khi đặt pattern mới, tránh những tên route chỉ khác nhau ở phần đuôi liền mạch.
5.3 Khi bị chặn
Cả hai middleware đều làm đúng 2 việc:
LogSlackQueue::dispatch(...)ở mứcLogLevel::WARNING— bắn cảnh báo lên Slack (job chạy trên queuelogs, không chặn request, không ném lại lỗi).throw new HttpException(403, "Access denied: ...")— thông điệp nêu rõ domain và path.
Nghĩa là mọi lần 403 vì domain đều để lại dấu vết trên Slack — chỗ đầu tiên nên nhìn khi có báo "API tự nhiên trả 403".
6. Hướng dẫn dựng lại từ đầu (gõ tay được)
Làm tuần tự 5 bước dưới đây là có một hệ access-control-by-domain hoàn chỉnh.
Bước 1 — config/access-control.php
<?php
return [
'enabled_restrict_domain' => (bool) env('ACCESS_CONTROL_RESTRICT_DOMAIN_ENABLED', true),
'enabled_allow_route' => (bool) env('ACCESS_CONTROL_ALLOW_ROUTE_ENABLED', true),
'domains' => [
'app' => env('APP_URL', 'Ecommer.vn'),
'tracking' => env('DOMAIN_TRACKING_URL', 'tracking.Ecommer.vn'),
'api' => env('DOMAIN_API_URL', 'api.Ecommer.vn'),
'intl' => env('DOMAIN_INTL_URL', 'intl.Ecommer.vn'),
],
'domains_allow_all_routes' => explode(',', env('ACCESS_CONTROL_DOMAINS_ALLOW_ALL_ROUTES', '')),
'restrict_domains' => [
// 'key-domain' => ['pattern route được phép'],
],
'allow_routes' => [
// 'pattern route' => ['key-domain được phép'],
],
];
Bước 2 — Helper chuẩn hoá domain
Thêm vào modules/General/Helpers/Helper.php:
/**
* Normalize domain for comparison
* Remove http://, https://, www. and trailing slashes
*
* @param string $domain
*
* @return string
*/
public static function normalizeDomain(string $domain): string
{
// Remove protocol (http://, https://)
$domain = preg_replace('#^https?://#', '', $domain);
// Remove www. if present
$domain = preg_replace('/^www\./', '', $domain);
// Remove trailing slashes
$domain = rtrim($domain, '/');
// Remove path after domain
$domain = explode('/', $domain)[0];
return strtolower(trim($domain));
}
Bước 3 — Trait dùng chung
modules/General/Traits/RouteDomainPatternMatcher.php:
<?php
namespace Modules\General\Traits;
use Modules\General\Helpers\Helper;
trait RouteDomainPatternMatcher
{
private const WILDCARD_ALL = '*';
private const WILDCARD_PREFIX = '/*';
private const WILDCARD_SUFFIX = '*/';
protected function getDomainName(string $url): ?string
{
$domains = array_map(static function ($item) {
return Helper::normalizeDomain($item);
}, config('access-control.domains'));
return array_search(Helper::normalizeDomain($url), $domains) ?: null;
}
protected function matchPattern(string $pattern, string $path): bool
{
if (empty($pattern) || empty($path)) {
return false;
}
// Exact match
if ($pattern === $path) {
return true;
}
// Wildcard to allow all routes
if ($pattern === self::WILDCARD_ALL) {
return true;
}
// Prefix match: starts with
if (str_ends_with($pattern, self::WILDCARD_PREFIX)) {
$prefix = rtrim($pattern, self::WILDCARD_PREFIX);
return str_starts_with($path, $prefix);
}
// Suffix match: ends with
if (str_starts_with($pattern, self::WILDCARD_SUFFIX)) {
$suffix = substr($pattern, 1); // remove '*'
return str_ends_with($path, $suffix);
}
return false;
}
protected function allowAllRoutes(?string $domain): bool
{
if ($domain && in_array($domain, config('access-control.domains_allow_all_routes'))) {
return true;
}
return false;
}
}
Bước 4 — Hai middleware
modules/General/Http/Middleware/RestrictDomainRoutes.php — chiều domain → route:
<?php
namespace Modules\General\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Modules\General\Helpers\Helper;
use Modules\General\Traits\RouteDomainPatternMatcher;
use Modules\Schedule\Jobs\LogSlackQueue;
use Psr\Log\LogLevel;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Middleware to restrict which routes can be accessed from each domain.
* - If the domain is not in the config or has no allowed routes, all routes are accessible.
* - If the domain is in the config but tries to access a disallowed route, a 403 is returned and a warning is logged.
*/
class RestrictDomainRoutes
{
use RouteDomainPatternMatcher;
public function handle(Request $request, Closure $next): mixed
{
// If domain restriction is not enabled, allow all requests
if (!config('access-control.enabled_restrict_domain')) {
return $next($request);
}
$currentHost = $request->getHost();
$currentHostName = $this->getDomainName($currentHost);
if (empty($currentHostName)) {
// If the domain is not recognized, allow access
return $next($request);
}
if ($this->allowAllRoutes($currentHostName)) {
return $next($request);
}
$path = '/' . ltrim($request->path(), '/');
$allowedRoutes = config('access-control.restrict_domains')[$currentHostName] ?? [];
if (empty($allowedRoutes)) {
// If there are no restrictions for this domain, allow access
return $next($request);
}
foreach ($allowedRoutes as $pattern) {
if ($this->matchPattern($pattern, $path)) {
// If the route matches an allowed pattern, allow access
return $next($request);
}
}
// Log and deny access if the route is not allowed for this domain
LogSlackQueue::dispatch(
sprintf(
"%s Access denied: Domain '%s' is not allowed to access route '%s'",
__CLASS__,
$currentHost,
$path
),
[
'time' => now()->format('Y-m-d H:i:s.u (T)'),
'domain' => $currentHost,
'path' => $path,
'allowed_routes' => $allowedRoutes,
],
LogLevel::WARNING
);
throw new HttpException(
403,
sprintf(
"Access denied: Domain '%s' is not allowed to access route '%s'",
$currentHost,
$path
)
);
}
protected function isDomainAllowed(string $domain, string $normalizedCurrentHost): bool
{
$domain = $this->getDomainConfig($domain);
if (empty($domain)) {
return false;
}
return Helper::normalizeDomain($domain) === $normalizedCurrentHost;
}
private function getDomainConfig(string $domain): ?string
{
return config('access-control.domains')[$domain] ?? null;
}
}
modules/General/Http/Middleware/VerifyRouteDomain.php — chiều route → domain:
<?php
namespace Modules\General\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Modules\General\Traits\RouteDomainPatternMatcher;
use Modules\Schedule\Jobs\LogSlackQueue;
use Psr\Log\LogLevel;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Middleware to control which domains are allowed to access specific routes.
* - If a route is not defined in allow_routes, all domains can access it.
* - If a route is defined but the domain is not allowed, a 403 is returned and a warning is logged.
*/
class VerifyRouteDomain
{
use RouteDomainPatternMatcher;
public function handle(Request $request, Closure $next): mixed
{
// If route access control is not enabled, allow all requests
if (!config('access-control.enabled_allow_route')) {
return $next($request);
}
$currentHost = $request->getHost();
$currentHostName = $this->getDomainName($currentHost);
if ($this->allowAllRoutes($currentHostName)) {
return $next($request);
}
$path = '/' . ltrim($request->path(), '/');
$allowRoutes = config('access-control.allow_routes', []);
if (empty($allowRoutes)) {
// If there are no allow_routes defined, allow all requests
return $next($request);
}
foreach ($allowRoutes as $route => $domainNames) {
if (!$this->matchPattern($route, $path)) {
continue;
}
// If the route matches but the domain is not allowed, deny access
if (!in_array($currentHostName, $domainNames)) {
LogSlackQueue::dispatch(
sprintf(
"%s Access denied: Route '%s' is not allowed on domain '%s'",
__CLASS__,
$path,
$currentHost
),
[
'time' => now()->format('Y-m-d H:i:s.u (T)'),
'domain' => $currentHost,
'path' => $path,
],
LogLevel::WARNING
);
throw new HttpException(
403, sprintf(
"Access denied: Route '%s' is not allowed on domain '%s'",
$path,
$currentHost
)
);
}
// If the route matches and the domain is allowed, allow access
return $next($request);
}
// If the route does not match any allow_routes, allow access
return $next($request);
}
}
Bước 5 — Đăng ký ở app/Http/Kernel.php
Cả hai nằm trong $middleware (global, chạy cho mọi request — kể cả route không tồn tại),
đặt sau HandleCors để preflight OPTIONS vẫn được xử lý bình thường:
use Modules\General\Http\Middleware\RestrictDomainRoutes;
use Modules\General\Http\Middleware\VerifyRouteDomain;
protected $middleware = [
\App\Http\Middleware\CheckForMaintenanceMode::class,
// \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\Modules\Api\Base\Http\Middleware\ApiValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
\App\Http\Middleware\TrustProxies::class,
\Fruitcake\Cors\HandleCors::class,
RestrictDomainRoutes::class,
VerifyRouteDomain::class,
];
Đây là 2 middleware của modules/ nhưng đăng ký trong app/Http/Kernel.php — vì
global middleware stack chỉ khai báo được ở đó (không đi qua ServiceProvider của module).
7. Công thức thêm mới
Thêm một domain mới (ví dụ partner)
.env+.env.example:DOMAIN_PARTNER_URL=partner.Ecommer.localconfig/access-control.php→domains:'partner' => env('DOMAIN_PARTNER_URL', 'partner.Ecommer.vn'),- Khai báo luật: thêm
'partner' => [...]vàorestrict_domains, và/hoặc thêm'partner'vào các pattern trongallow_routes. php artisan config:clear
Mở một route cho domain đã có
- Muốn domain X đi thêm được route R → thêm pattern
Rvàorestrict_domains['X']. - Muốn route R nhận thêm domain X → thêm
'X'vàoallow_routes['R']. - Thường phải làm cả hai nếu domain X đang bị khoá ở
restrict_domainsvà route R đang được liệt kê ởallow_routes. Quên một trong hai là vẫn 403.
Kiểm chứng nhanh bằng curl
# Giả lập domain tracking gọi đúng route của nó → 200
curl -i -H "Host: tracking.hasakinow.local" http://127.0.0.1/orders/tracking/search
# Giả lập domain tracking gọi API → 403 (RestrictDomainRoutes)
curl -i -H "Host: tracking.hasakinow.local" http://127.0.0.1/api/v1/orders
# Giả lập domain lạ gọi API → 403 (VerifyRouteDomain)
curl -i -H "Host: evil.example.com" http://127.0.0.1/api/v1/orders
8. Cạm bẫy thường gặp
| Triệu chứng | Nguyên nhân hay gặp |
|---|---|
Sửa .env mà không có tác dụng |
Chưa php artisan config:clear (config cache cũ) |
Route mới thêm vào allow_routes không ăn |
Đặt dưới /api/* — pattern đầu tiên khớp đã return |
| Local bị 403 hàng loạt | .env local thiếu DOMAIN_* → fallback về domain production, không domain nào khớp |
| Domain chính không gọi được API của mình | Thiếu app trong ACCESS_CONTROL_DOMAINS_ALLOW_ALL_ROUTES, dính luật '/api/*' => ['api'] |
Sau reverse proxy, getHost() sai |
Kiểm tra App\Http\Middleware\TrustProxies (X-Forwarded-Host) |
Sửa restrict_domains xong vẫn 403 |
Còn vướng chiều thứ hai — kiểm tra tiếp allow_routes |
| Muốn tắt tạm để debug | ACCESS_CONTROL_RESTRICT_DOMAIN_ENABLED=false và/hoặc ACCESS_CONTROL_ALLOW_ROUTE_ENABLED=false (chỉ ở local) |
Đây là lớp phân tách bề mặt tấn công theo domain, không phải lớp xác thực. Quyền thật sự vẫn do
api.auth(token) vàpermission.hsk:<key>(Entrust) quyết định.
Liên quan
docs/architecture/secure-payload.md— mã hoá payload cho API mobileAPI_ROUTES.md— danh mục endpoint API theo moduleconfig/app.php(url_api,url_tracking,url_intl) — cùng nguồn.env, dùng để sinh URL, khác mục đích vớiaccess-control.domainsdùng để kiểm tra host
All rights reserved