HMAC (Hash-based Message Authentication Code) Laravel

HMAC adalah metode autentikasi yang menggunakan hash kriptografi untuk memastikan integritas dan keabsahan suatu pesan.
HMAC (Hash-based Message Authentication Code) Laravel

Implementasi Server

Buatlah migration, seeder, middleware dan route untuk menjalankan autentikasi hmac. Misalnya, Anda bisa membuat seperti ini.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('clients', function (Blueprint $table) {
            $table->id();
            $table->uuid('client_id')->unique();
            $table->string('client_name');
            $table->string('client_key')->unique();
            $table->timestamps();
            $table->softDeletes();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('clients');
    }
};
<?php

namespace Database\Seeders;

use App\Models\Client;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;

class ClientSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $clientId   = Str::uuid();
        $clientName = 'ZULFAME';
        $clientKey  = bin2hex(random_bytes(32));

        Client::create([
            'client_id'   => $clientId,
            'client_name' => $clientName,
            'client_key'  => $clientKey,
        ]);

        echo "Client ID: $clientId\n";
        echo "Client Key: $clientKey\n";
    }
}
<?php

namespace App\Http\Middleware;

use App\Models\Client;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;

class HmacMiddleware
{
    /**
     * Handle an incoming request.
     *
     * @param  \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response)  $next
     */
    public function handle(Request $request, Closure $next): Response
    {
        try {
            $clientId         = $request->header('X-ZULFAME-ID');
            $clientKey        = $request->header('X-ZULFAME-KEY');
            $requestSignature = $request->header('X-ZULFAME-SIGNATURE');
            $timestamp        = $request->header('X-TIMESTAMP');

            if (!$clientId || !$clientKey || !$requestSignature || !$timestamp) {
                Log::error('Unauthorized: Missing credentials');
                return response()->json([
                    'status'  => 'error',
                    'message' => 'Unauthorized: Missing credentials'
                ], 401);
            }

            // Validasi client
            $client = Client::where('client_id', $clientId)->where('client_key', $clientKey)->first();
            if (!$client) {
                Log::error('Unauthorized: Invalid client credentials');
                return response()->json([
                    'status'  => 'error',
                    'message' => 'Unauthorized: Invalid client credentials'
                ], 401);
            }

            // Validasi format timestamp & konversi ke UNIX timestamp
            $timestampUnix = strtotime($timestamp);
            if (!$timestampUnix) {
                Log::error('Unauthorized: Invalid timestamp format');
                return response()->json([
                    'status'  => 'error',
                    'message' => 'Unauthorized: Invalid timestamp format'
                ], 401);
            }

            // Cek apakah request sudah kedaluwarsa (5 menit)
            if (abs(time() - $timestampUnix) > 300) {
                Log::error('Unauthorized: Request expired');
                return response()->json([
                    'status'  => 'error',
                    'message' => 'Unauthorized: Request expired'
                ], 401);
            }

            // Cek apakah signature sudah pernah digunakan (untuk mencegah replay attack)
            $cacheKey = "used_signature:$requestSignature";
            if (Cache::has($cacheKey)) {
                Log::error('Unauthorized: Duplicate request detected');
                return response()->json([
                    'status'  => 'error',
                    'message' => 'Unauthorized: Duplicate request'
                ], 401);
            }

            // Generate signature server-side
            $serverSignature = hash_hmac('sha256', $clientKey, $timestamp);

            if (!hash_equals($serverSignature, $requestSignature)) {
                Log::error('Unauthorized: Invalid signature', [
                    'expected' => $serverSignature,
                    'received' => $requestSignature,
                ]);
                return response()->json([
                    'status'  => 'error',
                    'message' => 'Unauthorized: Invalid signature'
                ], 401);
            }

            // Simpan signature di cache agar tidak bisa digunakan ulang dalam 5 menit
            Cache::put($cacheKey, true, now()->addMinutes(5));

            Log::info('HMAC Authentication Passed');
            return $next($request);
        } catch (\Throwable $e) {
            Log::error('HMAC Middleware Error:', [
                'message' => $e->getMessage(),
                'file'    => $e->getFile(),
                'line'    => $e->getLine(),
                'trace'   => $e->getTraceAsString(),
            ]);

            return response()->json([
                'status'  => 'error',
                'message' => 'Internal Server Error'
            ], 500);
        }
    }
}
<?php

use App\Models\Client;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider and all of them will
| be assigned to the "api" middleware group. Make something great!
|
*/

Route::prefix('v1')->group(function () {
    Route::middleware('auth.hmac')->group(function () {
        Route::get('/status', function (Request $request) {
            $client = Client::where('client_id', $request->header('X-ZULFAME-ID'))->first();
            $data = [
                'name' => $client->client_name,
                'time' => $request->header('X-TIMESTAMP'),
            ];

            return response()->json([
                'status'  => 'success',
                'message' => 'Authenticated successfully',
                'data'    => $data,
            ]);
        });
    });
});

Implementasi Client

Lakukan konfigurasi klien pada .env kemudian buatlah controller dan route untuk mencoba mengirim permintaan autentikasi hmac. Misalnya, Anda bisa membuat seperti ini.

ZULFAME_CLIENT_ID=dc355ed2-93fd-4a24-be63-39bd2d350fc7
ZULFAME_CLIENT_KEY=f1d47a895dcb16afb80a6255fe89f41465d217db11993bffb42f6117501535ca
<?php

namespace App\Http\Controllers;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Log;

class TestController extends Controller
{
    public function index()
    {
        $clientId  = env('ZULFAME_CLIENT_ID');
        $clientKey = env('ZULFAME_CLIENT_KEY');

        $endpoint  = "https://api-hmac.test";
        $urlPath   = "/api/v1/status";
        $timestamp = date("Y-m-d H:i:s");
        $signature = hash_hmac('sha256', $clientKey, $timestamp);

        $client = new Client([
            'base_uri' => $endpoint,
            'timeout'  => 10,
            'verify'   => false,
        ]);

        try {
            $response = $client->request('GET', $urlPath, [
                'headers' => [
                    'X-ZULFAME-ID'        => $clientId,
                    'X-ZULFAME-KEY'       => $clientKey,
                    'X-ZULFAME-SIGNATURE' => $signature,
                    'X-TIMESTAMP'         => $timestamp,
                ],
            ]);

            $body = json_decode($response->getBody(), true);

            Log::info('API Response:', $body);
            return response()->json($body);
        } catch (RequestException $e) {
            Log::error('API Request Failed:', [
                'message' => $e->getMessage(),
                'trace'   => $e->getTraceAsString(),
            ]);

            return response()->json([
                'status'  => 'error',
                'message' => $e->getMessage(),
            ], 500);
        }
    }
}
<?php

use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::get('/status', [\App\Http\Controllers\TestController::class, 'index']);

Menerapkan Perizinan

Setelah proses autentikasi hmac diverifikasi, Anda bisa memberikan sebuah perizinan (optional) untuk menambahkan keamanan. Misalnya, Anda bisa membuat seperti ini.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('permissions', function (Blueprint $table) {
            $table->id();
            $table->string('name')->unique();
            $table->timestamps();
        });

        Schema::create('client_permissions', function (Blueprint $table) {
            $table->foreignId('permission_id')->references('id')->on('permissions')->onDelete('cascade');
            $table->foreignId('client_id')->references('id')->on('clients')->onDelete('cascade');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('permissions');
        Schema::dropIfExists('client_permissions');
    }
};
<?php

namespace Database\Seeders;

use App\Models\Permission;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class PermissionSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $permissions = [
            ['name' => 'create-status'],
            ['name' => 'read-status'],
            ['name' => 'update-status'],
            ['name' => 'delete-status']
        ];

        foreach ($permissions as $permission) {
            Permission::create([
                'name' => $permission['name'],
                'created_at' => now(),
                'updated_at' => now(),
            ]);
        }

        DB::table('client_permissions')->insert([
            ['client_id' => 1, 'permission_id' => 2, 'created_at' => now(), 'updated_at' => now(),],
        ]);
    }
}
<?php

namespace App\Http\Middleware;

use App\Models\Client;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;

class PermissionMiddleware
{
    /**
     * Handle an incoming request.
     *
     * @param  \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response)  $next
     */
    public function handle(Request $request, Closure $next): Response
    {
        try {
            $clientId  = $request->header('X-ZULFAME-ID');
            $clientKey = $request->header('X-ZULFAME-KEY');

            // Validasi client
            $client = Client::where('client_id', $clientId)->where('client_key', $clientKey)->first();
            if (!$client) {
                Log::error('Unauthorized: Invalid client credentials');
                return response()->json([
                    'status'  => 'error',
                    'message' => 'Unauthorized: Invalid client credentials'
                ], 401);
            }

            // Validasi permission
            $permissions = DB::table('client_permissions')
                ->join('permissions', 'client_permissions.permission_id', '=', 'permissions.id')
                ->where('client_permissions.client_id', $client->id)
                ->pluck('permissions.name')
                ->toArray();

            Log::info('Client Permissions:', ['client_id' => $client->id, 'permissions' => $permissions]);

            $requiredPermission = collect($request->route()->middleware())
                ->first(fn($middleware) => str_starts_with($middleware, 'permission:'));

            if ($requiredPermission) {
                $requiredPermission = str_replace('permission:', '', $requiredPermission);
            }

            Log::info('Required Permission:', ['required' => $requiredPermission]);

            if (!$requiredPermission || !in_array($requiredPermission, $permissions)) {
                Log::error('Unauthorized: Insufficient permission', [
                    'client_id' => $client->id,
                    'required'  => $requiredPermission,
                    'owned'     => $permissions
                ]);

                return response()->json([
                    'status'  => 'error',
                    'message' => 'Unauthorized: Insufficient permission'
                ], 403);
            }

            Log::info('HMAC Permission Passed');
            return $next($request);
        } catch (\Throwable $e) {
            Log::error('HMAC Permission Error:', [
                'message' => $e->getMessage(),
                'file'    => $e->getFile(),
                'line'    => $e->getLine(),
                'trace'   => $e->getTraceAsString(),
            ]);

            return response()->json([
                'status'  => 'error',
                'message' => 'Internal Server Error'
            ], 500);
        }
    }
}
Route::get('/status', function (Request $request) {
    $client = Client::where('client_id', $request->header('X-ZULFAME-ID'))->first();
    $data = [
        'name' => $client->client_name,
        'time' => $request->header('X-TIMESTAMP'),
    ];

    return response()->json([
        'status'  => 'success',
        'message' => 'Authenticated successfully',
        'data'    => $data,
    ]);
})->middleware('permission:read-status');

About the author

Zulfadli Rizal
Make it Simple but Significant!

Posting Komentar