From e9d9ab04e643091058332d4aa02f0e07e6573373 Mon Sep 17 00:00:00 2001 From: Maduka Jayalath Date: Sun, 25 May 2025 16:36:42 +0530 Subject: [PATCH 01/11] L12 --- .gitignore | 1 + src/ApiToken.php | 77 +++++++++++++++++++++++++++----- src/ApiTokenServiceProvider.php | 49 ++++++++++++++++---- src/BasicApiTokenMiddleware.php | 59 ++++++++++++------------ src/Commands/CreateToken.php | 52 +++++++++++++++------ src/Commands/DeleteToken.php | 46 +++++++++++++------ src/Commands/ListTokens.php | 42 ++++++++++++++--- src/Commands/RegenerateToken.php | 48 +++++++++++++------- 8 files changed, 277 insertions(+), 97 deletions(-) diff --git a/.gitignore b/.gitignore index 5a2d43a..1ab6d14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ vendor composer.lock +/.idea diff --git a/src/ApiToken.php b/src/ApiToken.php index 0e15c68..abf8c00 100644 --- a/src/ApiToken.php +++ b/src/ApiToken.php @@ -1,32 +1,87 @@ + */ + protected $fillable = ['service', 'token']; - public static function createNew($service) + /** + * The attributes that should be hidden for arrays and JSON responses. + * + * @var array + */ + protected $hidden = ['token']; + + /** + * Create a new API token for the given service. + * + * @param string $service The service name for the token. + * @return string The generated raw token (not hashed). + * @throws \InvalidArgumentException If the service name is empty. + */ + public static function createNew(string $service): string { - $newToken = str_random(32); + // Validate the service name + if (empty($service)) { + throw new \InvalidArgumentException('Service name cannot be empty'); + } + + // Generate a random 32-character token + $newToken = Str::random(32); + + // Create and save the token record with a hashed token static::create([ 'service' => $service, - 'token' => bcrypt($newToken) + 'token' => Hash::make($newToken), ]); + + // Return the raw (unhashed) token return $newToken; } - public static function regenerate($service) + /** + * Regenerate an API token for the given service. + * + * @param string $service The service name for the token. + * @return string The regenerated raw token (not hashed). + * @throws \InvalidArgumentException If the service name is empty or no token exists for the service. + */ + public static function regenerate(string $service): string { - $token = static::where('service', '=', $service)->first(); - if (! $token) { - throw new \InvalidArgumentException('No such service'); + // Validate the service name + if (empty($service)) { + throw new \InvalidArgumentException('Service name cannot be empty'); } - $newToken = str_random(32); - $token->token = bcrypt($newToken); + + // Find the token for the given service + $token = static::where('service', $service)->first(); + + // Throw an exception if no token is found + if (!$token) { + throw new \InvalidArgumentException('No token found for service: ' . $service); + } + + // Generate a new random 32-character token + $newToken = Str::random(32); + + // Update the token with the new hashed value + $token->token = Hash::make($newToken); $token->save(); + + // Return the raw (unhashed) token return $newToken; } } diff --git a/src/ApiTokenServiceProvider.php b/src/ApiTokenServiceProvider.php index 9442d90..90ee6fc 100644 --- a/src/ApiTokenServiceProvider.php +++ b/src/ApiTokenServiceProvider.php @@ -3,24 +3,57 @@ namespace UoGSoE\ApiTokenMiddleware; use Illuminate\Support\ServiceProvider; +use Illuminate\Routing\Router; +use UoGSoE\ApiTokenMiddleware\Commands\ListTokens; +use UoGSoE\ApiTokenMiddleware\Commands\CreateToken; +use UoGSoE\ApiTokenMiddleware\Commands\DeleteToken; +use UoGSoE\ApiTokenMiddleware\Commands\RegenerateToken; +/** + * Service provider for the ApiTokenMiddleware package. + */ class ApiTokenServiceProvider extends ServiceProvider { - public function boot(\Illuminate\Routing\Router $router) + /** + * Bootstrap any application services. + * + * @param Router $router The Laravel router instance for registering middleware. + * @return void + */ + public function boot(Router $router): void { - $this->loadMigrationsFrom(__DIR__.'/../migrations'); - $router->aliasMiddleware('apitoken', 'UoGSoE\ApiTokenMiddleware\BasicApiTokenMiddleware'); + // Publish the ApiToken model to the app/Models directory to align with Laravel 12's default model namespace + $this->publishes([ + __DIR__ . '/ApiToken.php' => app_path('Models/ApiToken.php'), + ]); + + // Publish the migration file to create the api_tokens table + $this->publishes([ + __DIR__ . '/../migrations/2018_04_18_090739_create_api_tokens_table.php' => + database_path('migrations/2018_04_18_090739_create_api_tokens_table.php'), + ]); + + // Register the 'apitoken' middleware alias for use in routes + $router->aliasMiddleware('apitoken', BasicApiTokenMiddleware::class); + + // Register console commands, but only if running in the console environment if ($this->app->runningInConsole()) { $this->commands([ - Commands\ListTokens::class, - Commands\CreateToken::class, - Commands\DeleteToken::class, - Commands\RegenerateToken::class, + ListTokens::class, + CreateToken::class, + DeleteToken::class, + RegenerateToken::class, ]); } } - public function register() + /** + * Register any application services. + * + * @return void + */ + public function register(): void { + // Empty for now, but can be used to bind services or configurations in the future } } diff --git a/src/BasicApiTokenMiddleware.php b/src/BasicApiTokenMiddleware.php index 2dda966..b39d268 100644 --- a/src/BasicApiTokenMiddleware.php +++ b/src/BasicApiTokenMiddleware.php @@ -3,67 +3,68 @@ namespace UoGSoE\ApiTokenMiddleware; use Closure; -use UoGSoE\ApiTokenMiddleware\ApiToken; +use App\Models\ApiToken; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Hash; class BasicApiTokenMiddleware { - const CODE = 401; - const MESSAGE = 'Unauthorized'; + public const CODE = 401; + public const MESSAGE = 'Unauthorized'; /** * Handle an incoming request. * - * @param \Illuminate\Http\Request $request - * @param \Closure $next + * @param Request $request + * @param Closure $next + * @param string ...$services * @return mixed */ - public function handle($request, Closure $next) + public function handle(Request $request, Closure $next, string ...$services): mixed { - $services = array_except(func_get_args(), [0,1]); if (!$this->authorized($request, $services)) { return response()->json(['message' => self::MESSAGE], self::CODE); } + return $next($request); } /** - * Checks an incoming token against one in the database + * Check if the request is authorized based on the provided token and services. * - * @param \Illuminate\Http\Request $request - * @param array $service + * @param Request $request + * @param array $services + * @return bool */ - public function authorized($request, $services) + protected function authorized(Request $request, array $services): bool { $passedToken = $this->extractToken($request); - if (! $passedToken) { + + if (!$passedToken || empty($services)) { return false; } - $apiTokens = ApiToken::whereIn('service', $services)->get(); - if ($apiTokens->isEmpty()) { + // Optimize query to fetch only the first matching token + $apiToken = ApiToken::whereIn('service', $services) + ->whereNotNull('token') + ->first(); + + if (!$apiToken) { return false; } - return !is_null($apiTokens->first(function ($apiToken) use ($passedToken) { - return \Hash::check($passedToken, $apiToken->token); - })); + return Hash::check($passedToken, $apiToken->token); } /** - * Try to find the api token in the request + * Extract the API token from the request. * - * @param \Illuminate\Http\Request $request + * @param Request $request + * @return string|null */ - public function extractToken($request) + protected function extractToken(Request $request): ?string { - if ($request->bearerToken()) { - return $request->bearerToken(); - } - - if ($request->input('api_token')) { - return $request->input('api_token'); - } - - return null; + return $request->bearerToken() ?? $request->input('api_token'); } } diff --git a/src/Commands/CreateToken.php b/src/Commands/CreateToken.php index 117226c..22e3def 100644 --- a/src/Commands/CreateToken.php +++ b/src/Commands/CreateToken.php @@ -2,9 +2,12 @@ namespace UoGSoE\ApiTokenMiddleware\Commands; +use App\Models\ApiToken; use Illuminate\Console\Command; -use UoGSoE\ApiTokenMiddleware\ApiToken; +/** + * Console command to create a new API token for a specified service. + */ class CreateToken extends Command { /** @@ -12,19 +15,17 @@ class CreateToken extends Command * * @var string */ - protected $signature = 'apitoken:create {service}'; + protected $signature = 'apitoken:create {service : The service name for the API token}'; /** * The console command description. * * @var string */ - protected $description = 'Create a new API token'; + protected $description = 'Create a new API token for a specified service'; /** * Create a new command instance. - * - * @return void */ public function __construct() { @@ -34,19 +35,42 @@ public function __construct() /** * Execute the console command. * - * @return mixed + * @return int Exit code (0 for success, 1 for failure). */ - public function handle() + public function handle(): int { + // Get the service name from the command argument $service = $this->argument('service'); - $token = ApiToken::where('service', '=', $service)->first(); - if ($token) { - $this->error('That service name is already used'); - exit; + + // Check if a token already exists for the service + if (ApiToken::where('service', $service)->exists()) { + $this->error("A token for service '$service' already exists."); + return 1; } - $token = ApiToken::createNew($service); - $this->info("Token created :"); - $this->table(['Service', 'Token'], [['service' => $service, 'token' => $token]]); + try { + // Create a new token using the ApiToken model's createNew method + $token = ApiToken::createNew($service); + + // Display the created token in a table + $this->info('Token created successfully:'); + $this->table( + ['Service', 'Token'], + [[$service, $token]] + ); + + // Provide additional usage instructions + $this->comment('Use this token in API requests via Authorization: Bearer or api_token query parameter.'); + + return 0; // Success + } catch (\InvalidArgumentException $e) { + // Handle validation errors from createNew (e.g., empty service) + $this->error('Failed to create token: ' . $e->getMessage()); + return 1; + } catch (\Exception $e) { + // Handle unexpected errors (e.g., database issues) + $this->error('An unexpected error occurred: ' . $e->getMessage()); + return 1; + } } } diff --git a/src/Commands/DeleteToken.php b/src/Commands/DeleteToken.php index 0eca6b5..a0a88f7 100644 --- a/src/Commands/DeleteToken.php +++ b/src/Commands/DeleteToken.php @@ -2,9 +2,12 @@ namespace UoGSoE\ApiTokenMiddleware\Commands; +use App\Models\ApiToken; use Illuminate\Console\Command; -use UoGSoE\ApiTokenMiddleware\ApiToken; +/** + * Console command to delete an API token for a specified service. + */ class DeleteToken extends Command { /** @@ -12,19 +15,17 @@ class DeleteToken extends Command * * @var string */ - protected $signature = 'apitoken:delete {service}'; + protected $signature = 'apitoken:delete {service : The service name of the API token to delete}'; /** * The console command description. * * @var string */ - protected $description = 'Remove an API token'; + protected $description = 'Delete an API token for a specified service'; /** * Create a new command instance. - * - * @return void */ public function __construct() { @@ -34,17 +35,36 @@ public function __construct() /** * Execute the console command. * - * @return mixed + * @return int Exit code (0 for success, 1 for failure). */ - public function handle() + public function handle(): int { + // Get the service name from the command argument $service = $this->argument('service'); - $token = ApiToken::where('service', '=', $service)->first(); - if (! $token) { - $this->error('No such service'); - exit; + + // Validate the service name + if (empty($service)) { + $this->error('Service name cannot be empty.'); + return 1; + } + + try { + // Attempt to find and delete the token for the given service + $deleted = ApiToken::where('service', $service)->delete(); + + // Check if a token was deleted + if ($deleted === 0) { + $this->error("No token found for service '$service'."); + return 1; + } + + // Confirm successful deletion + $this->info("Token for service '$service' deleted successfully."); + return 0; // Success + } catch (\Exception $e) { + // Handle unexpected errors (e.g., database issues) + $this->error('Failed to delete token: ' . $e->getMessage()); + return 1; } - $token->delete(); - $this->info("Token for {$service} removed"); } } diff --git a/src/Commands/ListTokens.php b/src/Commands/ListTokens.php index 2c9e9d5..07aa52f 100644 --- a/src/Commands/ListTokens.php +++ b/src/Commands/ListTokens.php @@ -2,9 +2,12 @@ namespace UoGSoE\ApiTokenMiddleware\Commands; -use UoGSoE\ApiTokenMiddleware\ApiToken; +use App\Models\ApiToken; use Illuminate\Console\Command; +/** + * Console command to list all API tokens. + */ class ListTokens extends Command { /** @@ -19,12 +22,10 @@ class ListTokens extends Command * * @var string */ - protected $description = 'List current API tokens'; + protected $description = 'List all current API tokens'; /** * Create a new command instance. - * - * @return void */ public function __construct() { @@ -34,10 +35,37 @@ public function __construct() /** * Execute the console command. * - * @return mixed + * @return int Exit code (0 for success, 1 for failure). */ - public function handle() + public function handle(): int { - $this->table(['Service', 'Hashed Token'], ApiToken::all(['service', 'token'])); + try { + // Retrieve all API tokens, selecting only the service and created_at fields + $tokens = ApiToken::select('service', 'created_at')->get(); + + // Check if any tokens exist + if ($tokens->isEmpty()) { + $this->info('No API tokens found.'); + return 0; // Success, but no data + } + + // Display the tokens in a table (excluding token field due to $hidden) + $this->info('Current API tokens:'); + $this->table( + ['Service', 'Created At'], + $tokens->map(function ($token) { + return [ + $token->service, + $token->created_at->toDateTimeString(), + ]; + }) + ); + + return 0; // Success + } catch (\Exception $e) { + // Handle unexpected errors (e.g., database issues) + $this->error('Failed to list tokens: ' . $e->getMessage()); + return 1; // Failure + } } } diff --git a/src/Commands/RegenerateToken.php b/src/Commands/RegenerateToken.php index c77fac3..55ce671 100644 --- a/src/Commands/RegenerateToken.php +++ b/src/Commands/RegenerateToken.php @@ -2,9 +2,12 @@ namespace UoGSoE\ApiTokenMiddleware\Commands; +use App\Models\ApiToken; use Illuminate\Console\Command; -use UoGSoE\ApiTokenMiddleware\ApiToken; +/** + * Console command to regenerate an API token for a specified service. + */ class RegenerateToken extends Command { /** @@ -12,19 +15,17 @@ class RegenerateToken extends Command * * @var string */ - protected $signature = 'apitoken:regenerate {service}'; + protected $signature = 'apitoken:regenerate {service : The service name of the API token to regenerate}'; /** * The console command description. * * @var string */ - protected $description = 'Regenerate an API token'; + protected $description = 'Regenerate an API token for a specified service'; /** * Create a new command instance. - * - * @return void */ public function __construct() { @@ -34,19 +35,36 @@ public function __construct() /** * Execute the console command. * - * @return mixed + * @return int Exit code (0 for success, 1 for failure). */ - public function handle() + public function handle(): int { + // Get the service name from the command argument $service = $this->argument('service'); - $token = ApiToken::where('service', '=', $service)->first(); - if (!$token) { - $this->error('No such service'); - exit; - } - $token = ApiToken::regenerate($service); - $this->info("Token regenerated :"); - $this->table(['Service', 'Token'], [['service' => $service, 'token' => $token]]); + try { + // Regenerate the token using the ApiToken model's regenerate method + $token = ApiToken::regenerate($service); + + // Display the regenerated token in a table + $this->info('Token regenerated successfully:'); + $this->table( + ['Service', 'Token'], + [[$service, $token]] + ); + + // Provide additional usage instructions + $this->comment('Use this token in API requests via Authorization: Bearer or api_token query parameter.'); + + return 0; // Success + } catch (\InvalidArgumentException $e) { + // Handle validation errors from regenerate (e.g., empty or non-existent service) + $this->error('Failed to regenerate token: ' . $e->getMessage()); + return 1; + } catch (\Exception $e) { + // Handle unexpected errors (e.g., database issues) + $this->error('An unexpected error occurred: ' . $e->getMessage()); + return 1; + } } } From f15d9553e7171d4709f196ccc1283ec9cc326f51 Mon Sep 17 00:00:00 2001 From: Maduka Jayalath Date: Sun, 25 May 2025 20:49:25 +0530 Subject: [PATCH 02/11] Updated tests --- tests/ArtisanTest.php | 110 ++++++++++++++++----- tests/MiddlewareTest.php | 203 +++++++++++++++++++++++++-------------- tests/TestCase.php | 56 +++++++++-- 3 files changed, 263 insertions(+), 106 deletions(-) diff --git a/tests/ArtisanTest.php b/tests/ArtisanTest.php index 740a006..f971fb9 100644 --- a/tests/ArtisanTest.php +++ b/tests/ArtisanTest.php @@ -2,79 +2,137 @@ namespace Tests; -use App\ApiToken; - +use App\Models\ApiToken; +use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Hash; +use Tests\TestCase; + +/** + * Test case for API token Artisan commands and model functionality. + */ class ArtisanTest extends TestCase { - /** @test */ - public function creating_a_new_token_stores_a_hashed_token_in_the_db() + /** + * Test that creating a new token stores a hashed token in the database. + * + * @return void + */ + public function test_creating_a_new_token_stores_a_hashed_token_in_the_db(): void { + // Create a new token for the 'test' service $token = ApiToken::createNew('test'); + // Retrieve the first token from the database $dbToken = ApiToken::first(); + // Assert that the raw token is not stored directly (it's hashed) $this->assertNotEquals($token, $dbToken->token); - $this->assertTrue(\Hash::check($token, $dbToken->token)); + // Assert that the raw token matches the hashed token in the database + $this->assertTrue(Hash::check($token, $dbToken->token)); } - /** @test */ - public function we_can_generate_a_new_hashed_token_for_an_existing_token() + /** + * Test that regenerating a token creates a new hashed token. + * + * @return void + */ + public function test_we_can_generate_a_new_hashed_token_for_an_existing_token(): void { + // Create an initial token $token = ApiToken::createNew('test'); + // Regenerate the token for the same service $newToken = ApiToken::regenerate('test'); + // Retrieve the updated token from the database $dbToken = ApiToken::first(); + + // Assert that the new token is different from the original $this->assertNotEquals($token, $newToken); - $this->assertTrue(\Hash::check($newToken, $dbToken->token)); + // Assert that the new token matches the hashed token in the database + $this->assertTrue(Hash::check($newToken, $dbToken->token)); } - /** @test */ - public function we_can_call_artisan_to_create_a_new_token() + /** + * Test that the Artisan command creates a new token. + * + * @return void + */ + public function test_we_can_call_artisan_to_create_a_new_token(): void { + // Assert that no tokens exist initially $this->assertCount(0, ApiToken::all()); + // Run the Artisan command to create a token $this->artisan('apitoken:create', ['service' => 'test']); + // Assert that one token now exists $this->assertCount(1, ApiToken::all()); + // Assert that the database has the token for the 'test' service $this->assertDatabaseHas('api_tokens', ['service' => 'test']); } - /** @test */ - public function we_can_call_artisan_to_delete_a_token() + /** + * Test that the Artisan command deletes a token. + * + * @return void + */ + public function test_we_can_call_artisan_to_delete_a_token(): void { - $token1 = ApiToken::createNew('test1'); - $token2 = ApiToken::createNew('test2'); + // Create two tokens + ApiToken::createNew('test1'); + ApiToken::createNew('test2'); + // Run the Artisan command to delete one token $this->artisan('apitoken:delete', ['service' => 'test1']); + // Assert that only one token remains $this->assertCount(1, ApiToken::all()); + // Assert that the remaining token is for 'test2' $this->assertDatabaseHas('api_tokens', ['service' => 'test2']); } - /** @test */ - public function we_can_call_artisan_to_list_all_tokens() + /** + * Test that the Artisan command lists all tokens. + * + * @return void + */ + public function test_we_can_call_artisan_to_list_all_tokens(): void { - $token1 = ApiToken::createNew('test1'); - $token2 = ApiToken::createNew('test2'); + // Create two tokens + ApiToken::createNew('test1'); + ApiToken::createNew('test2'); + // Run the Artisan command to list tokens $this->artisan('apitoken:list'); - $output = \Artisan::output(); - $this->assertContains('test1', $output); - $this->assertContains('test2', $output); + // Get the command output + $output = Artisan::output(); + + // Assert that the output contains the service names + $this->assertStringContainsString('test1', $output); + $this->assertStringContainsString('test2', $output); } - /** @test */ - public function we_can_call_artisan_to_regenerate_a_token() + /** + * Test that the Artisan command regenerates a token. + * + * @return void + */ + public function test_we_can_call_artisan_to_regenerate_a_token(): void { + // Create an initial token $token = ApiToken::createNew('test'); $dbToken = ApiToken::first(); - $this->assertTrue(\Hash::check($token, $dbToken->token)); + // Verify the initial token is valid + $this->assertTrue(Hash::check($token, $dbToken->token)); + // Run the Artisan command to regenerate the token $this->artisan('apitoken:regenerate', ['service' => 'test']); + // Retrieve the updated token from the database $dbToken = ApiToken::first(); - $this->assertFalse(\Hash::check($token, $dbToken->token)); + // Assert that the original token is no longer valid + $this->assertFalse(Hash::check($token, $dbToken->token)); } -} +} \ No newline at end of file diff --git a/tests/MiddlewareTest.php b/tests/MiddlewareTest.php index 29510df..25f41b8 100644 --- a/tests/MiddlewareTest.php +++ b/tests/MiddlewareTest.php @@ -2,128 +2,191 @@ namespace Tests; +use App\Models\ApiToken; +use Illuminate\Support\Facades\Route; +use Tests\TestCase; + +/** + * Test case for API token middleware functionality. + */ class MiddlewareTest extends TestCase { - /** @test */ - public function using_an_invalid_token_returns_unauthorised() + /** + * Test that an invalid token returns a 401 Unauthorized response. + * + * @return void + */ + public function test_using_an_invalid_token_returns_unauthorised(): void { - $token = \App\ApiToken::createNew('test'); - \Route::middleware('apitoken:test')->any('/_test/', function () { - return 'OK'; - }); + // Create a valid token for the 'test' service + $token = ApiToken::createNew('test'); + // Register a test route with the apitoken middleware + Route::middleware('apitoken:test')->any('/_test', fn() => 'OK'); - $response = $this->call('GET', '_test', ['api_token' => 'invalidtoken']); + // Send a request with an invalid token + $response = $this->call('GET', '/_test', ['api_token' => 'invalidtoken']); + // Assert that the response is 401 Unauthorized $response->assertStatus(401); } - /** @test */ - public function using_no_token_returns_unauthorised() + /** + * Test that no token returns a 401 Unauthorized response. + * + * @return void + */ + public function test_using_no_token_returns_unauthorised(): void { - $token = \App\ApiToken::createNew('test'); - \Route::middleware('apitoken:test')->any('/_test/', function () { - return 'OK'; - }); + // Create a token (not used in the request) + $token = ApiToken::createNew('test'); + // Register a test route with the apitoken middleware + Route::middleware('apitoken:test')->any('/_test', fn() => 'OK'); - $response = $this->call('GET', '_test'); + // Send a request without a token + $response = $this->call('GET', '/_test'); + // Assert that the response is 401 Unauthorized $response->assertStatus(401); } - /** @test */ - public function using_a_valid_token_as_a_url_param_returns_ok() + /** + * Test that a valid token in a URL parameter returns a 200 OK response. + * + * @return void + */ + public function test_using_a_valid_token_as_a_url_param_returns_ok(): void { - $token = \App\ApiToken::createNew('test'); - \Route::middleware('apitoken:test')->any('/_test/', function () { - return 'OK'; - }); + // Create a valid token for the 'test' service + $token = ApiToken::createNew('test'); + // Register a test route with the apitoken middleware + Route::middleware('apitoken:test')->any('/_test', fn() => 'OK'); - $response = $this->call('GET', '_test', ['api_token' => $token]); + // Send a request with the valid token as a URL parameter + $response = $this->call('GET', '/_test', ['api_token' => $token]); + // Assert that the response is 200 OK $response->assertStatus(200); } - /** @test */ - public function using_a_valid_token_as_a_json_field_returns_ok() + /** + * Test that a valid token in a JSON payload returns a 200 OK response. + * + * @return void + */ + public function test_using_a_valid_token_as_a_json_field_returns_ok(): void { - $token = \App\ApiToken::createNew('test'); - \Route::middleware('apitoken:test')->any('/_test/', function () { - return 'OK'; - }); - $response = $this->json('GET', '_test', ['api_token' => $token]); + // Create a valid token for the 'test' service + $token = ApiToken::createNew('test'); + // Register a test route with the apitoken middleware + Route::middleware('apitoken:test')->any('/_test', fn() => 'OK'); + + // Send a JSON request with the valid token + $response = $this->json('GET', '/_test', ['api_token' => $token]); + // Assert that the response is 200 OK $response->assertStatus(200); } - /** @test */ - public function using_a_valid_token_as_a_form_field_returns_ok() + /** + * Test that a valid token in a form field returns a 200 OK response. + * + * @return void + */ + public function test_using_a_valid_token_as_a_form_field_returns_ok(): void { - $token = \App\ApiToken::createNew('test'); - \Route::middleware('apitoken:test')->any('/_test/', function () { - return 'OK'; - }); - $response = $this->call('POST', '_test', ['api_token' => $token]); + // Create a valid token for the 'test' service + $token = ApiToken::createNew('test'); + // Register a test route with the apitoken middleware + Route::middleware('apitoken:test')->any('/_test', fn() => 'OK'); + + // Send a POST request with the valid token as a form field + $response = $this->call('POST', '/_test', ['api_token' => $token]); + // Assert that the response is 200 OK $response->assertStatus(200); } - /** @test */ - public function using_a_valid_token_as_a_bearer_token_returns_ok() + /** + * Test that a valid token as a Bearer token returns a 200 OK response. + * + * @return void + */ + public function test_using_a_valid_token_as_a_bearer_token_returns_ok(): void { - $token = \App\ApiToken::createNew('test'); - \Route::middleware('apitoken:test')->any('/_test/', function () { - return 'OK'; - }); + // Create a valid token for the 'test' service + $token = ApiToken::createNew('test'); + // Register a test route with the apitoken middleware + Route::middleware('apitoken:test')->any('/_test', fn() => 'OK'); - $response = $this->withHeaders(['Authorization' => 'Bearer '.$token])->get('_test'); + // Send a request with the valid token as a Bearer token + $response = $this->withHeaders(['Authorization' => "Bearer {$token}"])->get('/_test'); + // Assert that the response is 200 OK $response->assertStatus(200); } - /** @test */ - public function we_can_use_multiple_api_service_tokens() + /** + * Test that multiple service tokens can be used, and invalid ones are rejected. + * + * @return void + */ + public function test_we_can_use_multiple_api_service_tokens(): void { - $this->withoutExceptionHandling(); - $token1 = \App\ApiToken::createNew('test1'); - $token2 = \App\ApiToken::createNew('test2'); - $token3 = \App\ApiToken::createNew('test3'); - \Route::middleware('apitoken:test1,test2')->any('/_test/', function () { - return 'OK'; - }); - - $response = $this->call('GET', '_test', ['api_token' => $token1]); + // Create tokens for multiple services + $token1 = ApiToken::createNew('test1'); + $token2 = ApiToken::createNew('test2'); + $token3 = ApiToken::createNew('test3'); + // Register a test route allowing test1 and test2 services + Route::middleware('apitoken:test1,test2')->any('/_test', fn() => 'OK'); + + // Test with token1 (valid) + $response = $this->call('GET', '/_test', ['api_token' => $token1]); $response->assertStatus(200); - $response = $this->call('GET', '_test', ['api_token' => $token2]); + // Test with token2 (valid) + $response = $this->call('GET', '/_test', ['api_token' => $token2]); $response->assertStatus(200); - $response = $this->call('GET', '_test', ['api_token' => $token3]); + // Test with token3 (invalid for this route) + $response = $this->call('GET', '/_test', ['api_token' => $token3]); $response->assertStatus(401); } - /** @test */ - public function using_a_non_existant_service_name_always_returns_unauthorised() + /** + * Test that a non-existent service name returns a 401 Unauthorized response. + * + * @return void + */ + public function test_using_a_non_existant_service_name_always_returns_unauthorised(): void { - $token = \App\ApiToken::createNew('test'); - \Route::middleware('apitoken:nottest')->any('/_test/', function () { - return 'OK'; - }); + // Create a token for a valid service + $token = ApiToken::createNew('test'); + // Register a test route with a non-existent service + Route::middleware('apitoken:nottest')->any('/_test', fn() => 'OK'); - $response = $this->call('GET', '_test', ['api_token' => $token]); + // Send a request with the token + $response = $this->call('GET', '/_test', ['api_token' => $token]); + // Assert that the response is 401 Unauthorized $response->assertStatus(401); } - /** @test */ - public function using_no_service_name_always_returns_unauthorised() + /** + * Test that no service name returns a 401 Unauthorized response. + * + * @return void + */ + public function test_using_no_service_name_always_returns_unauthorised(): void { - $token = \App\ApiToken::createNew('test'); - \Route::middleware('apitoken')->any('/_test/', function () { - return 'OK'; - }); + // Create a token for a valid service + $token = ApiToken::createNew('test'); + // Register a test route with no service specified + Route::middleware('apitoken')->any('/_test', fn() => 'OK'); - $response = $this->call('GET', '_test', ['api_token' => $token]); + // Send a request with the token + $response = $this->call('GET', '/_test', ['api_token' => $token]); + // Assert that the response is 401 Unauthorized $response->assertStatus(401); } -} +} \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php index 7672dc8..a4f87f3 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,24 +2,60 @@ namespace Tests; -class TestCase extends \Orchestra\Testbench\TestCase +use Orchestra\Testbench\TestCase as OrchestraTestCase; + +/** + * Base test case for package testing, setting up migrations and service providers. + */ +class TestCase extends OrchestraTestCase { + /** + * Set up the test environment. + * + * @return void + */ protected function setUp(): void { parent::setUp(); - $this->loadMigrationsFrom(realpath(__DIR__.'/../migrations')); - $this->artisan('migrate', ['--database' => 'testing']); + + // Load migrations from the package's migrations directory + $migrationPath = realpath(__DIR__ . '/../migrations'); + if ($migrationPath === false) { + $this->fail('Migration directory not found at ' . __DIR__ . '/../migrations'); + } + $this->loadMigrationsFrom($migrationPath); + + // Run migrations for the testing database + $this->artisan('migrate', ['--database' => 'testing'])->run(); } - protected function getPackageProviders($app) + /** + * Define package service providers for testing. + * + * @param \Illuminate\Foundation\Application $app + * @return array + */ + protected function getPackageProviders($app): array { return [ - 'UoGSoE\ApiTokenMiddleware\ApiTokenServiceProvider', + \UoGSoE\ApiTokenMiddleware\ApiTokenServiceProvider::class, ]; } - // protected function resolveApplicationConsoleKernel($app) - // { - // $app->singleton('Illuminate\Contracts\Console\Kernel', 'Acme\Testbench\Console\Kernel'); - // } -} + /** + * Define environment setup. + * + * @param \Illuminate\Foundation\Application $app + * @return void + */ + protected function getEnvironmentSetUp($app): void + { + // Configure an in-memory SQLite database for testing + $app['config']->set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } +} \ No newline at end of file From dd90c16e13552d842828d6192f32ff0c8565c7fa Mon Sep 17 00:00:00 2001 From: Maduka Jayalath Date: Sun, 25 May 2025 20:52:58 +0530 Subject: [PATCH 03/11] Updated phpunit --- phpunit.xml | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/phpunit.xml b/phpunit.xml index 08800d5..22afabf 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,5 +1,7 @@ - + stopOnFailure="false" + beStrictAboutTestsThatDoNotTestAnything="true"> + ./tests - - + + + + ./app - - + ./vendor/uogsoe/apitokenmiddleware/src + + + + + + + + - + - + - + \ No newline at end of file From 04bd93958195119b9376cb9974f49a046063bf8b Mon Sep 17 00:00:00 2001 From: Maduka Jayalath Date: Sun, 25 May 2025 21:01:55 +0530 Subject: [PATCH 04/11] composer update --- composer.json | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/composer.json b/composer.json index d346f35..886fb9e 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { - "name": "uogsoe/basic-api-token-middleware", - "description": "Very basic API key middleware for Laravel", + "name": "uogsoe/apitokenmiddleware", + "description": "Basic API token middleware for Laravel", "type": "library", "license": "MIT", "authors": [ @@ -9,21 +9,20 @@ "email": "william.allan@glasgow.ac.uk" } ], + "require": {}, + "require-dev": { + "orchestra/testbench": "^10.0" + }, "autoload": { "psr-4": { - "UoGSoE\\ApiTokenMiddleware\\": "src" + "UoGSoE\\ApiTokenMiddleware\\": "src/" } }, "autoload-dev": { "psr-4": { - "Tests\\": "tests", - "App\\": "src" + "Tests\\": "tests/" } }, - "require": {}, - "require-dev": { - "orchestra/testbench": "~3.0" - }, "extra": { "laravel": { "providers": [ @@ -31,4 +30,4 @@ ] } } -} +} \ No newline at end of file From a186e76d3066e94028453c1004568abcf1ec91f5 Mon Sep 17 00:00:00 2001 From: Maduka Jayalath Date: Sun, 25 May 2025 21:04:40 +0530 Subject: [PATCH 05/11] rename readme to README --- Readme.md => README.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Readme.md => README.md (100%) diff --git a/Readme.md b/README.md similarity index 100% rename from Readme.md rename to README.md From 27024915d90123101b50f2a8de2fab03a5a1282a Mon Sep 17 00:00:00 2001 From: Maduka Jayalath Date: Sun, 25 May 2025 21:15:35 +0530 Subject: [PATCH 06/11] composer update --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 886fb9e..825e76e 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { - "name": "uogsoe/apitokenmiddleware", - "description": "Basic API token middleware for Laravel", + "name": "uogsoe/basic-api-token-middleware", + "description": "Very basic API key middleware for Laravel", "type": "library", "license": "MIT", "authors": [ From f76db461cd10d24b9abf736904a942be143bf02c Mon Sep 17 00:00:00 2001 From: Maduka Jayalath Date: Mon, 26 May 2025 06:34:02 +0530 Subject: [PATCH 07/11] Prevent API tokens from being passed in query parameters or POST bodies due to risks like exposure in logs, browser history, and XSS vulnerabilities. --- README.md | 176 ++++++++++++++++++++++++-------- src/BasicApiTokenMiddleware.php | 11 +- 2 files changed, 140 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 8039896..000e3f6 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,169 @@ -# Basic API key middleware for Laravel -This is a simple key-based middleware for Laravel. It suited our common use-case of internal apps which need access to other internal apps (machine-to-machine) without the hassles of oauth etc. +# Basic API Token Middleware for Laravel + +This middleware provides a simple, secure token-based authentication mechanism for Laravel, designed for internal machine-to-machine communication (e.g., internal apps accessing other internal APIs) without the complexity of OAuth. + +> ⚠️ **Security Note**: Always send API tokens via `Authorization: Bearer` headers. Do not use query parameters or POST body fields as they are insecure and unsupported. + +--- ## Installation -You should be able to pull it in using composer : +Install the package via Composer: -``` +```bash composer require uogsoe/basic-api-token-middleware ``` -Then you have to publish the database migration and ApiKey model : -``` +Publish the database migration and model: + +```bash php artisan vendor:publish ``` -And pick `UoGSoE\ApiTokenMiddleware\ApiTokenServiceProvider` from the list. Then run the migration : -``` + +Select:`UoGSoE\ApiTokenMiddleware\ApiTokenServiceProvider` + +Run the migration to create the `api_tokens` table: + +```bash php artisan migrate ``` +--- + ## Usage -First of all you create a token for the consuming 'service' (eg, the remote client) : -``` +### Creating a Token + +Generate a token for a service (e.g., `testservice`): + +```bash php artisan apitoken:create testservice ``` -That will create the token and show it to you. You need to take note of the token as your client will have to use it to access the routes. -Now in your `routes/api.php` file you can use the middleware to wrap endpoints : -``` -Route::group(['middleware' => 'apitoken:testservice'], function () { - Route::get('/hello', function () { - return 'hello'; - }); +> The token will only be displayed once. Store it securely. + +--- + +### Protecting Routes + +In `routes/api.php`, apply the middleware: + +```php +use Illuminate\Support\Facades\Route; + +Route::middleware('apitoken:testservice')->group(function () { + Route::get('/hello', fn() => response()->json(['message' => 'Hello, World!'])); }); ``` -If you try and access that route without passing the token you will get a 401 response : +Multiple services: + +```php +Route::middleware('apitoken:testservice,anotherservice')->group(function () { + Route::get('/hello', fn() => response()->json(['message' => 'Hello, World!'])); +}); ``` -curl -kv https://my-project.test/api/hello -... -HTTP/2 401 -{"message":"Unauthorized"} + +--- + +### Authenticating Requests + +Send requests using the Authorization header: + +```bash +curl -H "Authorization: Bearer jT7ryt28gi3YCvgE4WvluO1uVcb0ndVx" https://my-project.test/api/hello ``` -So pass the token you created above and it should let you through : + +**Successful Response:** + +```json +{"message": "Hello, World!"} ``` -curl -kv https://my-project.test/api/hello?api_token=jT7ryt28gi3YCvgE4WvluO1uVcb0ndVx -... -HTTP/2 200 -hello + +**Unauthorized Response:** + +```json +{"message":"Unauthorized"} ``` -You can pass the token in various ways, like a GET param as above, a bearer token header or as part of the JSON body. Eg: +**AJAX Example:** + +```javascript +fetch('https://my-project.test/api/hello', { + method: 'GET', + headers: { + 'Authorization': 'Bearer jT7ryt28gi3YCvgE4WvluO1uVcb0ndVx', + 'Accept': 'application/json' + } +}) +.then(response => response.json()) +.then(data => console.log(data)) +.catch(error => console.error('Error:', error)); ``` -$this->withHeaders([ - 'Authorization' => 'Bearer '.$tokenString, -])->get('https://my-project.test/api/hello'); -$this->json('POST', 'https://my-project.test/api/hello', ['api_token' => $token]); +> ❗ Avoid sending tokens via query strings or POST bodies - will result in a 401 Unauthorized response. + +--- + +## Managing Tokens -$this->call('POST', 'https://my-project.test/api/hello', ['api_token' => $token]); +- **List all tokens:** + +```bash +php artisan apitoken:list ``` -You can use multiple service token names with a route if you want to seperate your api controls too : +- **Regenerate a token:** + +```bash +php artisan apitoken:regenerate testservice ``` -Route::group(['middleware' => 'apitoken:testservice,anotherservice'], function () { - Route::get('/hello', function () { - return 'hello'; - }); -}); + +- **Delete a token:** + +```bash +php artisan apitoken:delete testservice ``` -There are a few other artisan commands available to help manage the tokens : +--- + +## Security Best Practices + +- **Use HTTPS**: Encrypt all API traffic. +- **Secure Token Storage**: Use environment variables, secret vaults, or HTTP-only secure cookies. Avoid client-side exposure. +- **Token Expiry**: Use `apitoken:regenerate` periodically. +- **CORS Configuration** (in `config/cors.php`): + +```php +'allowed_origins' => ['https://your-frontend.com'], +'supports_credentials' => true, ``` -php artisan apitoken:list -- lists all current tokens -php artisan apitoken:regenerate -- create a new token for a given service -php artisan apitoken:delete -- deletes a given service token + +- **Rate Limiting**: + +```php +Route::middleware('throttle:60,1')->get('/hello', fn() => response()->json(['message' => 'Hello, World!'])); ``` + +- **XSS Protection**: Use Content Security Policy (CSP) and sanitize inputs. + +--- + +## Upgrading from Previous Versions + +If you were using `?api_token=` in URLs or POST bodies, **update clients** to use `Authorization: Bearer` headers immediately. These methods are no longer supported. + +--- + +## Contributing + +Contributions are welcome! +Submit PRs at: [https://github.com/uogsoe/basic-api-token-middleware](https://github.com/uogsoe/basic-api-token-middleware) +Ensure tests and security practices are followed. + +--- + +## License + +This project is licensed under the **MIT License**. diff --git a/src/BasicApiTokenMiddleware.php b/src/BasicApiTokenMiddleware.php index b39d268..4a15a0f 100644 --- a/src/BasicApiTokenMiddleware.php +++ b/src/BasicApiTokenMiddleware.php @@ -2,12 +2,15 @@ namespace UoGSoE\ApiTokenMiddleware; -use Closure; use App\Models\ApiToken; +use Closure; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; +/** + * Middleware to authenticate API requests using Bearer tokens. + */ class BasicApiTokenMiddleware { public const CODE = 401; @@ -58,13 +61,13 @@ protected function authorized(Request $request, array $services): bool } /** - * Extract the API token from the request. + * Extract the API token from the request's Authorization header. * * @param Request $request * @return string|null */ protected function extractToken(Request $request): ?string { - return $request->bearerToken() ?? $request->input('api_token'); + return $request->bearerToken(); } -} +} \ No newline at end of file From e9609398e8ac273ea5196d9ff2bab34f37e7e6ae Mon Sep 17 00:00:00 2001 From: Maduka Jayalath Date: Mon, 26 May 2025 06:52:05 +0530 Subject: [PATCH 08/11] update README.md --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 000e3f6..46d9b23 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,15 @@ curl -H "Authorization: Bearer jT7ryt28gi3YCvgE4WvluO1uVcb0ndVx" https://my-proj {"message":"Unauthorized"} ``` +**Laravel Example:** + +```php +use Illuminate\Support\Facades\Http; +Http::withHeaders([ + 'Authorization' => 'Bearer jT7ryt28gi3YCvgE4WvluO1uVcb0ndVx', +])->get('https://my-project.test/api/hello'); +``` + **AJAX Example:** ```javascript From c365299800a54b1d95996dc3eb47d7eec1960086 Mon Sep 17 00:00:00 2001 From: Maduka Jayalath Date: Mon, 26 May 2025 06:54:01 +0530 Subject: [PATCH 09/11] update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 46d9b23..afc883e 100644 --- a/README.md +++ b/README.md @@ -78,12 +78,14 @@ curl -H "Authorization: Bearer jT7ryt28gi3YCvgE4WvluO1uVcb0ndVx" https://my-proj **Successful Response:** ```json +HTTP/2 200 {"message": "Hello, World!"} ``` **Unauthorized Response:** ```json +HTTP/2 401 {"message":"Unauthorized"} ``` From e7ecc7f6065a9b576a4bf616ef1beb0996446c2e Mon Sep 17 00:00:00 2001 From: Maduka Jayalath Date: Mon, 26 May 2025 07:31:05 +0530 Subject: [PATCH 10/11] Optimisations --- src/BasicApiTokenMiddleware.php | 15 ++++++++------- src/Commands/CreateToken.php | 2 +- src/Commands/RegenerateToken.php | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/BasicApiTokenMiddleware.php b/src/BasicApiTokenMiddleware.php index 4a15a0f..4db9861 100644 --- a/src/BasicApiTokenMiddleware.php +++ b/src/BasicApiTokenMiddleware.php @@ -48,16 +48,17 @@ protected function authorized(Request $request, array $services): bool return false; } - // Optimize query to fetch only the first matching token - $apiToken = ApiToken::whereIn('service', $services) - ->whereNotNull('token') - ->first(); + foreach ($services as $service) { + $apiToken = ApiToken::where('service', $service) + ->whereNotNull('token') + ->first(); - if (!$apiToken) { - return false; + if ($apiToken && Hash::check($passedToken, $apiToken->token)) { + return true; + } } - return Hash::check($passedToken, $apiToken->token); + return false; } /** diff --git a/src/Commands/CreateToken.php b/src/Commands/CreateToken.php index 22e3def..2c453aa 100644 --- a/src/Commands/CreateToken.php +++ b/src/Commands/CreateToken.php @@ -60,7 +60,7 @@ public function handle(): int ); // Provide additional usage instructions - $this->comment('Use this token in API requests via Authorization: Bearer or api_token query parameter.'); + $this->comment('Use this token in API requests via Authorization: Bearer .'); return 0; // Success } catch (\InvalidArgumentException $e) { diff --git a/src/Commands/RegenerateToken.php b/src/Commands/RegenerateToken.php index 55ce671..f77d22d 100644 --- a/src/Commands/RegenerateToken.php +++ b/src/Commands/RegenerateToken.php @@ -54,7 +54,7 @@ public function handle(): int ); // Provide additional usage instructions - $this->comment('Use this token in API requests via Authorization: Bearer or api_token query parameter.'); + $this->comment('Use this token in API requests via Authorization: Bearer .'); return 0; // Success } catch (\InvalidArgumentException $e) { From 07e4f3d8177373c9e3b65a278dd85f1a11792afd Mon Sep 17 00:00:00 2001 From: Maduka Jayalath Date: Wed, 4 Jun 2025 15:44:21 +0530 Subject: [PATCH 11/11] Laravel 12's migration convention --- migrations/2018_04_18_090739_create_api_tokens_table.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/2018_04_18_090739_create_api_tokens_table.php b/migrations/2018_04_18_090739_create_api_tokens_table.php index aa90e83..ab49627 100644 --- a/migrations/2018_04_18_090739_create_api_tokens_table.php +++ b/migrations/2018_04_18_090739_create_api_tokens_table.php @@ -14,7 +14,7 @@ class CreateApiTokensTable extends Migration public function up() { Schema::create('api_tokens', function (Blueprint $table) { - $table->increments('id'); + $table->id(); $table->string('service'); $table->string('token'); $table->timestamps();