loading...

Only allow owners to update their user profile in Laravel with a policy

martin_betz profile image Martin Betz Originally published at martinbetz.eu ・4 min read

The problem: Only owners should be allowed to edit their profiles

  • You have a website that lists the profile of Laravel developers
  • The profile is just an extension of the User model, so you have extra fields
  • For sake of simplicity, you have only one field in your profile that is the numbers of years you've been coding with Laravel
  • Developers listed should only be able to edit their own profile and not foreign profiles

The solution: Use a policy for the User model

  • We will write a policy that will be triggered when a user updates a profile
  • The policy checks whether you are the owner of the user profile
  • If you are, you can update the profile
  • If you are not the owner, your request will be denied

The step by step explanation

  • I will show you how to add this policy test-driven
  • I assume that you already created a fresh Laravel app and set up your database

Step 1: Write a failing test user_can_update_own_profile

  • Create a test for the User model: php artisan make:test Http/Controllers/UserControllerTest
  • We will write a test with how we wish our app should work if we are the owner of a profile
  • This is called the Happy Path
// tests/Feature/Http/Controllers/UserControllerTest.php

<?php

namespace Tests\Feature\Http\Controllers;

use App\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;

class UserControllerTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function user_can_update_own_profile()
    {
        $user = factory(User::class)->create();
        $response = $this->actingAs($user)->post(route('user.profile.update', $user), [
            'experience_years' => 5,
        ]);

        $response->assertSuccessful();
        $user->refresh();
        $this->assertEquals(5, $user->experience_years);
    }
}
  • This test will fail for many reasons
    • We do not have a route named user.profile.update that we can send a POST request to
    • We do not have a experience_years property on the User model
    • We do not have a User controller that actually updates the model
  • In a normal test-driven process, I would solve this error by error
  • As this tutorial is about policies, I will just solve all problems at once to get to the policy part

Step 2: Fix the test to assert that you can update your own profile

  • The solution: Update your migration, User model, web.php routes file and create a UserController with an update method that updates with everything passed with a request
  • I marked all new files with a // ADD: comment
  • I deleted all comments and unneccessary lines
// Migration: database/migrations/2014_10_12_000000_create_users_table.php
// Your date in the filename should differ

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateUsersTable extends Migration
{
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->integer('experience_years')->nullable(); // ADD: nullable integer
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->rememberToken();
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::dropIfExists('users');
    }
}

// User Model: app/User.php

<?php

namespace App;

use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    use Notifiable;

    protected $fillable = [
        'name', 'email', 'password', 'experience_years' // ADD: 'experience_years'
    ];

    protected $hidden = [
        'password', 'remember_token',
    ];

    protected $casts = [
        'email_verified_at' => 'datetime',
    ];
}

// Routes: routes/web.php

<?php

use Illuminate\Support\Facades\Route;

Route::post('/{user}/profile/update', 'UserController@update')->name('user.profile.update');

// Controller: app/Http/Controllers/UserController.php

<?php

namespace App\Http\Controllers;

use App\User;
use Illuminate\Http\Request;

class UserController extends Controller
{
    public function update(Request $request, User $user)
    {
        $user->update($request->all());
    }
}
  • You can now run the tests with php artisan test.
  • You should see two passing example tests and a passing test: ✓ user can update own profile
  • Let's try to update someone else's years of experience:

Step 3: Try to update someone else's profile – and fail

/** @test */
    public function user_cannot_update_foreign_profile()
    {
        $user = factory(User::class)->create();
        $foreign_user = factory(User::class)->create([
            'experience_years' => 2,
        ]);
        $response = $this->actingAs($user)->post(route('user.profile.update', $foreign_user), [
            'experience_years' => 5,
        ]);

        $response->assertForbidden();
        $foreign_user->refresh();
        $this->assertEquals(2, $foreign_user->experience_years);
    }
  • This test will fail Asserting that 5 is 2
  • This means: Yes, we can change someone else's profile
  • Not good!

Step 4: Add a policy to disallow changing someone else's profile

  • Solution: Add a policy to stopp this
  • Create the policy: php artisan make:policy UserPolicy
// Policy: app/Policies/UserPolicy.php

<?php

namespace App\Policies;

use App\User;
use Illuminate\Auth\Access\Response;
use Illuminate\Auth\Access\HandlesAuthorization;

class UserPolicy
{
    use HandlesAuthorization;

    public function update(User $user, User $user_model)
    {
        return $user->id === $user_model->id ? Response::allow() : Response::deny();
    }
}

  • Policies always have the current user passed via User $user
  • The second argument is the model that we want to protect
  • Because we want to protect the user model, we need to pass it twice with differing names
  • Interpretation of the command: If the ID of the currently logged in user is the same as the id of the user model that is being tried to update, allow the update. If not, reject the update
  • Because we named the policy like the model, Laravel automatically finds it
  • We only need to apply the policy on the route
// Routes with policy: routes/web.php

<?php

use Illuminate\Support\Facades\Route;

Route::post('/{user}/profile/update', 'UserController@update')->name('user.profile.update')->middleware('can:update,user');

The ->middleware('can:update,user) means: Authorize the update() action and pass the user URL parameter to the policy (that's our $user_model in the policy).

Repo and extension

  • If you have problems following the code, check out the repo on Github.
  • If you want to extend this functionality, try to add an exception for admins: They should be able to change every profile

Discussion

pic
Editor guide
Collapse
martin_betz profile image
Martin Betz Author

Thanks to Reddit user reddit.com/user/Re-Infected/, here's an simpler update() method:

public function update(User $user, User $user_model)
{
  return $user->is($user_model) ?: Response::deny();
}

In plain English: If the logged in user is equal to the user passed then return true (= pass this request), if not deny the update process.