Toidicode.com

Toidicode.com

BASIC TO ADVANCE

Bài 23: Xây dựng API với Sanctum trong Laravel 13

TaskFlow mới chỉ trả về HTML cho trình duyệt. Tiếp tục với series, bài này mình sẽ giới thiệu với mọi người về xây dựng API với Sanctum trong Laravel 13: cấp token và mở API project, task cho app khác gọi.

1. Cài đặt API và Sanctum.

API trả về JSON cho app di động hay frontend riêng. Các client này không giữ session, nên mỗi request gửi kèm một token để Laravel biết ai đang gọi. Các bạn cài phần API bằng:

php artisan install:api
# ...
#   INFO  API scaffolding installed. Please add the [Laravel\Sanctum\HasApiTokens] trait to your User model.

Lệnh này cài package Sanctum, tạo config/sanctum.php, migration bảng personal_access_tokens (lưu token), file routes/api.php và khai báo file đó trong withRouting() của bootstrap/app.php. Model User thêm use Laravel\Sanctum\HasApiTokens; và đổi dòng trait thành use HasApiTokens, HasFactory, Notifiable;.

Route trong routes/api.php tự có tiền tố /api và chạy middleware group api: không session, cookie, CSRF như web (stateless).

2. Cấp token cho người dùng.

Mỗi user có nhiều token, mỗi thiết bị một cái. API nhận email, mật khẩu, tên thiết bị và trả về token:

- app/Http/Controllers/Api/TokenController.php (tạo bằng php artisan make:controller Api/TokenController)

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;

class TokenController extends Controller
{
    public function store(Request $request): JsonResponse
    {
        $data = $request->validate([
            'email' => ['required', 'email'],
            'password' => ['required'],
            'device_name' => ['required', 'string', 'max:255'],
        ]);

        // cùng key email + IP với LoginController: sai 5 lần thì khóa 60 giây
        $key = Str::lower($data['email']).'|'.$request->ip();

        if (RateLimiter::tooManyAttempts($key, 5)) {
            throw ValidationException::withMessages([
                'email' => __('auth.throttle', ['seconds' => RateLimiter::availableIn($key)]),
            ])->status(429);
        }

        $user = User::where('email', $data['email'])->first();

        if (! $user || ! Hash::check($data['password'], $user->password)) {
            RateLimiter::hit($key);

            throw ValidationException::withMessages(['email' => __('auth.failed')]);
        }

        RateLimiter::clear($key);
        // token có mọi ability ('*'), chuỗi plainTextToken chỉ xem được lúc này
        $token = $user->createToken($data['device_name']);

        return response()->json(['token' => $token->plainTextToken], 201);
    }

    public function destroy(Request $request): Response
    {
        // xóa token đang dùng cho request này
        $request->user()->currentAccessToken()->delete();

        return response()->noContent();
    }
}

Trong đó:

  • createToken($name, $abilities = ['*'], $expiresAt = null): DB chỉ lưu bản băm SHA-256, chuỗi gốc plainTextToken chỉ có lúc tạo. Token mặc định không hết hạn, app thật nên đặt expiration (phút) trong config/sanctum.php và chạy định kỳ php artisan sanctum:prune-expired.
  • RateLimiter: /api/tokens cũng là nơi dò mật khẩu, nên giới hạn 5 lần sai như LoginController ở Bài 21 (cùng key, sai ở web hay API đều tính). Sai thì ValidationException trả 422 như validate(), bị khóa thì status(429) đổi thành 429.
  • currentAccessToken()->delete(): thu hồi token đang dùng.

Tham số thứ hai là các ability (quyền của token, giống scope của OAuth). Thử trong php artisan tinker:

$user = User::find(1);
$token = $user->createToken('chi-doc', ['projects:read']);
$token->plainTextToken; // "2|kBUYHByWsghqYAXSh0XF75Y8oBXTHxfNnbvroezE9f5eee24", mỗi lần chạy ra chuỗi khác
$token->accessToken->can('projects:read'); // true
$token->accessToken->can('tasks:create'); // false

Ability chỉ có tác dụng ở chỗ mình kiểm tra bằng $request->user()->tokenCan('tasks:create'): API đọc của bài này không kiểm tra nên token nào cũng đọc được, còn token từ /api/tokens luôn có ['*'].

3. Route và controller cho API.

- routes/api.php

<?php

use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\TaskController;
use App\Http\Controllers\Api\TokenController;
use Illuminate\Support\Facades\Route;

// đổi email + mật khẩu lấy token
Route::post('/tokens', [TokenController::class, 'store'])->name('api.tokens.store');

// mọi route trong group phải gửi kèm token hợp lệ
Route::middleware('auth:sanctum')->name('api.')->group(function () {
    Route::delete('/tokens/current', [TokenController::class, 'destroy'])->name('tokens.destroy');

    Route::apiResource('projects', ProjectController::class)->only(['index', 'show']);
    Route::apiResource('projects.tasks', TaskController::class)->only(['index', 'store']);
});

Trong đó:

  • auth:sanctum: middleware auth với guard sanctum, đọc header Authorization: Bearer <token>, thiếu hoặc sai token thì 401.
  • apiResource(): như Route::resource() ở Bài 5 nhưng bỏ create, edit. projects.tasks sinh /api/projects/{project}/tasks.
  • name('api.'): tránh trùng tên projects.index với web.php (route đăng ký sau thắng, route('projects.index') sẽ ra /api/projects).

Hai controller tạo với --api (không sinh create, edit), VD php artisan make:controller Api/TaskController --api --model=Task --parent=Project, rồi chỉ giữ các method cần:

- app/Http/Controllers/Api/ProjectController.php (trích, phần thân class, các dòng use như TaskController bên dưới)

public function index(Request $request): Collection
{
    return $request->user()->projects()->withCount('tasks')->get();
}

#[Authorize('view', 'project')]
public function show(Project $project): Project
{
    return $project->loadCount('tasks');
}

- app/Http/Controllers/Api/TaskController.php

<?php

namespace App\Http\Controllers\Api;

use App\Enums\TaskStatus;
use App\Http\Controllers\Controller;
use App\Models\Project;
use App\Models\Task;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;
use Illuminate\Routing\Attributes\Controllers\Authorize;

class TaskController extends Controller
{
    #[Authorize('view', 'project')]
    public function index(Project $project): Collection
    {
        return $project->tasks()->get();
    }

    #[Authorize('update', 'project')]
    public function store(Request $request, Project $project): Task
    {
        // token '*' có mọi ability
        abort_unless($request->user()->tokenCan('tasks:create'), 403, 'Token không có quyền tạo task.');

        $data = $request->validate([
            'title' => ['required', 'string', 'max:255'],
            'due_date' => ['nullable', 'date'],
        ]);

        // model vừa tạo được trả về với status 201
        return $project->tasks()->create($data + ['status' => TaskStatus::Todo]);
    }
}

Trả về model hay collection thì Laravel tự đổi thành JSON (bỏ cột trong #[Hidden]). Policy của Bài 22 dùng lại qua #[Authorize]. Lỗi validate, 403, 404 trả JSON nhờ shouldRenderJsonWhen() từ Bài 11.

- lang/vi/validation.php (trích, thêm date và hai tên trường)

'date' => 'Trường :attribute phải là ngày hợp lệ.',
// ...
'attributes' => [
    'name' => 'họ tên',
    'password' => 'mật khẩu',
    'title' => 'tiêu đề',
    'due_date' => 'hạn',
],

4. Gọi API bằng curl.

Chạy php artisan migrate:fresh --seed và php artisan serve, rồi gọi thử (nên luôn gửi header Accept: application/json):

curl -H "Accept: application/json" http://127.0.0.1:8000/api/projects
# {"message":"Unauthenticated."}

curl -H "Accept: application/json" -d [email protected] -d password=password \
    -d device_name=curl http://127.0.0.1:8000/api/tokens
# {"token":"1|Inb0CMkjL5LJ5SYw6BsmLJ46f83i9KDpQz2VNbroe2a106a9"}

TOKEN="1|Inb0CMkjL5LJ5SYw6BsmLJ46f83i9KDpQz2VNbroe2a106a9"
curl -H "Accept: application/json" -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/api/projects
# [{"id":1,"user_id":1,"name":"Website toidicode.com",...,"tasks_count":4},{"id":2,"user_id":1,"name":"TaskFlow API",...,"tasks_count":3}]

curl -H "Accept: application/json" -H "Authorization: Bearer $TOKEN" \
    --data-urlencode "title=Viết bài Sanctum" -d due_date=2026-12-01 http://127.0.0.1:8000/api/projects/1/tasks
# {"title":"Vi\u1ebft b\u00e0i Sanctum","due_date":"2026-12-01T00:00:00.000000Z","status":"todo","project_id":1,...,"id":11,"is_overdue":false}

for i in 1 2 3 4 5 6; do
    curl -s -o /dev/null -w "%{http_code} " -H "Accept: application/json" -d [email protected] \
        -d password=sai -d device_name=curl http://127.0.0.1:8000/api/tokens
done
# 422 422 422 422 422 429

JSON escape tiếng Việt thành \u1ebf, client đọc vẫn ra chữ đúng. Lần 429 báo "Bạn đăng nhập sai quá nhiều lần, thử lại sau 58 giây.", nhập đúng mật khẩu cũng phải đợi. Ngoài ra:

  • Thiếu title, due_date=abc: 422 "Trường tiêu đề là bắt buộc.", "Trường hạn phải là ngày hợp lệ.".
  • Token của Bình gọi /api/projects/1: 403 "This action is unauthorized.".
  • Token chi-doc tạo task: 403 "Token không có quyền tạo task.", đọc task vẫn được.
  • DELETE /api/tokens/current trả về 204, token đó gọi lại nhận 401.

5. Giới hạn request và SPA.

Group api mặc định chưa giới hạn số request. $middleware->throttleApi(); trong bootstrap/app.php thêm throttle:api vào group, limiter api khai báo như limiter projects ở Bài 4:

- app/Providers/AppServiceProvider.php (trích, trong boot())

// API: 60 request mỗi phút cho mỗi user, khách tính theo IP
RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});

Header X-RateLimit-Remaining giảm dần, request thứ 61 trong một phút nhận 429 "Too Many Attempts.". 60 lần mỗi phút theo IP quá rộng để chặn dò mật khẩu, nên TokenController có giới hạn riêng.

Lưu ý: SPA (Vue, React...) cùng domain nên dùng SPA authentication của Sanctum (cookie session, $middleware->statefulApi()) thay vì token.

6. Lời kết.

Như vậy chúng ta đã tìm hiểu xong xây dựng API với Sanctum trong Laravel 13: token, ability, auth:sanctum, apiResource và giới hạn request. Các bạn xem thêm tại tài liệu chính thức. API hiện trả về cả model với mọi cột, bài sau mình sẽ giới thiệu về API Resource và JSON:API để chọn dữ liệu API trả về.

Đăng ký nhận tin.

Chúng tôi chỉ gửi tối đa 2 lần trên 1 tháng. Tuyên bố không spam mail!

Vũ Thanh Tài

About author
The best way to learn is to share
Xem tất cả bài đăng

0 Comments

Bài viết chưa có ai bình luận, hãy là người đầu tiên đi bạn!

Bình luận

Captcha