+1

Xây dựng hệ thống Đăng nhập Passwordless (OTP) tích hợp bên thứ 3 chuẩn Design Pattern

Xu hướng "Passwordless" (Không sử dụng mật khẩu) đang thống trị các ứng dụng hiện đại. Thay vì bắt user nhớ mật khẩu dài ngoằng, các app như Grab, Shopee hay Momo chỉ yêu cầu nhập Số điện thoại, nhận OTP và vào thẳng hệ thống.

Tuy nhiên, khi làm việc với bên thứ 3 (3rd Party SMS Gateway như Twilio, eSMS, v.v.), rủi ro lớn nhất là Vendor Lock-in (Bị trói buộc vào một nhà cung cấp). Hôm nay sếp bảo dùng Twilio, tháng sau Twilio tăng giá, sếp bảo chuyển qua nhà mạng Viettel. Nếu bạn hardcode logic gọi API Twilio thẳng vào Controller, bạn sẽ phải đập đi viết lại toàn bộ.

Hôm nay, chúng ta sẽ xây dựng luồng Đăng nhập bằng OTP hoàn toàn mới từ con số 0. Chúng ta sẽ áp dụng Interface/Contract Pattern để việc thay đổi nhà cung cấp SMS trong tương lai chỉ mất đúng 1 phút cấu hình.

Lời mở đầu: Thoát khỏi cái bẫy "Hardcode"

Hãy tưởng tượng bạn viết một hàm Đăng nhập bằng OTP như thế này:

public function login(Request $request) {
    // Gọi thẳng API của Twilio ở đây
    $twilio = new TwilioClient('sid', 'token');
    $twilio->messages->create($request->phone, ['body' => 'Your OTP is 123456']);
}

Nhìn thì nhanh, nhưng khi hệ thống cần đổi sang nhà mạng khác, bạn phải lục lọi hàng chục file Controller, Job, Service để tìm và sửa từng dòng code.

Trong môi trường Enterprise, chúng ta phải lập trình dựa trên "Hợp đồng" (Interface) chứ không lập trình dựa trên "Thực thể" (Implementation). Hãy cùng xem cách một Kỹ sư hệ thống thiết lập luồng này nhé.

Bước 1: Khởi tạo dự án & Chuẩn bị Database

Tạo một project Laravel hoàn toàn mới:

laravel new passwordless-app
cd passwordless-app

1. Cập nhật bảng Users

Với Passwordless, User đôi khi không cần Password. Ta cần sửa lại Migration mặc định của bảng users để thêm cột phone và cho phép password có thể null.

// database/migrations/0001_01_01_000000_create_users_table.php
public function up(): void
{
    Schema::create('users', function (Blueprint $table) {
        $table->id();
        $table->string('name')->nullable(); // Có thể cập nhật sau khi login
        $table->string('phone')->unique();  // SĐT là định danh chính
        $table->string('email')->unique()->nullable();
        $table->string('password')->nullable(); // Không ép buộc có mật khẩu
        $table->rememberToken();
        $table->timestamps();
    });
}

Chạy lệnh php artisan migrate để tạo bảng.

Bước 2: Xây dựng "Hợp đồng" SMS (Interface Pattern)

Đây là bước phân loại Junior và Senior. Chúng ta sẽ định nghĩa một luật chung cho TẤT CẢ các nhà cung cấp SMS.

1. Tạo Interface:

Tạo thư mục app/Contracts/ và thêm file SmsGatewayInterface.php:

// app/Contracts/SmsGatewayInterface.php
namespace App\Contracts;

interface SmsGatewayInterface
{
    /**
     * Hàm gửi tin nhắn SMS
     * @param string $phone Số điện thoại nhận
     * @param string $message Nội dung tin nhắn
     * @return bool Trả về true nếu gửi thành công
     */
    public function sendSms(string $phone, string $message): bool;
}

2. Tạo class implement cho Twilio (Bên thứ 3):

Tạo thư mục app/Services/Sms/ và thêm class TwilioSmsService.php:

// app/Services/Sms/TwilioSmsService.php
namespace App\Services\Sms;

use App\Contracts\SmsGatewayInterface;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Http; // Dùng HTTP Client để gọi API

class TwilioSmsService implements SmsGatewayInterface
{
    public function sendSms(string $phone, string $message): bool
    {
        // Thực tế bạn sẽ gọi API của Twilio ở đây.
        // Để demo, mình sẽ dùng Log để giả lập.
        Log::info("[TWILIO GATEWAY] Đang gửi tới {$phone} nội dung: {$message}");
        
        // Ví dụ code thật:
        // $response = Http::withBasicAuth('sid', 'token')->post('https://api.twilio.com/...', [
        //     'To' => $phone, 'Body' => $message
        // ]);
        // return $response->successful();

        return true; 
    }
}

3. Đăng ký (Bind) Interface vào hệ thống:

Mở app/Providers/AppServiceProvider.php. Đây là lúc bạn nói với Laravel: "Ê, mỗi khi ai đó cần dùng SmsGatewayInterface, hãy ném cho họ thằng TwilioSmsService nhé".

// app/Providers/AppServiceProvider.php
use App\Contracts\SmsGatewayInterface;
use App\Services\Sms\TwilioSmsService;

public function register(): void
{
    // Ràng buộc Interface với class cụ thể
    $this->app->bind(SmsGatewayInterface::class, TwilioSmsService::class);
}

Tương lai: Nếu đổi sang Viettel, bạn chỉ việc tạo ViettelSmsService, và vào đây đổi chữ TwilioSmsService thành ViettelSmsService là xong. Toàn bộ dự án tự động chuyển qua nhà mạng mới mà không cần sửa logic!

Bước 3: Viết Controller xử lý luồng Login

Chúng ta cần 2 API: Một cái để yêu cầu gửi mã (Request OTP), và một cái để xác nhận và nhả Token (Verify OTP). OTP sẽ được lưu trên RAM (Cache) để nó tự động bốc hơi sau 2 phút.

php artisan make:controller Api/OtpAuthController
// app/Http/Controllers/Api/OtpAuthController.php
namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use App\Contracts\SmsGatewayInterface;
use App\Models\User;

class OtpAuthController extends Controller
{
    protected SmsGatewayInterface $smsGateway;

    // Dependency Injection: Laravel tự động nạp TwilioSmsService vào đây nhờ cấu hình ở Provider
    public function __construct(SmsGatewayInterface $smsGateway)
    {
        $this->smsGateway = $smsGateway;
    }

    /**
     * API 1: Yêu cầu gửi mã OTP
     */
    public function requestOtp(Request $request)
    {
        $request->validate([
            'phone' => 'required|regex:/^([0-9\s\-\+\(\)]*)$/|min:10'
        ]);

        $phone = $request->phone;
        
        // 1. Sinh mã OTP 6 số
        $otpCode = (string) random_int(100000, 999999);

        // 2. Lưu Cache sống 2 phút
        Cache::put('login_otp_' . $phone, $otpCode, now()->addMinutes(2));

        // 3. Gọi bên thứ 3 gửi tin nhắn (Thông qua Interface)
        $message = "Ma xac nhan dang nhap cua ban la: {$otpCode}. Co hieu luc trong 2 phut.";
        $isSent = $this->smsGateway->sendSms($phone, $message);

        if (!$isSent) {
            return response()->json(['message' => 'Lỗi kết nối nhà mạng SMS.'], 500);
        }

        return response()->json([
            'success' => true,
            'message' => 'Mã OTP đã được gửi đến số điện thoại của bạn.'
        ]);
    }

    /**
     * API 2: Xác nhận OTP và Cấp Token (Đăng nhập)
     */
    public function verifyOtp(Request $request)
    {
        $request->validate([
            'phone' => 'required|string',
            'otp' => 'required|digits:6'
        ]);

        $phone = $request->phone;
        $inputOtp = $request->otp;

        $cacheKey = 'login_otp_' . $phone;

        // 1. Kiểm tra OTP có khớp và còn hạn không
        $validOtp = Cache::get($cacheKey);

        if (!$validOtp || $validOtp !== $inputOtp) {
            return response()->json(['message' => 'Mã OTP không hợp lệ hoặc đã hết hạn.'], 400);
        }

        // 2. Xác nhận đúng -> Xóa OTP khỏi Cache
        Cache::forget($cacheKey);

        // 3. Passwordless Logic: Nếu User chưa tồn tại thì Tự động tạo mới (Register), có rồi thì lấy ra (Login)
        $user = User::firstOrCreate(
            ['phone' => $phone],
            ['name' => 'User_' . random_int(1000, 9999)] // Gán tên mặc định
        );

        // 4. Sinh Sanctum Token
        $token = $user->createToken('MobileApp')->plainTextToken;

        return response()->json([
            'success' => true,
            'message' => 'Đăng nhập thành công!',
            'data' => [
                'user' => $user,
                'access_token' => $token,
                'token_type' => 'Bearer'
            ]
        ]);
    }
}

Đăng ký Route vào routes/api.php:

use App\Http\Controllers\Api\OtpAuthController;

Route::post('/auth/request-otp', [OtpAuthController::class, 'requestOtp']);
Route::post('/auth/verify-otp', [OtpAuthController::class, 'verifyOtp']);

Bước 4: Test luồng bằng Postman

Khởi động server Laravel (php artisan serve) và làm theo kịch bản sau:

Phase 1: User yêu cầu mã OTP

  • Method: POST
  • URL: [http://127.0.0.1:8000/api/auth/request-otp](http://127.0.0.1:8000/api/auth/request-otp)
  • Headers: Accept: application/json
  • Body (raw - JSON):
{
    "phone": "0901234567"
}

Kết quả (Response):

{
    "success": true,
    "message": "Mã OTP đã được gửi đến số điện thoại của bạn."
}

(Nếu bạn nhìn vào Terminal đang chạy Laravel, bạn sẽ thấy dòng Log giả lập Twilio in ra: [TWILIO GATEWAY] Đang gửi tới 0901234567 nội dung: Ma xac nhan dang nhap cua ban la: 837492...). Hãy copy cái mã 6 số đó để test bước tiếp theo.

Phase 2: User nhập OTP để lấy Token

  • Method: POST
  • URL: [http://127.0.0.1:8000/api/auth/verify-otp](http://127.0.0.1:8000/api/auth/verify-otp)
  • Headers: Accept: application/json
  • Body (raw - JSON):
{
    "phone": "0901234567",
    "otp": "837492" 
}

(Nhập đúng mã 6 số bạn vừa copy).

Kết quả (Response): Sự mượt mà của kiến trúc hiển thị rõ ở đây!

{
    "success": true,
    "message": "Đăng nhập thành công!",
    "data": {
        "user": {
            "phone": "0901234567",
            "name": "User_4829",
            "updated_at": "2026-05-04T10:15:00.000000Z",
            "created_at": "2026-05-04T10:15:00.000000Z",
            "id": 1
        },
        "access_token": "1|sRtvY6...SanctumTokenSinhRaDay",
        "token_type": "Bearer"
    }
}

User đã được tự động tạo mới vào Database, và hệ thống nhả ra access_token để đi vào các màn hình được bảo mật.

Tóm lại

Bằng cách áp dụng Interface/Contracts, hệ thống của bạn hoàn toàn tách biệt khỏi sự ràng buộc với Twilio. Việc sử dụng Cache thay vì Database để lưu OTP giúp tốc độ xử lý nhanh, không sinh rác. Cuối cùng, hàm firstOrCreate() giúp biến một luồng đăng ký/đăng nhập cồng kềnh thành một trải nghiệm "Chạm là vào" (Passwordless) mượt mà cho người dùng.

Anh em hãy thử clone về và đổi nhà cung cấp SMS xem nó dễ đến mức nào nhé!


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í