Skip to content

Commit

Permalink
Merge pull request #8 from totem-it/master-feat-auth
Browse files Browse the repository at this point in the history
Master feat auth
  • Loading branch information
rudashi authored Oct 24, 2024
2 parents 2a1a9f6 + 85b8cd8 commit 806831c
Show file tree
Hide file tree
Showing 7 changed files with 289 additions and 2 deletions.
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ Remember to put repository in a composer.json

Functionalities are organized into packages within the src/Bundles folder:

- [Auth](#Auth)
- [AuthorizedRequest](#AuthorizedRequest)
- [TrustOnlyAuthenticated](#TrustOnlyAuthenticated)
- [Middleware](#middleware)
- [LocalizationMiddleware](#LocalizationMiddleware)
- [ForceJsonMiddleware](#ForceJsonMiddleware)
Expand All @@ -45,6 +48,26 @@ Functionalities are organized into packages within the src/Bundles folder:

---

## Auth

### AuthorizedRequest

The trait is used in `FormRequest` classes to automatically check if a user is authorized to perform a given action.
It ensures that only authenticated users can proceed with the request.

### TrustOnlyAuthenticated

The middleware checks if the authenticated user’s UUID matches the UUID in the route

example:

```php
Route::middleware(TrustOnlyAuthenticated::class)->group(function () {
Route::post('/user/{uuid}/update', [UserController::class, 'update']);
```

---

## Middleware

### LocalizationMiddleware
Expand All @@ -59,6 +82,7 @@ Route::middleware(LocalizationMiddleware::class)->get('/', [MyController::class,
```

### ForceJsonMiddleware

This middleware changes the `accept: *` header to `accept: application/json`.

example:
Expand All @@ -77,7 +101,8 @@ Route::middleware(ForceJsonMiddleware::class)->get('/', [MyController::class, 'i

### ApiCollection

Used to return a collection of models in an API response. Extends the ResourceCollection by providing additional information
Used to return a collection of models in an API response. Extends the ResourceCollection by providing additional
information
to the API response

### ApiResource
Expand All @@ -86,8 +111,8 @@ extends JsonResource

- `whenHasAttribute()` Checks if the resource has the specified attribute.
- `noContent()` - Allows the response to be returned with an HTTP 204 (No Content) status code.
---

---

## ValueObject

Expand Down
22 changes: 22 additions & 0 deletions src/Bundles/Auth/AuthorizedRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Totem\SamSkeleton\Bundles\Auth;

use Illuminate\Contracts\Container\BindingResolutionException;

trait AuthorizedRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
try {
return $this->container?->make('auth')->check() ?? false;
} catch (BindingResolutionException) {
return false;
}
}
}
44 changes: 44 additions & 0 deletions src/Bundles/Auth/TrustOnlyAuthenticated.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace Totem\SamSkeleton\Bundles\Auth;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

class TrustOnlyAuthenticated
{
public function handle(Request $request, Closure $next): mixed
{
if ($this->getUser($request)->getAttribute('uuid') !== $this->getRoute($request)) {
$this->throwException();
}

return $next($request);
}

private function getRoute(Request $request): string
{
return tap($request->route('uuid'), function ($uuid) {
if (! $uuid) {
$this->throwException();
}
});
}

private function getUser(Request $request): mixed
{
return tap($request->user(), function ($user) {
if (! $user) {
$this->throwException();
}
});
}

private function throwException(): void
{
throw new AccessDeniedHttpException(__('The user is not allowed to modify it.'));
}
}
69 changes: 69 additions & 0 deletions tests/Auth/AuthorizedRequestTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace Totem\SamSkeleton\Tests\Auth;

use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Container\Container;
use Illuminate\Support\Facades\Route;
use Totem\SamSkeleton\Bundles\Auth\AuthorizedRequest;
use Totem\SamSkeleton\Tests\TestCase;

uses(TestCase::class);

mutates(AuthorizedRequest::class);

beforeEach(function () {
$this->user = new FixtureUser();

Route::get('/', fn (FixtureRequest $request) => response()->json(['message' => $request->method()]));
});

describe('Authorization logic', function () {
it('grants access to authorized users', function (): void {
$request = new FixtureRequest();
$request->setContainer(app());

$this->actingAs($this->user);

expect($request->authorize())->toBeTrue();
});

it('denies access to unauthorized users', function (): void {
$request = new FixtureRequest();
$request->setContainer(app());

expect($request->authorize())->toBeFalse();
});

it('denies access to unauthorized users when container is not provided', function (): void {
$request = new FixtureRequest();

expect($request->authorize())->toBeFalse();
});

it('denies access when container is empty', function (): void {
$request = new FixtureRequest();
$request->setContainer(new Container());

expect($request->authorize())->toBeFalse();
});
});

describe('Access via HTTP requests', function (): void {
it('grants access to authorized users', function (): void {
$this->actingAs($this->user);

$this->get('/')
->assertOk()
->assertJson(['message' => 'GET']);
});

it('throw an exception for unauthorized users', function (): void {
$response = $this->get('/');

expect(fn () => $response->json())
->toThrow(AuthorizationException::class, __('This action is unauthorized.'));
});
});
13 changes: 13 additions & 0 deletions tests/Auth/FixtureRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Totem\SamSkeleton\Tests\Auth;

use Illuminate\Foundation\Http\FormRequest;
use Totem\SamSkeleton\Bundles\Auth\AuthorizedRequest;

class FixtureRequest extends FormRequest
{
use AuthorizedRequest;
}
21 changes: 21 additions & 0 deletions tests/Auth/FixtureUser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Totem\SamSkeleton\Tests\Auth;

use Illuminate\Foundation\Auth\User;

class FixtureUser extends User
{
public function __construct(array $attributes = [])
{
static::unguard();

parent::__construct([...$attributes,
'password' => 'aaa',
'firstname' => 'John',
'lastname' => 'Doe',
]);
}
}
93 changes: 93 additions & 0 deletions tests/Auth/TrustOnlyAuthenticatedTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

declare(strict_types=1);

namespace Totem\SamSkeleton\Tests\Auth;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Totem\SamSkeleton\Bundles\Auth\TrustOnlyAuthenticated;
use Totem\SamSkeleton\Tests\TestCase;

uses(TestCase::class);

covers(TrustOnlyAuthenticated::class);

beforeEach(function () {
$this->uuid = fake()->uuid();

$this->user = new FixtureUser([
'email' => fake()->email(),
'password' => bcrypt(fake()->password()),
'uuid' => $this->uuid,
]);

Route::middleware(TrustOnlyAuthenticated::class)->get('user/{uuid?}', function () {
return response()->json(['message' => 'Access granted']);
});
});

describe('Middleware Logic', function (): void {
beforeEach(function () {
$this->middleware = new TrustOnlyAuthenticated();
});

it('allows access when user UUID matches the route UUID', function () {
$request = Request::create('user/' . $this->uuid);
$request->setUserResolver(fn () => $this->user);
$request->setRouteResolver(fn () => Route::getRoutes()->match($request));

$response = $this->middleware->handle($request, fn ($request) => 'next step');

expect($response)->toBe('next step');
});

it('throw an exception when user UUID does not match route UUID', function () {
$request = Request::create('user/' . fake()->uuid());
$request->setUserResolver(fn () => $this->user);
$request->setRouteResolver(fn () => Route::getRoutes()->match($request));

expect(fn () => $this->middleware->handle($request, fn () => null))
->toThrow(AccessDeniedHttpException::class, __('The user is not allowed to modify it.'));
});

it('throws an exception if the user is not logged in', function () {
$request = Request::create('user/' . fake()->uuid());
$request->setRouteResolver(fn () => Route::getRoutes()->match($request));

expect(fn () => $this->middleware->handle($request, fn () => null))
->toThrow(AccessDeniedHttpException::class, __('The user is not allowed to modify it.'));
});

it('throws an exception if the route UUID is not defined', function () {
$request = Request::create('user');
$request->setUserResolver(fn () => $this->user);
$request->setRouteResolver(fn () => Route::getRoutes()->match($request));

expect(fn () => $this->middleware->handle($request, fn () => null))
->toThrow(AccessDeniedHttpException::class, __('The user is not allowed to modify it.'));
});
});

describe('Access via HTTP requests', function (): void {
beforeEach(function () {
$this->actingAs($this->user);
});

it('grants access when user UUID matches route UUID', function (): void {
$response = $this->get('user/' . $this->uuid);

$response
->assertOk()
->assertJson(['message' => 'Access granted']);
});

it('denies access when user UUID does not match route UUID', function (): void {
$response = $this->get('user/' . fake()->uuid());

$response
->assertForbidden()
->assertSee(__('The user is not allowed to modify it.'));
});
});

0 comments on commit 806831c

Please sign in to comment.