Completed
Push — 4-cactus ( 9ca9b2...23b6ed )
by Alberto
21s queued 10s
created

plugins/BEdita/API/src/Auth/EndpointAuthorize.php (6 issues)

1
<?php
2
/**
3
 * BEdita, API-first content management framework
4
 * Copyright 2016 ChannelWeb Srl, Chialab Srl
5
 *
6
 * This file is part of BEdita: you can redistribute it and/or modify
7
 * it under the terms of the GNU Lesser General Public License as published
8
 * by the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 *
11
 * See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details.
12
 */
13
14
namespace BEdita\API\Auth;
15
16
use BEdita\Core\Model\Entity\EndpointPermission;
17
use BEdita\Core\Model\Table\RolesTable;
18
use BEdita\Core\State\CurrentApplication;
19
use Cake\Auth\BaseAuthorize;
20
use Cake\Http\ServerRequest;
21
use Cake\Network\Exception\NotFoundException;
22
use Cake\ORM\TableRegistry;
23
use Cake\Utility\Hash;
24
25
/**
26
 * Provide authorization on a per-endpoint basis.
27
 *
28
 * @since 4.0.0
29
 */
30
class EndpointAuthorize extends BaseAuthorize
31
{
32
33
    /**
34
     * {@inheritDoc}
35
     *
36
     * If 'blockAnonymousUsers' is true no access will be granted
37
     * to unauthenticated users otherwise authorization check is performed
38
     * If 'defaultAuthorized' is set current request is authorized
39
     * unless a specific permission is set.
40
     */
41
    protected $_defaultConfig = [
42
        'blockAnonymousUsers' => true,
43
        'defaultAuthorized' => false,
44
    ];
45
46
    /**
47
     * Current endpoint entity.
48
     *
49
     * @var \BEdita\Core\Model\Entity\Endpoint|null
50
     */
51
    protected $endpoint = null;
52
53
    /**
54
     * Request object instance.
55
     *
56
     * @var \Cake\Http\ServerRequest
57
     */
58
    protected $request;
59
60
    /**
61
     * Cache result of `authorized()` method call.
62
     *
63
     * This is required for controller to know whether authorization was granted on all contents,
64
     * or only on those that belong to the current user. Whatever that means, it is controller's
65
     * responsibility to interpret, as it may vary. Some controller may also decide to ignore this
66
     * fine-grained authorization level.
67
     *
68
     * @var bool|string
69
     */
70
    protected $authorized;
71
72
    /**
73
     * {@inheritDoc}
74
     */
75
    public function authorize($user, ServerRequest $request)
76
    {
77
        $this->request = $request;
78
79
        // if 'blockAnonymousUsers' configuration is true and user unlogged authorization is denied
80
        if (!$this->getConfig('defaultAuthorized') &&
81
            $this->isAnonymous($user) &&
82
            $this->getConfig('blockAnonymousUsers')) {
83
            $this->unauthenticated();
84
        }
85
86
        // For anonymous users performing write operations, use strict mode.
87
        $strict = ($this->isAnonymous($user) && !$this->request->is(['get', 'head']));
88
89
        if (empty($this->endpoint)) {
90
            $this->getEndpoint();
91
        }
92
93
        $permissions = $this->getPermissions($user, $strict)->toArray();
94
        $allPermissions = $this->getPermissions(false);
95
96
        // If request si authorized and no permission is set on it then it is authorized for anyone
97
        if ($this->getConfig('defaultAuthorized') && ($this->endpoint->isNew() || $allPermissions->count() === 0)) {
98
            return $this->authorized = true;
99
        }
100
101
        $this->authorized = $this->checkPermissions($permissions);
102
        if (empty($permissions) && ($this->endpoint->isNew() || $allPermissions->count() === 0)) {
103
            // If no permissions are set for an endpoint, assume the least restrictive permissions possible.
104
            // This does not apply to write operations for anonymous users: those **MUST** be explicitly allowed.
105
            $this->authorized = !$strict;
106
        }
107
108
        // if 'administratorOnly' configuration is true logged user must have administrator role
109
        if ($this->authorized && $this->getConfig('administratorOnly')) {
110
            $this->authorized = in_array(RolesTable::ADMIN_ROLE, Hash::extract($user, 'roles.{n}.id'));
0 ignored issues
show
It seems like Cake\Utility\Hash::extract($user, 'roles.{n}.id') can also be of type ArrayAccess; however, parameter $haystack of in_array() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

110
            $this->authorized = in_array(RolesTable::ADMIN_ROLE, /** @scrutinizer ignore-type */ Hash::extract($user, 'roles.{n}.id'));
Loading history...
111
        }
112
113
        if ($this->isAnonymous($user) && $this->authorized !== true) {
114
            // Anonymous user should not get a 403. Thus, we invoke authentication provider's
115
            // `unauthenticated()` method. Furthermore, for anonymous users, `mine` doesn't make any sense,
116
            // so we treat that as a non-authorized request.
117
            $this->unauthenticated();
118
        }
119
120
        // Authorization is granted for both `true` and `'mine'` values.
121
        return !empty($this->authorized);
122
    }
123
124
    /**
125
     * Perform user unauthentication to return 401 Unauthorized
126
     * instead of 403 Forbidden
127
     *
128
     * @return void
129
     */
130
    protected function unauthenticated()
131
    {
132
        $controller = $this->_registry->getController();
133
        $controller
134
            ->Auth->getAuthenticate('BEdita/API.Jwt')
135
            ->unauthenticated($controller->request, $controller->response);
0 ignored issues
show
It seems like $controller->response can also be of type null; however, parameter $response of Cake\Auth\BaseAuthenticate::unauthenticated() does only seem to accept Cake\Http\Response, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

135
            ->unauthenticated($controller->request, /** @scrutinizer ignore-type */ $controller->response);
Loading history...
It seems like $controller->request can also be of type null; however, parameter $request of Cake\Auth\BaseAuthenticate::unauthenticated() does only seem to accept Cake\Http\ServerRequest, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

135
            ->unauthenticated(/** @scrutinizer ignore-type */ $controller->request, $controller->response);
Loading history...
136
    }
137
138
    /**
139
     * Check if user is anonymous.
140
     *
141
     * @param array|\ArrayAccess $user User data.
142
     * @return bool
143
     */
144
    public function isAnonymous($user)
145
    {
146
        return !empty($user['_anonymous']);
147
    }
148
149
    /**
150
     * Get endpoint for request.
151
     *
152
     * @return \BEdita\Core\Model\Entity\Endpoint
153
     * @throws \Cake\Network\Exception\NotFoundException If endpoint is disabled
154
     */
155
    protected function getEndpoint()
156
    {
157
        $endpointName = $this->request->url;
158
        if (($slashPos = strpos($endpointName, '/')) !== false) {
159
            $endpointName = substr($endpointName, 0, $slashPos);
160
        }
161
162
        $Endpoints = TableRegistry::get('Endpoints');
163
        $this->endpoint = $Endpoints->find()
0 ignored issues
show
Documentation Bug introduced by
It seems like $Endpoints->find()->wher...endpointName))->first() can also be of type array. However, the property $endpoint is declared as type BEdita\Core\Model\Entity\Endpoint|null. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
164
            ->where([
165
                'Endpoints.name' => $endpointName,
166
            ])
167
            ->first();
168
169
        if (!$this->endpoint) {
170
            $this->endpoint = $Endpoints->newEntity(
171
                [
172
                    'name' => $endpointName,
173
                    'enabled' => true,
174
                ],
175
                ['validate' => false]
176
            );
177
        }
178
179
        if (!$this->endpoint->enabled) {
0 ignored issues
show
Accessing enabled on the interface Cake\Datasource\EntityInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
180
            throw new NotFoundException(__d('bedita', 'Resource not found.'));
181
        }
182
183
        return $this->endpoint;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->endpoint returns the type array which is incompatible with the documented return type BEdita\Core\Model\Entity\Endpoint.
Loading history...
184
    }
185
186
    /**
187
     * Get list of applicable permissions.
188
     *
189
     * @param array|\ArrayAccess|false $user Authenticated (or anonymous) user.
190
     * @param bool $strict Use strict mode. Do not consider permissions set on all applications/endpoints.
191
     * @return \Cake\ORM\Query
192
     * @todo Future optimization: Permissions that are `0` on the two bits that are interesting for the current request can be excluded...
193
     */
194
    protected function getPermissions($user, $strict = false)
195
    {
196
        $applicationId = CurrentApplication::getApplicationId();
197
        $endpointIds = $this->endpoint && !$this->endpoint->isNew() ? [$this->endpoint->id] : [];
198
199
        $query = TableRegistry::get('EndpointPermissions')
200
            ->find('byApplication', compact('applicationId', 'strict'))
201
            ->find('byEndpoint', compact('endpointIds', 'strict'));
202
203
        if ($user !== false && !$this->isAnonymous($user)) {
204
            $roleIds = Hash::extract($user, 'roles.{n}.id');
205
            $query = $query
206
                ->find('byRole', compact('roleIds'));
207
        }
208
209
        return $query;
210
    }
211
212
    /**
213
     * Checks if request can be authorized basing on a set of applicable permissions.
214
     *
215
     * @param \BEdita\Core\Model\Entity\EndpointPermission[] $permissions Set of applicable permissions.
216
     * @return bool|string
217
     */
218
    protected function checkPermissions(array $permissions)
219
    {
220
        $shift = EndpointPermission::PERM_READ;
221
        if (!$this->request->is(['get', 'head'])) {
222
            $shift = EndpointPermission::PERM_WRITE;
223
        }
224
225
        $result = EndpointPermission::PERM_NO;
226
        foreach ($permissions as $permission) {
227
            $permission = $permission->permission >> $shift & EndpointPermission::PERM_YES;
228
            $result = $result | $permission;
229
230
            if ($permission === EndpointPermission::PERM_BLOCK) {
231
                $result = EndpointPermission::PERM_NO;
232
233
                break;
234
            }
235
        }
236
237
        return EndpointPermission::decode($result);
238
    }
239
}
240