zendframework / zend-expressive

PSR-15 middleware in minutes!
BSD 3-Clause "New" or "Revised" License
710 stars 197 forks source link

[Cookbook] When to do authorization checks? #249

Closed RalfEggert closed 8 years ago

RalfEggert commented 8 years ago

I wonder when and where is the best place to do the authorization check depending on the routing. I already have authenticated a user and know its role or the user is not logged in so he has the guest role. Depending on the routing I want to check, if the current user is allowed to access this route. If not he should be redirected to a login page (if not logged in) or to a forbidden page (if logged in).

My first idea was to add a middleware to the pre_routing middleware pipeline. But these middlewares are always executed before the routing took place. So, it is not a good idea.

Then I thought of adding my authorization middleware to each route before the right middleware for this route will be accessed. That would work, but would mean that I need to add this middleware to almost each route.

I also thought of implementing the RouteResultObserverInterface to get informed directly after the routing. But that won't help neither in the pre_routing nor in the post_routing middleware pipeline path.

Then I thought of an abstract middleware class which all my middleware classes need to extend and add the authorization check there. But that would feel bad somehow. Having similar as the controller plugins for middleware actions would be helpful.

So, how and where is the best practice to handle the authorization? Has anyone implemented it yet and can push me in the right direction?

bakura10 commented 8 years ago

Okay, so as you had answer my issue, I'm answering yours :).

We are currently doing a project that is based on Shopify e-commerce platform. The authentication is actually pretty basic, and the authorization is too (basically, the only thing we need is make sure that the authenticated shop is the right one).

What we've done is creating two pre_routing middlewares that we have piped to the path "/api", so those are executed only for the /api endpoints that require authentication.

The first one is "ApiAuthenticationMiddleware". It reads the access token in the Authorization header, find the shop (in our context, shop is equivalent to the logged user). If we find a valid shop, we store it as part of the "shopify.shop" request attribute (or "logged_user").

Then, we have a second pre_routing middleware called "ApiAuthorizationMiddleware". This middleware only check if the shopify.shop is not null. If it is, it throws a 403 exception.

Interestingly, it got a bit more complex: for some very specific API endpoints, we needed to make sure that there was public (so it MUST NOT throws a 403 exception even if no one is authenticated). In order to achieve that, I've created an "AppOptions" class with an "unauthenticatedRoutes" array, that I fill with route names that do not require authentication.

I've modified the ApiAuthorizationMiddleware to include the RouterInterface instance, and actually do a first route match on the pre_routing middleware (so yes, we actually have match method executed twice, but FastRoute is fast and I think it can caches results):

/**
     * {@inheritDoc}
     */
    public function __invoke(Request $request, Response $response, callable $out = null)
    {
        $match                 = $this->router->match($request);
        $unauthenticatedRoutes = $this->appOptions->getUnauthenticatedRoutes();

        // If the request is an API request and is not in the unauthenticated whitelist, we MUST make sure the request has the "Authorization" header of just the shop domain
        if ($this->isApiRequest($match)
            && !in_array($match->getMatchedRouteName(), $unauthenticatedRoutes, true)
            && !$request->hasHeader('Authorization')
        ) {
            throw new RuntimeException('You are not allowed to access this resource', 403);
        }
        return $out($request, $response);
    }

Interestingly, the middlewares open a lot of simplicity. In Shopify app we have actually two different authentication mechanisms: the first one is used to authenticate the initial request coming from Shopify, while all API requests need to be authenticated using our own mechanism.

By segregating by path, we were able to attach our API authentication/authorization to the "/api" path, and specific Shopify authentication to the "/shopify" path.

Of course, this may be different from your application, as in Shopify application development, there is no advanced authorization mechanism (basically, everyone installing the app have all permissions of a given app). I'd suggest that you actually use a Rbac library (ZfcRbac is not really compatible with Expressive but I definitely want to port this to middleware logic), and do that kind of checks in your actions middleware:

public function __invoke($request, $response)
{
    $user = $request->getAttribute('user');

    if (!$this->rbac->hasPermission($user, 'article.edit')) {
       throw Exception();
    }
}
RalfEggert commented 8 years ago

Thanks for your explanations. So you added two pre_routing middlewares to a single path? And all your requests go through this path? How did you do the config for that?

Well, I already have two dozens routes so far with about four different leading paths like /user, /whatever, and so on. Having to add my AuthorizationMiddleware to each route sucks and if someone forgets to add the AuthorizationMiddleware to a new route than there will be no authorization.

Running the router match() method twice sounds interesting. But looks like an overhead. I am using the zend router and have no idea for the caching of the route result.

Throwing an exception for unauthorized users is an interesting idea but the catching gets tricky. I guess the Zend\Expressive\FinalHandler will handle this but that would mean to overwrite the TemplatedErrorHandler for production and the WhoopsErrorHandler for development.

bakura10 commented 8 years ago

Exactly.

Well, I set it up this way:

'middleware_pipeline' => [
        'pre_routing' => [
            [
                'path'       => '/api',
                'middleware' => [
                    ApiAuthenticationMiddleware::class,
                    ApiAuthorizationMiddleware::class
                ]
            ]
        ]
    ]

Mmhh throwing an exception for unauthorized user sounds to me the only solution, if you have specific permission per route. How would you do it otherwise? In my case I can do it very generic way and don't care (as the only permission if allowed / not allowed), but for other use cases, having more granular permission is essential.

RalfEggert commented 8 years ago

Well, after further investigation,there will be no need to overwrite the WhoopsErrorHandler since it will display the exception as wanted. For production the TemplatedErrorHandler could be enough since it displays an error page as well. I just need to tweak the error.phtml a little to display nice forbidden message and an optional login form.

RalfEggert commented 8 years ago

Closed

RalfEggert commented 8 years ago

Nice one! Throwing an exception with exception code 403 results in a 403 HTTP status code

weierophinney commented 8 years ago

@RalfEggert there's another place you can do it now: between routing and dispatch. This allows you to have authentication and/or authorization checks only if the matched route is one that needs it, which can save you some cycles when you don't. The approach would be the same as @bakura10 suggests, with one change: you'd check the route result for the matched route name prior to deciding whether or not to check authentication or authorization.

I'd couple this with an additional piece of functionality: an error middleware that looks for a specific exception type and/or code. This allows you to then provide an alternate response in such conditions. As an example, on my site, I have an Unauthorized error middleware that checks for a specific exception type, and provides a login screen.

RalfEggert commented 8 years ago

@weierophinney

Thanks for the further comments. I already moved by Auth middleware between routing and dispatch. Adding an Unauthorized error middleware sounds great. Need to play with it.

excennfahnfah1976 commented 8 years ago

have someone an authorization , authentication sample code for an API?