Check Authorization With Policies Before Delete Function

Use Policy For Authorization

In this tutorial we want to set up the ability for authorized users to delete any reply that they have created. In addition, guests or unauthorized users should not be able to delete any replies. So as usual, we’ll set up a couple of tests to support these new features. In addition we’ll leverage a new policy object to determine whether any particular user is authorized to delete a given reply. Lastly, we’ll update the view side to display the option of deleting a reply by authorized users.


Unauthorized Users Test

First up, let’s open the ParticipateInForumTest.php test class we have and add a new test to it. We can name it test_unauthorized_users_can_not_delete_replies(). The logic is pretty simple. Given there is a Reply, and a guest tries to delete that reply, then the user should be redirected to the login page. Here is a first shot at that test.


public function test_unauthorized_users_can_not_delete_replies()
{
    $this->withoutExceptionHandling();
    
    $reply = create('AppReply');

    $this->delete('/replies/' . $reply->id)
        ->assertRedirect('/login');
}

Running the test gives us a NotFoundHttpException which is usually a missing route.

vagrant@homestead:~/Code/forumio$ phpunit --filter test_unauthorized_users_can_not_delete_replies
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 849 ms, Memory: 8.00MB

There was 1 error:

1) TestsFeatureParticipateInForumTest::test_unauthorized_users_can_not_delete_replies
SymfonyComponentHttpKernelExceptionNotFoundHttpException: DELETE http://localhost/replies/1

Add The Route

We register a delete request to /replies/{reply} which will trigger the destroy() method on the RepliesController.


<?php

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

Auth::routes();

Route::get('/home', 'HomeController@index')->name('home');

Route::get('/threads', 'ThreadsController@index');
Route::get('/threads/create', 'ThreadsController@create');
Route::get('/threads/{channel}/{thread}', 'ThreadsController@show');
Route::delete('/threads/{channel}/{thread}', 'ThreadsController@destroy');
Route::post('/threads', 'ThreadsController@store');
Route::get('/threads/{channel}', 'ThreadsController@index');
Route::post('/threads/{channel}/{thread}/replies', 'RepliesController@store');
Route::delete('/replies/{reply}', 'RepliesController@destroy');
Route::post('/replies/{reply}/favorites', 'FavoritesController@store');
Route::get('/profiles/{user}', 'ProfilesController@show')->name('profile');

Modifying The Test

Let’s assume a user is actually signed in. In this case, the test might look like this.


public function test_unauthorized_users_can_not_delete_replies()
{
    $this->withoutExceptionHandling();

    $reply = create('AppReply');

    $this->signIn()
        ->delete('/replies/' . $reply->id)
        ->assertStatus(403);
}

If we run the test, now we are getting a BadMethodCallException. This is because we still need to create the destroy() method on the RepliesController.

vagrant@homestead:~/Code/forumio$ phpunit --filter test_unauthorized_users_can_not_delete_replies
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 821 ms, Memory: 8.00MB

There was 1 error:

1) TestsFeatureParticipateInForumTest::test_unauthorized_users_can_not_delete_replies
BadMethodCallException: Method [destroy] does not exist on [AppHttpControllersRepliesController].

Adding The destroy() method

Open up RepliesController.php, and we can add the code to complete our goal.

<?php

namespace AppHttpControllers;

use AppReply;
use AppThread;
use IlluminateHttpRequest;

class RepliesController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth');
    }

    public function store($channelId, Thread $thread)
    {
        $this->validate(request(), [
            'body' => 'required'
        ]);

        $thread->addReply([
            'body' => request('body'),
            'user_id' => auth()->id()
        ]);

        return back();
    }

    public function destroy(Reply $reply)
    {
        $reply->delete();

        return back();
    }
}

Now we can run the test once more.

vagrant@homestead:~/Code/forumio$ phpunit --filter test_unauthorized_users_can_not_delete_replies
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

F                                                                   1 / 1 (100%)

Time: 920 ms, Memory: 10.00MB

There was 1 failure:

1) TestsFeatureParticipateInForumTest::test_unauthorized_users_can_not_delete_replies
Expected status code 403 but received 302.
Failed asserting that false is true.

/home/vagrant/Code/forumio/vendor/laravel/framework/src/Illuminate/Foundation/Testing/TestResponse.php:78
/home/vagrant/Code/forumio/tests/Feature/ParticipateInForumTest.php:53

FAILURES!
Tests: 1, Assertions: 1, Failures: 1.

What is happening now is during the test, the reply is getting deleted but we have not applied any authorization. As it stands now in the test, the user is signed in then tries to delete a reply that he or she did not create. The response should be a forbidden status of 403. We can enable this using a new policy object. We’ll do this in a moment, but first let’s add another test.


Authorized Users Test

The test for authorized users will look like this.

public function test_authorized_users_can_delete_replies()
{
    $this->signIn();

    $reply = create('AppReply', ['user_id' => auth()->id()]);

    $this->delete('/replies/' . $reply->id)->assertStatus(302);

    $this->assertDatabaseMissing('replies', ['id' => $reply->id]);
}

Setting Up A New Policy

Let’s add that new policy object to determine if a user can delete a reply or not. We’ll start by scaffolding it out like so.

vagrant@homestead:~/Code/forumio$ php artisan make:policy ReplyPolicy
Policy created successfully.

Now we simply add the update method where we specify that in order for a user to be able to delete a reply, their user id must match the user id found on the reply in question. Of course this means that the thread belongs to them, so they can delete it.


<?php

namespace AppPolicies;

use AppReply;
use AppUser;
use IlluminateAuthAccessHandlesAuthorization;

class ReplyPolicy
{
    use HandlesAuthorization;

    public function update(User $user, Reply $reply)
    {
        return $reply->user_id = $user->id;
    }
}

Once complete, just make sure to register the new policy within AuthServiceProvider. The highlighted line shows our addition for the registration of the Reply policy we just created. You may recall when we had created a Thread policy in an earlier tutorial as well, the registration for that policy is just above the highlighted line.


<?php

namespace AppProviders;

use function foofunc;
use IlluminateSupportFacadesGate;
use IlluminateFoundationSupportProvidersAuthServiceProvider as ServiceProvider;

class AuthServiceProvider extends ServiceProvider
{
    /**
     * The policy mappings for the application.
     *
     * @var array
     */
    protected $policies = [
        //'AppModel' => 'AppPoliciesModelPolicy',
        'AppThread' => 'AppPoliciesThreadPolicy',
        'AppReply' => 'AppPoliciesReplyPolicy',
    ];

    /**
     * Register any authentication / authorization services.
     *
     * @return void
     */
    public function boot()
    {
        $this->registerPolicies();

        Gate::before(function ($user) {
            if ($user->name === 'Nikola Tesla') {
                return true;
            }
        });
    }
}

Now you are free to authorize an update on the reply in the destroy() method of the RepliesController as we do here.


public function destroy(Reply $reply)
{
    $this->authorize('update', $reply);

    $reply->delete();

    return back();
}

Our final tests for these functions are as follows:

public function test_unauthorized_users_can_not_delete_replies()
{
    $reply = create('AppReply');

    $this->delete('/replies/' . $reply->id)
        ->assertRedirect('/login');

    $this->signIn()
        ->delete('/replies/' . $reply->id)
        ->assertStatus(403);
}

public function test_authorized_users_can_delete_replies()
{
    $this->signIn();

    $reply = create('AppReply', ['user_id' => auth()->id()]);

    $this->delete('/replies/' . $reply->id)->assertStatus(302);

    $this->assertDatabaseMissing('replies', ['id' => $reply->id]);
}

Running the full suite of this test class does confirm it is all working.

vagrant@homestead:~/Code/forumio$ phpunit --filter ParticipateInForumTest
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

.....                                                               5 / 5 (100%)

Time: 1.88 seconds, Memory: 14.00MB

OK (5 tests, 10 assertions)

Adding The Delete Button In The Browser

Ok, it looks like all of the functionality is working just fine for both authorized and unauthorized users with regard to deleting replies. We can update the reply.blade.php view to allow that. Highlighted below is the markup to provide the delete button. In addition, since we used policies, we can make use of that useful @can directive to only show the button to a user that is authorized to delete a specific reply. So guests will never see a delete button, but authorized users will see a delete button for their own replies.


<div id="reply-{{ $reply->id }}" class="panel panel-default">

    <div class="panel-body">
        <div class="level">
            <h5 class="flex">
                <a href="{{ route('profile', $reply->owner) }}">
                    {{$reply->owner->name}}
                </a> said {{ $reply->created_at->diffForHumans() }}
            </h5>

            <div>
                <form method="POST" action="/replies/{{$reply->id}}/favorites">
                    {{csrf_field()}}
                    <button type="submit" class="btn btn-primary {{ $reply->isFavorited() ? 'disabled' : '' }}">
                        {{ $reply->favorites_count }} {{ str_plural('Favorite', $reply->favorites_count) }}
                    </button>
                </form>
            </div>
        </div>
    </div>

    <div class="panel-body">
        {{ $reply->body }}
    </div>

    @can('update', $reply)
        <div class="panel-footer">
            <form method="POST" action="/replies/{{$reply->id}}">
                {{ csrf_field() }}
                {{ method_field('DELETE') }}
                <button class="btn btn-danger btn-xs">Delete Reply</button>
            </form>
        </div>
    @endcan
</div>

The logged in user now sees a delete button for is reply.
laravel can directive example

After clicking on the delete button, we can see the reply is gone. In addition, notice that there are no replies by this user. Therefore, we no longer even see the delete button at all on any remaining replies to the thread.
no delete button seen


Check Authorization With Policies Before Delete Function Summary

So a fairly straight forward example of using a policy object was given here to make sure a user is authorized to delete a reply to a thread. We had already configured a policy object during the tutorial where we set up the ability to delete threads, so this was a bit of review – but still helpful to reinforce our learning.