deefour / authorizer
Simple Authorization via PHP Classes
Installs: 3 239
Dependents: 0
Suggesters: 0
Security: 0
Stars: 49
Watchers: 7
Forks: 5
Open Issues: 0
Requires
- php: >=5.6.0
- deefour/transformer: ^1.2
Requires (Dev)
- friendsofphp/php-cs-fixer: 2.1.0
- phpspec/phpspec: ^3.1
README
Simple Authorization via PHP Classes. Inspired by elabs/pundit.
Getting Started
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.
Policies
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
Mass Assignment Protection
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' ]; } }
Scopes
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
The Authorizer Object
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.
Resolving Policies
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.
Resloving Scopes
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
Strict Resolution
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
Authorization
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
Failure Reasons
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.' }
Permitted Attributes
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()
Closed System
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); } }
Making Classes Aware of Authorization
In addition to the Authorizer
class, a Deefour\Authorizer\ProvidesAuthorization
trait is also provided to make authorizing user activity easier.
Preparing for Authorization
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.
Usage
Retrieving Policies
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
Retrieving Scopes
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.
Authorization Checks
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');
Mass Assignment
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.
Authorization Within Laravel
Integrating this library into a Laravel application is very straightforward.
Implementing the Trait Method Overrides
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(); } }
Gracefully Handling Unauthorized Exceptions
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);
}
```
Ensuring Policies Are Used
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.
Helping Form Requests
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'; } }
Contribute
- Issue Tracker: https://github.com/deefour/authorizer/issues
- Source Code: https://github.com/deefour/authorizer
Changelog
2.2.0 - February 12, 2017
- The
modelName()
method checked on a class to resolve a different policies and scopes against a different model has been changed tomodelClass()
.
2.1.1 - February 8, 2017
- Bugfixes for scope resolution, thanks to @gmedeiros.
2.1.0 - September 14, 2016
- Made
permittedAttributes()
available in theAuthorizer
class. - Docblocks throughout.
2.0.0 - September 13, 2016
- Complete rewrite.
- Much of the API is the same, but many interfaces and base classes have been removed for simplicity.
- Laravel-specific global functions, facade, and service provider have been removed.
- Class resolution has been simplified (no more dependence on deefour/producer).
1.1.0 - January 14, 2016
- The
Authorizer
now does a strict type check. ANotAuthorizedException
unlesstrue
is returned. Other 'truthy' values will fail authorization. - A string returned from a policy will now be set as the 'reason' for the authorization failure.
1.0.0 - October 7, 2015
- Release 1.0.0.
- New
skipAuthorization()
andskipScoping()
methods have been added. to bypass the exception throwing of the verification API.
0.6.0 - August 8, 2015
- Large rewrite of the policy and scope resolver, now using
deefour/producer
. - The
policyNamespace()
,policyClass()
,scopeNamespace()
andscopeClass()
methods have all been removed in favor of a singleresolve()
method now, used by thedeefour/producer
resolver. - Policies now require an
Authorizee
be passed to the constructor.
0.5.2 - July 31, 2015
- Throw
403
instead of401
when unauthorized.
0.5.1 - June 5, 2015
- Now following PSR-2.
0.5.0 - June 2, 2015
- All static methods are now public instance methods.
- Changed
currentUser()
touser()
for simplicity and compatibility with Laravel. - Code cleaning.
0.4.0 - March 25, 2015
- New
ResolvesAuthorizable
interface. This can be used on a class such as the decorators indeefour/presenter
to map an authorization attempt back to the underlying model, since the presenter itself is not implementing theAuthorizable
interface. - Now requires
symfony/http-kernel
to throw a full HTTP exception when authorization fails. - Code formatting improved.
0.3.0 - March 19, 2015
- Adding much improved support for policy scopes.
- Remove
helpers.php
from Composer autoload. Developers should be able to choose whether these functions are included. - Cleaned up docblocks.
0.2.0 - February 4, 2015
- Adding
Authorizee
contract to be attached to aUser
model for easy lookup through service containers. - Class Reorganization.
- Fixes for the Laravel service provider.
0.1.0 - November 13, 2014
- Initial release independent of deefour/Aide.
License
Copyright (c) 2016 Jason Daly (deefour). Released under the MIT License. 0Looking