0

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 .env xong bắt buộc chạy php artisan config:clear, nếu không config:cache cũ 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_routes rấ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ồi str_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:

  1. LogSlackQueue::dispatch(...) ở mức LogLevel::WARNING — bắn cảnh báo lên Slack (job chạy trên queue logs, không chặn request, không ném lại lỗi).
  2. 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)

  1. .env + .env.example: DOMAIN_PARTNER_URL=partner.Ecommer.local
  2. config/access-control.php → domains: 'partner' => env('DOMAIN_PARTNER_URL', 'partner.Ecommer.vn'),
  3. Khai báo luật: thêm 'partner' => [...] vào restrict_domains, và/hoặc thêm 'partner' vào các pattern trong allow_routes.
  4. php artisan config:clear

Mở một route cho domain đã có

  • Muốn domain X đi thêm được route R → thêm pattern R vào restrict_domains['X'].
  • Muốn route R nhận thêm domain X → thêm 'X' vào allow_routes['R'].
  • Thường phải làm cả hai nếu domain X đang bị khoá ở restrict_domains và 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 mobile
  • API_ROUTES.md — danh mục endpoint API theo module
  • config/app.php (url_api, url_tracking, url_intl) — cùng nguồn .env, dùng để sinh URL, khác mục đích với access-control.domains dùng để kiểm tra host

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí