Package Data | |
---|---|
Maintainer Username: | deefour |
Maintainer Contact: | jason@deefour.me (Jason Daly) |
Package Create Date: | 2014-11-13 |
Package Last Update: | 2017-02-13 |
Language: | PHP |
License: | MIT |
Last Refreshed: | 2024-12-22 03:03:36 |
Package Statistics | |
---|---|
Total Downloads: | 3,241 |
Monthly Downloads: | 2 |
Daily Downloads: | 0 |
Total Stars: | 49 |
Total Watchers: | 7 |
Total Forks: | 5 |
Total Open Issues: | 0 |
Simple Authorization via PHP Classes. Inspired by elabs/pundit.
Run the following to add Authorizer to your project's composer.json
. See Packagist for specific versions.
composer require deefour/authorizer
>=PHP5.6.0
is required.
At the core of Authorizer is the notion of policy classes. Policies accept a $user
and $record
during instantiation. Public methods (actions) contain logic to check if the $user
can perform the action on the $record
. Here is an example of a policy that authorizes users to create and edit article objects.
class ArticlePolicy
{
protected $user;
protected $record;
public function __construct($user, $record)
{
$this->user = $user;
$this->record = $record;
}
public function create()
{
return $this->user->exists;
}
public function edit()
{
return $this->record->exists && $this->record->author->is($user);
}
}
This policy allows any existing user to create a new article, and existing articles to be modified only by their author. Here are examples of how you might interact directly with this policy.
(new ArticlePolicy($user, new Article))->create(); // => true
(new ArticlePolicy($user, Article::class))->create(); // => true
(new ArticlePolicy($user, new Article))->edit(); // => false
(new ArticlePolicy($user, $user->articles->first()))->edit(); // => true
A permittedAttributes
method on a policy provides a whitelist of attributes for a request by a user when performing an action.
class ArticlePolicy
{
public function permittedAttributes()
{
$attributes = [ 'title', 'body', ];
// prevent the author and slug from being modified after the article
// has been persisted to the database.
if ( ! $this->record->exists) {
return array_merge($attributes, [ 'user_id', 'slug', ]);
}
return $attributes;
}
}
Action-specific methods can also be provided by in the format permittedAttributesFor{Action}
.
class ArticlePolicy
{
public function permittedAttributesForCreate()
{
return [ 'title', 'body', 'user_id', 'slug ];
}
public functoin permittedAttributesForEdit()
{
return [ 'title', 'body' ];
}
}
Authorizer also provides support for retrieving a resultset restricted based on a user's ability through scopes. A scope object receives a $user
and base $scope
during instantiation. It is expected to implement a resolve()
method with logic to refine the $scope
and typically return an iterable collection of objects the current user is able to access. For example
class ArticleScope
{
protected $user;
protected $scope;
public __construct($user, $scope)
{
$this->user = $user;
$this->scope = $scope;
}
public function resolve()
{
if ($this->user->isAdmin()) {
return $this->scope->all();
}
return $this->scope->where('published', true)->get();
}
}
This scope retrieves all articles if the current user is an administrator, and only published articles for other users.
$user = User::first();
$query = Article::newQuery();
(new ArticleScope($user, $query))->resolve(); //=> iterable list of Article objects
Creating and working with policy and scope classes directly is fine, but there are easier ways to authorize user activity. The first is the Deefour\Authorizer\Authorizer
class.
A policy can be instantiated and returned based on a $user
and $record
.
(new Authorizer)->policy(new User, Article::class); //=> ArticlePolicy
The policy resolution just appends 'Policy'
to the end of the $record
's class name by default. This can be customized by provided a static policyClass
method on the $record
class. For example, if the policy for Article
is at Policies\ArticlePolicy
, create a method like this:
class Article
{
static public function policyClass()
{
return \Policies\ArticlePolicy::class;
}
}
It's recommended that your
$record
objects extend a single class that implements apolicyClass
method that will work for most/all of your record classes instead of manually specifying FQN's on every record.
A scope can be instantiated and returned based on a $user
and base $scope
. Instead of returning a scope class, Authorizer
will call resolve()
on the scope class for you, returning the resultset.
(new Authorizer)->scope(new User, new Article); //=> a scoped resultset
Similar to policy resolution, the scope resolution just appends 'Scope'
to the end of the $scope
object by default. This can be customized by provided a static scopeClass
method on the $record
class.
class Article
{
static public function scopeClass()
{
return \Policies\ArticleScope::class;
}
}
It's important to note that many times you will pass a partially built query object to the scope()
method as the $record
instead of an instance of a record that actually resolves to a scope class. For example, a more realistic example of the one above might look like this:
(new Authorizer)->scope(new User, Article::where('promoted', true)); //=> ArticleScope
The second argument above will return an instance of Illuminate\Database\Eloquent\Builder
instead of an instance of Article
. Scope resolution will fail without a bit more help. The resolver must be told how to determine the actual record to resolve the scope from. This is done through a closure passed as an optional third argument which will be passed the $scope
the authorizer receives.
(new Authorizer)->scope(
new User,
Article::where('promoted', true),
function ($scope) {
return $scope->getModel();
}
); //=> a scoped resultset
If a policy or scope cannot be found, null
will be returned. If you need to stop execution, call policyOrFail()
or scopeOrFail()
instead of simply policy()
or scope()
.
(new Authorizer)->policyOrFail(new User, new Blog); //=> throws Deefour\Authorizer\Exception\NotDefinedException
The authorizer also provides an authorize
method that receives a $user
, $record
, and $action
. An exception will be thrown if anything but true
is returned from the resolved policy's action method.
(new Authorizer)->policyOrFail(new User, new Article, 'edit'); //=> throws Deefour\Authorizer\Exception\NotAuthorizedException
Authorizer considers any value other than true
returned from a policy action a failure. If a string is returned it will be passed through as the message on the thrown NotAuthorizedException
. This message can be used to inform a user exactly why their attempt to perform action was denied.
class ArticlePolicy
{
public function edit()
{
if ($this->record->user->is($this->user)) {
return true;
}
return 'You are not the owner of this article.';
}
}
try {
(new Authorizer)->authorize(new User, new Article, 'edit');
} catch (NotAuthorizedException $e) {
echo $e->getMessage(); //=> 'You are not the owner of this article.'
}
Authorizer can fetch a whitelist of attribute names permitted for mass assignment for a particular action.
(new Authorizer)->permittedAttributes(new User, new Article); //=> ArticlePolicy::permittedAttributes()
(new Authorizer)->permittedAttributes(new User, new Article, 'store'); //=> ArticlePolicy::permittedAttributesForStore()
Many apps only allow users to perform actions while authenticated. Instead of verifying on every policy action that the current user is logged in, you can create a base policy all others extend.
abstract class Policy
{
public function __construct($user, $record)
{
if (is_null($user) or ! $user->exists) {
throw new NotAuthorizedException($record, $this, 'initalization', 'You must be logged in!');
}
parent::__construct($user, $record);
}
}
In addition to the Authorizer
class, a Deefour\Authorizer\ProvidesAuthorization
trait is also provided to make authorizing user activity easier.
This trait can be used in any class provided it overrides the following three protected
methods on the implementing class:
authorizerUser()
This should return the user object to authorize. It can be useful to return a new/fresh/empty user object if no logged in user is present.
authorizerAction()
This should return the name of the action on the policy to be called. Often this is based on the controller method handling the current request.
authorizerAttributes()
This should return an array of input data for the request. This only needs to be overridden if you are taking advantage of the mass assignment protection.
With this trait included, a policy can be retrieved from within the controller. The $user
needed for the policy instantiation is derived from the authorizerUser()
method override.
$this->policy(new Article); //=> ArticlePolicy
Scoping can be done with similar simplicity. Similar to the Authorizer
class, this will call resolve()
on the scope for you, returning the resultset. A closure is provided below returning the $record
which the scope class should be resolved from based on the passed base $scope
.
$this->scope(
Article::newQuery(),
function($scope) {
return $scope->getModel();
}
); //=> a scoped resultset
Like policy resolution, the $user
needed for the policy instantiation is derived from the authorizerUser()
method override.
A failing authorization check will throw an instance of Deefour\Authorizer\Exception\NotAuthorizedException
. This can short-circuit method execution with a single line of code.
public function edit(Article $article)
{
$this->authorize($article); //=> NotAuthorizedException will be thrown on failure
echo "You can edit this article!"
}
Similar to policies, the $user
and $action
needed for the scope instantiation are derived from the authorizerUser()
and authorizerAction()
method overrides. An action can be passed as a second argument to call a specific method on the policy instead of the one authorizerAction()
will return.
$this->authorize($article, 'modify');
Model attributes can be safely mass assigned too. Calling permittedAttributes()
will pull a whitelist of attributes from the request info returned from the authorizerAttributes()
method. A policy is instantiated for the $record
behind the scenes, again with the $user
and $action
needed being derived from the authorizerUser()
and authorizerAction()
method overrides.
public function update(Article $article)
{
$article->forceFill($this->permittedAttributes(new Article))->save();
}
A second argument can be provided to permittedAttributes()
to call a specific variant of the method on the policy if available.
Integrating this library into a Laravel application is very straightforward.
Within a Laravel application, an implementation satisfying the above overrides might look like this:
use App\User;
use Auth;
use Deefour\Authorizer\ProvidesAuthorization;
use Illuminate\Routing\Controller as BaseController;
use Request;
use Route;
class Controller extends BaseController
{
use ProvidesAuthorization;
protected function authorizerAction()
{
$action = Route::getCurrentRoute()->getActionName();
return substr($action, strpos($action, '@') + 1);
}
protected function authorizerUser()
{
return Auth::user() ?: new User;
}
protected function authorizerAttributes()
{
return Request::all();
}
}
When a call to authorize()
fails, a Deefour\Authorizer\NotAuthorizedException
exception is thrown. Your Laravel app's App\Exceptions\Handler
could be modified to support this exception.
Add Deefour\Authorizer\Exception\NotAuthorizedException:class
to the $dontReport
list.
Import Deefour\Authorizer\Exception\NotAuthorizedException
at the top of the file.
Make your prepareException()
method look like this:
protected function prepareException(Exception $e) { if ($e instanceof NotAuthorizedException) { return new HttpException(403, $e->getMessage()); }
return parent::prepareException($e);
}
```
An middleware can be provided on a controller's constructor as a closure to prevent actions missing authorization checks from being wide open by default.
public function __construct()
{
$this->middleware(function ($request, $next) {
$response = $next($request);
$this->verifyAuthorized();
return $response;
});
}
This will throw a Deefour\Authorizer\Exceptions\AuthorizationNotPerformedException
exception if the controller action is run without a call to authorize()
.
There is a verifyScoped
method to ensure a scope is used that will throw a Deefour\Authorizer\Exceptions\ScopingNotPerformedException
if the controller action is run without a call to scope()
.
On occasion, bypassing this blanket authorization or scoping requirement may be necessary. Exceptions will not be thrown if skipAuthorization()
or skipScoping()
are called before the verification occurs.
Laravel's Illuminate\Foundation\Http\FormRequest
class has an authorize()
method. Integrating policies into form request objects is easy. An added benefit is the validation rules can be based on authorization too:
namespace App\Http\Requests;
use Deefour\Authorizer\ProvidesAuthorization;
use Illuminate\Foundation\Http\FormRequest;
class CreateArticleRequest extends FormRequest
{
use ProvidesAuthorization;
public function authorize()
{
return $this->authorize(new Article);
}
public function rules()
{
$rules = [
'title' => 'required'
];
if ( ! $this->policy->createWithoutApproval()) {
$rules['approval_from'] => 'required';
}
return $rules;
}
protected authorizerUser()
{
return $this->user();
}
protected authorizerAttributes()
{
return $this->all();
}
protected authorizerAction()
{
return $this->has('id') ? 'create' : 'edit';
}
}
modelName()
method checked on a class to resolve a different policies and scopes against a different model has been changed to modelClass()
.permittedAttributes()
available in the Authorizer
class.Authorizer
now does a strict type check. A NotAuthorizedException
unless true
is returned. Other 'truthy' values will fail authorization.skipAuthorization()
and skipScoping()
methods have been added.
to bypass the exception throwing of the verification API.deefour/producer
.policyNamespace()
, policyClass()
, scopeNamespace()
and scopeClass()
methods have all been removed in favor of a single resolve()
method now, used by the deefour/producer
resolver.Authorizee
be passed to the constructor.403
instead of 401
when unauthorized.currentUser()
to user()
for simplicity and compatibility with Laravel.ResolvesAuthorizable
interface. This can be used on a class such as the decorators in deefour/presenter
to map an authorization attempt back to the underlying model, since the presenter itself is not implementing the Authorizable
interface.symfony/http-kernel
to throw a full HTTP exception when authorization fails.helpers.php
from Composer autoload. Developers should be able to choose whether these functions are included.Authorizee
contract to be attached to a User
model for easy lookup through service containers.Copyright (c) 2016 Jason Daly (deefour). Released under the MIT License. 0Looking