From d7f2532ffbfd7622f3dc389684eabd3e5d2d21ed Mon Sep 17 00:00:00 2001 From: gusalberto Date: Thu, 21 May 2026 22:55:55 -0300 Subject: [PATCH 1/3] feat(auth): add temporary session login endpoint --- routes/web.php | 22 ++++++++++++++++++++ tests/Feature/SessionLoginTest.php | 33 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 tests/Feature/SessionLoginTest.php diff --git a/routes/web.php b/routes/web.php index 86a06c5..5b65385 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,29 @@ validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + ]); + + if (! Auth::attempt($credentials)) { + return response()->json([ + 'message' => 'Credenciais inválidas.', + ], 422); + } + + $request->session()->regenerate(); + + return response()->json([ + 'message' => 'Sessão autenticada criada com sucesso.', + ]); +})->withoutMiddleware(ValidateCsrfToken::class); diff --git a/tests/Feature/SessionLoginTest.php b/tests/Feature/SessionLoginTest.php new file mode 100644 index 0000000..8675c3e --- /dev/null +++ b/tests/Feature/SessionLoginTest.php @@ -0,0 +1,33 @@ +create([ + 'email' => 'admin@example.com', + 'password' => 'password', + ]); + + $loginResponse = $this->post('/session-login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $loginResponse + ->assertOk() + ->assertJsonPath('message', 'Sessão autenticada criada com sucesso.'); + + $this->assertAuthenticated(); + + $apiResponse = $this->getJson('/api/products'); + + $apiResponse + ->assertOk() + ->assertJsonPath('success', true); + } +} \ No newline at end of file From 406d5dda4484aa6652a4a2d65b0718b4e4240d75 Mon Sep 17 00:00:00 2001 From: gusalberto Date: Thu, 21 May 2026 23:16:56 -0300 Subject: [PATCH 2/3] feat(auth): add browser login page and fix login redirect --- .../Controllers/Api/ProductController.php | 28 +-- resources/views/session-login.blade.php | 220 ++++++++++++++++++ routes/web.php | 20 ++ tests/Feature/SessionLoginTest.php | 2 +- 4 files changed, 256 insertions(+), 14 deletions(-) create mode 100644 resources/views/session-login.blade.php diff --git a/app/Http/Controllers/Api/ProductController.php b/app/Http/Controllers/Api/ProductController.php index fa12c2a..a2b68c3 100644 --- a/app/Http/Controllers/Api/ProductController.php +++ b/app/Http/Controllers/Api/ProductController.php @@ -48,24 +48,26 @@ public function index(Request $request) $page = max(1, (int) $request->integer('page', self::DEFAULT_PAGE)); $cacheKey = $this->buildIndexCacheKey($filters, $page, $perPage); - $products = Cache::remember($cacheKey, now()->addMinutes(10), function () use ($filters, $perPage, $page) { - return Product::query() + $payload = Cache::remember($cacheKey, now()->addMinutes(10), function () use ($filters, $perPage, $page) { + $products = Product::query() ->filter($filters) ->orderByDesc('id') ->paginate($perPage, ['*'], 'page', $page); + + return [ + 'items' => ProductResource::collection($products->items())->resolve(), + 'meta' => [ + 'current_page' => $products->currentPage(), + 'last_page' => $products->lastPage(), + 'per_page' => $products->perPage(), + 'total' => $products->total(), + 'from' => $products->firstItem(), + 'to' => $products->lastItem(), + ], + ]; }); - return $this->success('Products retrieved successfully.', [ - 'items' => ProductResource::collection($products->items()), - 'meta' => [ - 'current_page' => $products->currentPage(), - 'last_page' => $products->lastPage(), - 'per_page' => $products->perPage(), - 'total' => $products->total(), - 'from' => $products->firstItem(), - 'to' => $products->lastItem(), - ], - ]); + return $this->success('Products retrieved successfully.', $payload); } private function buildIndexCacheKey(array $filters, int $page, int $perPage): string diff --git a/resources/views/session-login.blade.php b/resources/views/session-login.blade.php new file mode 100644 index 0000000..7646584 --- /dev/null +++ b/resources/views/session-login.blade.php @@ -0,0 +1,220 @@ + + + + + + Login temporário + + + +
+
Acesso temporário
+

Login no navegador

+

Use esta página para gerar uma sessão autenticada e testar a API sem `curl`.

+ +
+
+
+ + +
+
+ + +
+
+ +
+ + Abrir Swagger + Abrir produtos +
+
+ +
Ainda não autenticado.
+
Depois do login, a mesma sessão do navegador será usada para acessar /browser/products.
+
+ + + + \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 5b65385..2c6d291 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,7 @@ 'admin@example.com'], + [ + 'name' => 'Admin Demo', + 'password' => 'password', + ] + ); + + return view('session-login'); +})->name('login'); + +Route::get('/login', function () { + return redirect('/login-temporario'); +}); + +Route::middleware(['auth', 'throttle:products-api'])->get('/browser/products', [ProductController::class, 'index']); + Route::post('/session-login', function (Request $request) { $credentials = $request->validate([ 'email' => ['required', 'email'], diff --git a/tests/Feature/SessionLoginTest.php b/tests/Feature/SessionLoginTest.php index 8675c3e..d01457a 100644 --- a/tests/Feature/SessionLoginTest.php +++ b/tests/Feature/SessionLoginTest.php @@ -24,7 +24,7 @@ public function test_temporary_session_login_allows_access_to_products_api(): vo $this->assertAuthenticated(); - $apiResponse = $this->getJson('/api/products'); + $apiResponse = $this->getJson('/browser/products'); $apiResponse ->assertOk() From 890ef18d271e546a5031acefbee0f1b2914361f9 Mon Sep 17 00:00:00 2001 From: gusalberto Date: Thu, 21 May 2026 23:55:04 -0300 Subject: [PATCH 3/3] feat(auth): enable Swagger bearer auth for products API --- README.md | 9 ++++++ .../AuthenticateProductRequests.php | 29 +++++++++++++++++++ app/Providers/AppServiceProvider.php | 8 +++++ bootstrap/app.php | 4 ++- resources/views/session-login.blade.php | 17 +++++++++++ routes/api.php | 2 +- routes/web.php | 4 ++- tests/Feature/SessionLoginTest.php | 11 +++++++ 8 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 app/Http/Middleware/AuthenticateProductRequests.php diff --git a/README.md b/README.md index f21b7bf..3054c38 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,15 @@ Este projeto usa o gerador OpenAPI do Laravel via `dedoc/scramble`. - UI interativa: `/docs/api` - Especificação JSON: `/docs/api.json` +Para executar `Try it out` sem erro `401 Unauthorized`: + +1. Abra `/docs/api`. +2. Clique em `Authorize`. +3. Informe `Bearer local-demo-token`. +4. Execute o endpoint `POST /api/products`. + +Opcionalmente, você pode abrir `/login-temporario` para testar o fluxo por sessão no navegador. + Os endpoints de produto já incluem exemplos de parâmetros e payloads, além de códigos de status documentados na especificação gerada. ### Listar Produtos diff --git a/app/Http/Middleware/AuthenticateProductRequests.php b/app/Http/Middleware/AuthenticateProductRequests.php new file mode 100644 index 0000000..364ba10 --- /dev/null +++ b/app/Http/Middleware/AuthenticateProductRequests.php @@ -0,0 +1,29 @@ +bearerToken(); + + if ($token !== null && hash_equals($expectedToken, $token)) { + return $next($request); + } + + return response()->json([ + 'message' => 'Unauthenticated.', + ], 401); + } +} \ No newline at end of file diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 26d97bb..9d4d311 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,9 @@ namespace App\Providers; +use Dedoc\Scramble\Scramble; +use Dedoc\Scramble\Support\Generator\OpenApi; +use Dedoc\Scramble\Support\Generator\SecurityScheme; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; @@ -22,6 +25,11 @@ public function register(): void */ public function boot(): void { + Scramble::configure() + ->withDocumentTransformers(function (OpenApi $openApi): void { + $openApi->secure(SecurityScheme::http('bearer')); + }); + RateLimiter::for('products-api', function (Request $request): Limit { return Limit::perMinute(5)->by($request->user()?->id ?? $request->ip()); }); diff --git a/bootstrap/app.php b/bootstrap/app.php index c3928c5..a913727 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,7 +12,9 @@ health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'product.auth' => \App\Http\Middleware\AuthenticateProductRequests::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/resources/views/session-login.blade.php b/resources/views/session-login.blade.php index 7646584..a2339b1 100644 --- a/resources/views/session-login.blade.php +++ b/resources/views/session-login.blade.php @@ -136,6 +136,18 @@ color: var(--muted); } + .token { + margin-top: 16px; + padding: 14px 16px; + border-radius: 14px; + background: rgba(34, 197, 94, 0.12); + border: 1px solid rgba(34, 197, 94, 0.25); + color: var(--text); + font-size: 14px; + line-height: 1.5; + word-break: break-word; + } + @media (max-width: 720px) { .card { padding: 20px; } .grid { grid-template-columns: 1fr; } @@ -168,6 +180,11 @@
Ainda não autenticado.
+
+ Token do Swagger: {{ $swaggerToken }} +
+ No Swagger, clique em Authorize e use Bearer {{ $swaggerToken }}. +
Depois do login, a mesma sessão do navegador será usada para acessar /browser/products.
diff --git a/routes/api.php b/routes/api.php index 8b91ade..5d93855 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,4 +3,4 @@ use App\Http\Controllers\Api\ProductController; use Illuminate\Support\Facades\Route; -Route::middleware(['auth', 'throttle:products-api'])->apiResource('products', ProductController::class); \ No newline at end of file +Route::middleware(['product.auth', 'throttle:products-api'])->apiResource('products', ProductController::class); \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 2c6d291..9eb96a7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -20,7 +20,9 @@ ] ); - return view('session-login'); + return view('session-login', [ + 'swaggerToken' => env('PRODUCTS_API_TOKEN', 'local-demo-token'), + ]); })->name('login'); Route::get('/login', function () { diff --git a/tests/Feature/SessionLoginTest.php b/tests/Feature/SessionLoginTest.php index d01457a..72b5547 100644 --- a/tests/Feature/SessionLoginTest.php +++ b/tests/Feature/SessionLoginTest.php @@ -30,4 +30,15 @@ public function test_temporary_session_login_allows_access_to_products_api(): vo ->assertOk() ->assertJsonPath('success', true); } + + public function test_demo_bearer_token_allows_api_access_without_session(): void + { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer local-demo-token', + ])->getJson('/api/products'); + + $response + ->assertOk() + ->assertJsonPath('success', true); + } } \ No newline at end of file