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/README.md b/README.md new file mode 100644 index 0000000..afc883e --- /dev/null +++ b/README.md @@ -0,0 +1,180 @@ + +# 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 + +Install the package via Composer: + +```bash +composer require uogsoe/basic-api-token-middleware +``` + +Publish the database migration and model: + +```bash +php artisan vendor:publish +``` + +Select:`UoGSoE\ApiTokenMiddleware\ApiTokenServiceProvider` + +Run the migration to create the `api_tokens` table: + +```bash +php artisan migrate +``` + +--- + +## Usage + +### Creating a Token + +Generate a token for a service (e.g., `testservice`): + +```bash +php artisan apitoken:create testservice +``` + +> 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!'])); +}); +``` + +Multiple services: + +```php +Route::middleware('apitoken:testservice,anotherservice')->group(function () { + Route::get('/hello', fn() => response()->json(['message' => 'Hello, World!'])); +}); +``` + +--- + +### Authenticating Requests + +Send requests using the Authorization header: + +```bash +curl -H "Authorization: Bearer jT7ryt28gi3YCvgE4WvluO1uVcb0ndVx" https://my-project.test/api/hello +``` + +**Successful Response:** + +```json +HTTP/2 200 +{"message": "Hello, World!"} +``` + +**Unauthorized Response:** + +```json +HTTP/2 401 +{"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 +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)); +``` + +> ❗ Avoid sending tokens via query strings or POST bodies - will result in a 401 Unauthorized response. + +--- + +## Managing Tokens + +- **List all tokens:** + +```bash +php artisan apitoken:list +``` + +- **Regenerate a token:** + +```bash +php artisan apitoken:regenerate testservice +``` + +- **Delete a token:** + +```bash +php artisan apitoken:delete testservice +``` + +--- + +## 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, +``` + +- **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/Readme.md b/Readme.md deleted file mode 100644 index 8039896..0000000 --- a/Readme.md +++ /dev/null @@ -1,79 +0,0 @@ -# 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. - -## Installation - -You should be able to pull it in using composer : - -``` -composer require uogsoe/basic-api-token-middleware -``` - -Then you have to publish the database migration and ApiKey model : -``` -php artisan vendor:publish -``` -And pick `UoGSoE\ApiTokenMiddleware\ApiTokenServiceProvider` from the list. Then run the migration : -``` -php artisan migrate -``` - -## Usage - -First of all you create a token for the consuming 'service' (eg, the remote client) : -``` -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'; - }); -}); -``` - -If you try and access that route without passing the token you will get a 401 response : -``` -curl -kv https://my-project.test/api/hello -... -HTTP/2 401 -{"message":"Unauthorized"} -``` -So pass the token you created above and it should let you through : -``` -curl -kv https://my-project.test/api/hello?api_token=jT7ryt28gi3YCvgE4WvluO1uVcb0ndVx -... -HTTP/2 200 -hello -``` - -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: -``` -$this->withHeaders([ - 'Authorization' => 'Bearer '.$tokenString, -])->get('https://my-project.test/api/hello'); - -$this->json('POST', 'https://my-project.test/api/hello', ['api_token' => $token]); - -$this->call('POST', 'https://my-project.test/api/hello', ['api_token' => $token]); -``` - -You can use multiple service token names with a route if you want to seperate your api controls too : -``` -Route::group(['middleware' => 'apitoken:testservice,anotherservice'], function () { - Route::get('/hello', function () { - return 'hello'; - }); -}); -``` - -There are a few other artisan commands available to help manage the tokens : -``` -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 -``` diff --git a/composer.json b/composer.json index d346f35..825e76e 100644 --- a/composer.json +++ b/composer.json @@ -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 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(); 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 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..4db9861 100644 --- a/src/BasicApiTokenMiddleware.php +++ b/src/BasicApiTokenMiddleware.php @@ -2,68 +2,73 @@ namespace UoGSoE\ApiTokenMiddleware; +use App\Models\ApiToken; use Closure; -use UoGSoE\ApiTokenMiddleware\ApiToken; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Hash; +/** + * Middleware to authenticate API requests using Bearer tokens. + */ 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()) { - return false; + foreach ($services as $service) { + $apiToken = ApiToken::where('service', $service) + ->whereNotNull('token') + ->first(); + + if ($apiToken && Hash::check($passedToken, $apiToken->token)) { + return true; + } } - return !is_null($apiTokens->first(function ($apiToken) use ($passedToken) { - return \Hash::check($passedToken, $apiToken->token); - })); + return false; } /** - * Try to find the api token in the request + * Extract the API token from the request's Authorization header. * - * @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(); } -} +} \ No newline at end of file diff --git a/src/Commands/CreateToken.php b/src/Commands/CreateToken.php index 117226c..2c453aa 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 .'); + + 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..f77d22d 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 .'); + + 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; + } } } 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