0

Passportless for Laravel

Laravel vốn đã có hai giải pháp mạnh cho xác thực API: Passport và Sanctum. Trong một thời gian dài, mình nghĩ lựa chọn chỉ có vậy: cần token thì chọn một trong hai.

Nhưng rồi mình liên tục gặp cùng một kiểu dự án:

  • Ứng dụng Laravel của mình phát hành token cho các client do chính mình sở hữu: ứng dụng di động, CLI, giao diện quản trị nội bộ, hay dịch vụ gọi lẫn nhau.
  • Mình không cần ứng dụng bên thứ ba đăng nhập qua OAuth2.
  • Muốn access token có thời hạn ngắn, refresh token được xoay vòng, đăng xuất theo thiết bị và kiểm tra ability.
  • Muốn mọi thứ hoạt động như guard và middleware Laravel thông thường.

Khoảng trống đó là lý do Passportless for Laravel ra đời. Đây không phải bài hướng dẫn từng dòng mã, mà là câu chuyện về lý do sản phẩm: Passport và Sanctum đã làm tốt những gì, chúng còn để ngỏ điều gì với API first-party, và vì sao mình chọn một package nhỏ, chuyên biệt thay vì ép một trong hai công cụ vào vai trò vốn không dành cho nó.

Quyết định thực sự không phải là "package nào ngầu hơn"

Mà là thế này:

Bạn cần... Lựa chọn phù hợp hơn
Ứng dụng bên thứ ba, màn hình cấp quyền, OAuth client, authorization code + PKCE, các grant flow chuẩn Laravel Passport
SPA first-party dùng session cookie, hoặc personal API token đơn giản, tồn tại lâu Laravel Sanctum
API token first-party với access token ngắn hạn, refresh rotation, phát hiện tái sử dụng, token session, nhiều guard sở hữu token và không cần OAuth2 Passportless (hoặc tự xây)

Tài liệu Laravel cũng phân định tương tự. Passport là OAuth2 server khi bạn thực sự cần OAuth2. Sanctum là lựa chọn gọn nhẹ cho SPA, ứng dụng di động và API token đơn giản. Không công cụ nào tự nhận mình là giải pháp "xác thực token first-party có xoay vòng, có session theo thiết bị và không có bề mặt OAuth".

Vì vậy, câu hỏi không phải là "Passport dở, Sanctum dở". Câu hỏi là: bài toán của mình có khớp với trọng tâm thiết kế của chúng không?

Điều mình thực sự cần

Trong phần lớn ứng dụng mình xây, xác thực diễn ra như sau:

  1. Người dùng, nhân viên hoặc service account xác thực với backend của mình.
  2. Backend phát hành access token, và thường kèm refresh token.
  3. Client gọi API bằng credential đó.
  4. Mình có thể thu hồi quyền truy cập của một thiết bị mà không đăng xuất tất cả thiết bị.
  5. Access token hết hạn nhanh; refresh token được xoay vòng; refresh token bị dùng lại sau khi đã xoay vòng được coi là dấu hiệu bị đánh cắp.
  6. Ability giới hạn những việc token được phép làm, chẳng hạn orders:read, thay vì "toàn quyền tài khoản mãi mãi".
  7. Đôi khi một ứng dụng có nhiều model có thể xác thực riêng biệt, như UserStaff; chúng tuyệt đối không được dùng chung danh tính trên cùng một đường xác thực bằng token.

Mình kiểm soát cả hai phía của ranh giới tin cậy. Không có danh bạ client bên ngoài, không có quy trình chuyển hướng authorization code, cũng không có quyền truy cập ủy quyền cho bên thứ ba.

Đó là xác thực token first-party. Passportless chỉ tập trung vào đúng nhu cầu đó.

Vì sao không dùng Laravel Passport

Passport rất tốt ở vai trò của nó: một OAuth2 authorization server đầy đủ dựa trên League OAuth2 Server.

Bạn có client, secret, redirect URI, grant type, scope, authorization endpoint và toàn bộ chi phí vận hành đi kèm. Đó là stack phù hợp khi bên thứ ba cần quyền truy cập được ủy quyền vào dữ liệu người dùng qua các flow OAuth2 chuẩn.

Nhưng với API first-party của mình, đó lại là cái giá không cần thiết:

  • Những khái niệm OAuth2 mình không cần. Đăng ký client, chuyển hướng authorization code, PKCE, password grant, client credentials và scope chính thức đều giải quyết bài toán ủy quyền cho bên thứ ba. Ứng dụng di động hay trang quản trị của mình không phải OAuth client bên thứ ba theo nghĩa đó.
  • Độ phức tạp vận hành không tạo thêm giá trị sản phẩm. Key, client, grant, redirect và quản trị scope đều cần bảo trì thực sự. Nếu không bao giờ có nhà phát triển bên ngoài đăng ký OAuth client, mình đang trả giá cho một bộ máy người dùng sẽ không bao giờ chạm đến.
  • Mô hình tư duy không phù hợp với đội ngũ. Ability trên token first-party không phải OAuth scope. Coi chúng như nhau dễ dẫn đến các cuộc thảo luận bảo mật và thiết kế API sai hướng.
  • Mình vẫn chỉ cần cơ chế xác thực thuần Laravel. Guard, middleware, model và config, chứ không phải một authorization server gắn thêm vào API nội bộ.

Quyết định dành cho package này rất rõ ràng: giữ access token first-party, refresh token, cơ chế xoay vòng, session theo thiết bị và token ability. Không triển khai OAuth2. OAuth2 flow, đăng ký client và ủy quyền cho bên thứ ba nằm ngoài phạm vi sử dụng mục tiêu.

Nếu bạn cần ủy quyền cho bên thứ ba, hãy dùng Passport. Passportless không phải bản thay thế Passport.

Vì sao không dùng Laravel Sanctum

Sanctum cũng rất tốt ở vai trò của nó: một hệ thống xác thực cực nhẹ cho SPA first-party và API token đơn giản.

Nó có hai chế độ chính:

  1. Xác thực SPA: xác thực session dựa trên cookie kèm bảo vệ CSRF. Đây là hướng Laravel khuyến nghị cho SPA của bạn giao tiếp với API của bạn.
  2. API token: personal access token được lưu dưới dạng hash, có thể kèm ability. Phù hợp cho flow đơn giản: "phát token, gửi Bearer, thu hồi sau".

Với xác thực session SPA first-party truyền thống, Sanctum vẫn là lựa chọn mặc định tốt. Passportless không cố thay thế phần này.

Mình cần một giải pháp khác khi phần API token của Sanctum không còn khớp với mô hình bảo mật mình muốn:

  • Personal token thường tồn tại lâu, thay vì một cặp token xoay vòng. API token của Sanctum là bearer credential đơn giản. Thời hạn là tùy chọn; không có cơ chế refresh-token rotation và phát hiện tái sử dụng như một tính năng lõi. Mình cần access token ngắn hạn cùng refresh token dài hạn, được xoay vòng mỗi lần sử dụng.
  • Không có mô hình phát hiện đánh cắp theo token family. Nếu refresh credential bị đánh cắp rồi bị phát lại sau khi đã xoay vòng, mình muốn toàn bộ family bị thu hồi mặc định. Đây là tính năng bảo mật có chủ đích trong Passportless, không phải thứ mình muốn tự chắp vá ở mỗi ứng dụng.
  • Session theo thiết bị là một tính năng sản phẩm. "Đăng xuất iPhone này" và "đăng xuất mọi thiết bị" cần mô hình nhóm session, không chỉ một tập hợp token độc lập, cùng API riêng để thu hồi một session hoặc tất cả session của một guard.
  • Nhiều model có thể xác thực là các ranh giới danh tính. Một ứng dụng Laravel có cả UserStaff phải phát hành token không thể vượt qua guard/provider, ngay cả khi ID số trùng nhau. Passportless coi named Laravel guard là danh tính xác thực và lưu snapshot guard/provider trên token.
  • Trình duyệt chỉ là một cách truyền cùng token, không phải mô hình xác thực thứ hai. Với browser client, mình vẫn muốn access/refresh cookie HttpOnly và một CSRF cookie mà JavaScript có thể đọc để dùng double-submit. Chế độ SPA của Sanctum là dựa trên session. Passportless vẫn ở làn token: ưu tiên Bearer, rồi mới đến access cookie theo guard; có route login/refresh/logout cho SPA nếu cần; middleware CSRF và same-origin là các alias opt-in. Ứng dụng chủ vẫn kiểm soát CORS, ngoại lệ mã hóa cookie và policy.

API token của Sanctum được chủ đích giữ đơn giản. Sự đơn giản ấy là ưu điểm khi bạn cần token đơn giản. Nó trở thành khoảng trống khi bạn muốn hành vi token xoay vòng hiện đại mà không cần chuyển sang OAuth2 đầy đủ.

Khoảng trống mình cứ phải tự giải quyết bằng tay

Trước khi có package, phiên bản trong controller thường khởi đầu rất vô hại:

$plainTextToken = Str::random(40);

auth()->user()->tokens()->create([
    'name' => 'iphone',
    'token' => $plainTextToken,
]);

Rồi các yêu cầu thực tế lần lượt xuất hiện:

  • Ngừng lưu token thô; lưu hash và chỉ trả id|secret một lần.
  • Tích hợp với Laravel guard thay vì kiểm tra ad-hoc trong controller.
  • Đặt thời hạn cho access token.
  • Gắn ability và thực thi bằng middleware.
  • Nhóm token thành session để đăng xuất theo thiết bị.
  • Thêm refresh token, rotation, lockForUpdate và phát hiện tái sử dụng.
  • Hỗ trợ guard/provider riêng cho khách hàng và nhân viên.
  • Tạo cookie HttpOnly cho browser client mà không đưa secret vào localStorage.
  • Kết nối CSRF và kiểm tra same-origin cho trình duyệt mà không phải tạo thêm một session stack song song.
  • Cung cấp logout thu hồi cả session hoặc tất cả session, thay vì chỉ credential đang có trong tay.
  • Phát hiện sai cấu hình cookie path, guard và CORS trước khi lên production.

Mỗi ứng dụng lại tự làm lại một nửa danh sách này theo một cách hơi khác. Passport quá nặng. Sanctum quá mỏng cho hướng token xoay vòng. Passportless là phần ở giữa có thể tái sử dụng: xác thực token first-party với những hành vi bảo mật mình thực sự triển khai.

Passportless tối ưu cho điều gì

Ở cấp độ sản phẩm, package cần mang cảm giác thuần Laravel nhưng vẫn gọn nhỏ:

use l3aro\Passportless\Concerns\HasPassportless;

class User extends Authenticatable
{
    use HasPassportless;
}

$token = $user->createToken('iphone', ['orders:read', 'orders:write']);

$staffToken = $staff->createToken('admin', ['staff:read'], guard: 'passportless-admin');
Route::get('/orders', OrdersController::class)
    ->middleware(['auth:passportless-client', 'abilities:orders:read']);

Những hành vi lõi khiến một package trở nên cần thiết thay vì "chỉ dùng token của Sanctum":

  • Access token được hash; giá trị thô chỉ được trả về khi phát hành hoặc làm mới.
  • Refresh-token rotation là tùy chọn, với cách xử lý tái sử dụng có thể cấu hình; mặc định là thu hồi cả family.
  • Token session hỗ trợ thu hồi theo thiết bị (logoutCurrentSession, logoutAllSessions, đăng xuất bằng refresh token cho client chỉ dùng cookie).
  • Named Laravel guard là ranh giới danh tính cho các model có thể xác thực khác nhau.
  • Ability first-party, dùng middleware abilities / ability, và rõ ràng không phải OAuth scope.
  • Hai đường cho client nhưng cùng một mô hình token: Bearer cho mobile/CLI/server; cookie HttpOnly cùng các route SPA tùy chọn cho trình duyệt.
  • Middleware trình duyệt opt-in (passportless.csrf, passportless.origin) và passportless:doctor để kiểm tra cấu hình.
  • Không có OAuth2 server, không ép buộc route đăng nhập, không có danh bạ client bên thứ ba.

Các non-goal vẫn là non-goal: Authorization Code, PKCE, client credentials, password grant, đăng ký client bên thứ ba, social login và tính năng identity provider. Những việc đó thuộc về Passport hoặc công cụ IdP chuyên biệt.

Package thể hiện lý do đó trong mã nguồn ra sao

Hash một lần, so sánh an toàn

Database chỉ lưu hash('sha256', $plainTextToken). Client chỉ nhận id|plain một lần. Việc tra cứu dựa vào id, còn xác thực dùng hash_equals.

public function createToken(
    Model $tokenable,
    string $name,
    array $abilities = ['*'],
    ?DateTimeInterface $expiresAt = null,
    string|int|null $sessionId = null,
    ?string $guard = null,
): NewAccessToken {
    $resolved = $this->authBindings->resolve($guard);
    $this->assertTokenableMatchesBinding($tokenable, $resolved);

    $plainTextToken = Str::random(40);

    $token = $tokenable->morphMany(PersonalAccessToken::class, 'tokenable')->create([
        'name' => $name,
        'token' => hash('sha256', $plainTextToken),
        'abilities' => $abilities,
        'session_id' => $sessionId,
        'guard' => $resolved->guard,
        'provider' => $resolved->provider,
        'expires_at' => $expiresAt ?? now()->addMinutes((int) config('passportless.access_token.expiration', 15)),
    ]);

    return new NewAccessToken($token, $token->getKey().'|'.$plainTextToken);
}

Guard là ranh giới xác thực

Passportless đăng ký driver guard passportless. Bảo vệ route vẫn là cách làm Laravel thông thường:

'guards' => [
    'passportless-client' => [
        'driver' => 'passportless',
        'provider' => 'users',
    ],
    'passportless-admin' => [
        'driver' => 'passportless',
        'provider' => 'staff',
    ],
],
Route::get('/profile', ClientProfileController::class)
    ->middleware('auth:passportless-client');

Route::get('/admin/profile', StaffProfileController::class)
    ->middleware('auth:passportless-admin');

Token của client không được phép xác thực thành staff. Khi xác thực, guard, provider và model sở hữu token vẫn phải khớp; nếu không, request sẽ bị từ chối an toàn.

Authenticator xử lý credential theo thứ tự: ưu tiên Authorization: Bearer, sau đó mới dùng access cookie theo guard nếu không có Bearer. Cùng một mô hình token, hai cách truyền.

Ability là giới hạn quyền năng của token

if ($request->user()->tokenCan('orders:read')) {
    // ...
}
Route::get('/orders', OrdersController::class)
    ->middleware(['auth:passportless-client', 'abilities:orders:read']);

Route::post('/orders', OrdersController::class)
    ->middleware(['auth:passportless-client', 'ability:orders:write,orders:admin']);
  • abilities yêu cầu có đủ mọi ability được liệt kê.
  • ability yêu cầu có ít nhất một ability được liệt kê.

Hãy dùng policy và gate cho authorization theo nghiệp vụ. Ability của token chỉ trả lời: "credential này được phép làm gì tại thời điểm được phát hành?"

Session, refresh rotation và logout

$pair = $user->createTokenPair('iphone', ['orders:read']);

$rotated = app(Passportless::class)->refreshToken(
    $pair->plainTextRefreshToken(),
    ['orders:read'],
);

Khi refresh, ability có thể bị thu hẹp nhưng không bao giờ được âm thầm mở rộng. Việc tái sử dụng refresh token đã xoay vòng có thể thu hồi toàn bộ family. Các thao tác với family luôn được giới hạn bởi family_id, guardprovider.

'refresh_token' => [
    'expiration' => 60 * 24 * 30, // 30 days
    'reuse_detection' => RefreshTokenReuseDetection::REVOKE_FAMILY,
],

Một session nhóm các access credential và refresh credential liên quan. Khi thu hồi session, mọi token còn hoạt động trong session đó đều bị thu hồi, không chỉ credential bạn truyền vào:

use l3aro\Passportless\Facades\Passportless;

// Thiết bị này, từ access token
Passportless::logoutCurrentSession($plainTextAccessToken, 'passportless');

// Đăng xuất chỉ bằng cookie khi chỉ còn refresh cookie
Passportless::revokeSessionFromRefreshToken($plainTextRefreshToken, 'passportless');

// Mọi session của người dùng này trên guard này
Passportless::logoutAllSessions($user, 'passportless');

Credential không hợp lệ, hết hạn, đã thu hồi hoặc không khớp guard sẽ không gây lỗi và không thực hiện thao tác nào. Cách ly theo guard/provider vẫn luôn được áp dụng.

Cookie cho trình duyệt, Bearer cho mọi thứ khác

Mobile, CLI và server client dùng Bearer token. Browser client có thể dùng cùng mô hình token qua cookie HttpOnly.

Lựa chọn A: route SPA của package, phù hợp để bắt đầu. Route không bao giờ tự động được nạp; hãy đăng ký cho từng guard:

use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Route;
use App\Models\User;

final class AuthenticateUser
{
    public function __invoke(\Illuminate\Http\Request $request): ?User
    {
        $user = User::query()->where('email', $request->input('email'))->first();

        if ($user === null || ! Hash::check((string) $request->input('password'), $user->password)) {
            return null;
        }

        return $user;
    }
}

Route::passportlessSpaAuth(
    prefix: 'api/auth',
    guard: 'passportless',
    authenticate: AuthenticateUser::class,
    abilities: ['demo:read'],
    loginMiddleware: ['throttle:login'],
    refreshMiddleware: ['throttle:refresh'],
);
Method Path Vai trò
POST {prefix}/login Phát hành cặp token và đặt cookie
POST {prefix}/refresh Xoay vòng từ refresh cookie
POST {prefix}/logout Thu hồi session và xóa cookie

JSON trả về từ login không chứa secret, chỉ gồm token_type, thời hạn, csrf_token tùy chọn và session; không bao giờ trả access/refresh token thô. Khi csrf: true (mặc định), login dùng passportless.origin; refresh/logout dùng middleware CSRF. Handler xác thực phải cache được, tức là invokable class hoặc Class@method, không dùng closure. Ứng dụng chủ vẫn chịu trách nhiệm về CORS, ngoại lệ EncryptCookies cho CSRF cookie cần JavaScript đọc được và giới hạn tần suất request.

Lựa chọn B: tự tạo cookie khi bạn muốn kiểm soát hoàn toàn route:

use Illuminate\Support\Str;
use l3aro\Passportless\PassportlessCookieManager;

Route::post('/auth/login', function (PassportlessCookieManager $cookies) {
    $pair = auth()->user()->createTokenPair('browser', guard: 'passportless-client');
    $csrf = Str::random(40);

    return response()->json(['csrf_token' => $csrf])
        ->withCookie($cookies->createAccessCookie($pair->plainTextAccessToken()))
        ->withCookie($cookies->createRefreshCookie($pair->plainTextRefreshToken()))
        ->withCookie($cookies->createCsrfCookie($csrf));
});

Access cookie và refresh cookie luôn là HttpOnly. CSRF cookie có thể để JavaScript đọc được để phục vụ flow double-submit.

Middleware opt-in cho các route trình duyệt do ứng dụng chủ quản lý:

// Double-submit CSRF: so sánh cookie với header X-CSRF-TOKEN; bỏ qua GET/HEAD/OPTIONS
Route::middleware(['passportless.csrf', 'auth:passportless'])
    ->post('/profile', ...);

// Từ chối Origin có mặt nhưng không khớp origin của request
Route::post('/auth/login', AuthenticateUserController::class)
    ->middleware('passportless.origin');

Cookie path rất quan trọng: access và CSRF mặc định là /; refresh thường có path hẹp hơn, chẳng hạn /api/auth, nhưng phải bao phủ cả refresh lẫn logout. Lệch path là lỗi production thường gặp, và đó là lý do có lệnh doctor.

Vận hành và kiểm thử cũng theo triết lý first-party

php artisan passportless:doctor
php artisan passportless:prune-stale --hours=24

passportless:doctor kiểm tra guard, cookie profile, phạm vi path của SPA refresh/logout, CORS có credential và các cột migration. Lệnh chỉ báo cáo nhưng sẽ thoát với mã khác 0 khi phát hiện vấn đề.

Khi kiểm thử ứng dụng chủ, InteractsWithPassportless cung cấp actingAsPassportless, withPassportlessCookieSession và các assertion cho cookie queue. Quy tắc danh tính giống hệt production: model phải khớp với guard provider.

Cách cài đặt, chủ đích giữ gọn

composer require l3aro/passportless-for-laravel
php artisan vendor:publish --tag="passportless-migrations"
php artisan migrate

Config là tùy chọn:

php artisan vendor:publish --tag="passportless-config"

Sau khi cấu hình guard, cookie, route SPA hoặc CORS:

php artisan passportless:doctor

Dọn dẹp:

php artisan passportless:prune-stale --hours=24

Thử ngay với demo có thể chạy được

Nếu muốn thấy cookie SPA, dual guard và refresh rotation hoạt động cùng nhau thay vì tự cấu hình từ đầu, bạn có thể dùng ứng dụng demo hoàn chỉnh:

l3aro/passportless-demo: Laravel 13 API, Vue 3 SPA và Docker với nginx gateway, SQLite.

Demo xây dựng trên Passportless ^1.2.0 và sử dụng đúng bề mặt sản phẩm mà bài viết này đề cập:

  • Access/refresh cookie HttpOnly, không lưu token trong localStorage
  • Login, refresh và logout theo guard bằng Route::passportlessSpaAuth
  • Hai vùng danh tính không được lẫn nhau: User + passportlessStaff + passportless-admin
  • Alias CSRF của package, passportless:doctor và tác vụ dọn token cũ theo lịch

Hãy clone repo, khởi động stack bằng Docker Compose qua Traefik hoặc cổng host trực tiếp, migrate và seed, rồi đăng nhập bằng user hoặc staff. Tài khoản seed và cả hai phương thức truy cập đều được ghi trong README của demo.

Repo đó cho thấy bức tranh tích hợp; package này là lõi xác thực có thể tái sử dụng.

Khi nào mình vẫn chọn Passport hoặc Sanctum

Chọn Passport khi:

  • Ứng dụng bên ngoài cần quyền truy cập được ủy quyền.
  • Bạn cần OAuth2 grant, client, redirect hoặc scope chính thức.
  • Tuân thủ quy định hoặc tích hợp đối tác yêu cầu OAuth2 authorization server.

Chọn Sanctum khi:

  • Bạn xây SPA first-party theo cách truyền thống và muốn xác thực SPA bằng session + CSRF của Laravel.
  • Bạn chỉ cần personal access token đơn giản, không cần refresh rotation hay tính năng session theo thiết bị.
  • Bạn muốn thiết lập first-party nhỏ nhất và mô hình của Sanctum đã phù hợp.

Chọn Passportless khi:

  • Ứng dụng Laravel của bạn tự phát hành và xác thực API token cho các client do bạn sở hữu.
  • Bạn cần access token ngắn hạn, refresh token xoay vòng và phát hiện tái sử dụng.
  • Bạn cần session đa thiết bị và thu hồi theo từng session hoặc toàn bộ session.
  • Bạn có các model có thể xác thực riêng biệt, cần được cô lập theo guard/provider.
  • Bạn muốn tùy chọn truyền chính những token đó qua cookie trên trình duyệt, với route SPA, CSRF và same-origin, mà không cần dùng OAuth2 hay xác thực SPA bằng session của Laravel.
  • Bạn không muốn gánh độ phức tạp OAuth2 vì sản phẩm của bạn không có bài toán OAuth2.

Kết lại

Mình không xây Passportless vì Passport và Sanctum yếu. Mình xây nó vì mỗi công cụ mạnh ở một việc khác nhau, còn nhu cầu của mình nằm ở khoảng giữa:

  • chặt chẽ về bảo mật hơn "lưu một chuỗi ngẫu nhiên hoặc PAT đơn giản"
  • ít bề mặt giao thức hơn một OAuth2 server đầy đủ
  • vẫn mang hình dáng Laravel: guard, middleware, trait, config và migration có thể publish

Phiên bản 1.2.0 đưa luận điểm đó vào các nhu cầu thường ngày rõ hơn: route SPA dùng cookie là tùy chọn, middleware CSRF và same-origin, API logout theo session, ưu tiên Bearer rồi đến cookie, và lệnh doctor để lỗi cookie/guard/CORS lộ ra trong CI thay vì ở production.

Tên package chính là luận điểm ấy: xác thực token first-party cho Laravel không cần Passport và không cần độ phức tạp OAuth2, nhưng vẫn giữ các hành vi quan trọng cho API đa thiết bị trong thực tế: token được hash, xoay vòng, phát hiện tái sử dụng, thu hồi theo session, named guard và secret trên trình duyệt không nằm trong JavaScript.

Package: l3aro/passportless-for-laravel

Demo app: l3aro/passportless-demo


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í