Passed
Push — develop ( a25f7a...01c1ac )
by Nikolay
05:32
created

SecurityPlugin   A

Complexity

Total Complexity 28

Size/Duplication

Total Lines 232
Duplicated Lines 0 %

Importance

Changes 4
Bugs 0 Features 0
Metric Value
wmc 28
eloc 91
c 4
b 0
f 0
dl 0
loc 232
rs 10

7 Methods

Rating   Name   Duplication   Size   Complexity  
C beforeDispatch() 0 54 12
A forwardToLoginPage() 0 7 1
B checkUserAuth() 0 33 7
A isLocalHostRequest() 0 3 1
A redirectToHome() 0 43 4
A isAllowedAction() 0 9 2
A forwardTo401Error() 0 7 1
1
<?php
2
3
/*
4
 * MikoPBX - free phone system for small business
5
 * Copyright © 2017-2023 Alexey Portnov and Nikolay Beketov
6
 *
7
 * This program is free software: you can redistribute it and/or modify
8
 * it under the terms of the GNU General Public License as published by
9
 * the Free Software Foundation; either version 3 of the License, or
10
 * (at your option) any later version.
11
 *
12
 * This program is distributed in the hope that it will be useful,
13
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15
 * GNU General Public License for more details.
16
 *
17
 * You should have received a copy of the GNU General Public License along with this program.
18
 * If not, see <https://www.gnu.org/licenses/>.
19
 */
20
21
namespace MikoPBX\AdminCabinet\Plugins;
22
23
use MikoPBX\AdminCabinet\Controllers\ErrorsController;
24
use MikoPBX\AdminCabinet\Controllers\LanguageController;
25
use MikoPBX\AdminCabinet\Controllers\SessionController;
26
use MikoPBX\Common\Handlers\CriticalErrorsHandler;
27
use MikoPBX\Common\Library\Text;
28
use MikoPBX\Common\Models\AuthTokens;
29
use MikoPBX\Common\Providers\AclProvider;
30
use MikoPBX\Common\Providers\ManagedCacheProvider;
31
use Phalcon\Acl\Enum as AclEnum;
32
use Phalcon\Di\Injectable;
33
use Phalcon\Events\Event;
34
use Phalcon\Mvc\Dispatcher;
35
36
/**
37
 * Handles access control and authentication for the application.
38
 * Ensures that users access only the areas they are permitted to.
39
 */
40
class SecurityPlugin extends Injectable
41
{
42
    /**
43
     * Executes before every request is dispatched.
44
     * Verifies user authentication and authorization for the requested resource.
45
     * Unauthenticated users are redirected to the login page or shown a 403 error for AJAX requests.
46
     * Unauthorized access attempts lead to a 401 error page.
47
     *
48
     * @param Event $event The current event instance.
49
     * @param Dispatcher $dispatcher The dispatcher instance.
50
     *
51
     * @return bool Returns `true` to continue with the dispatch process, `false` to halt.
52
     */
53
    public function beforeDispatch(/** @scrutinizer ignore-unused */ Event $event, Dispatcher $dispatcher): bool
54
    {
55
        // Determine if the user is authenticated
56
        $isAuthenticated = $this->checkUserAuth() || $this->isLocalHostRequest();
57
58
        // Identify the requested action and controller
59
        $action = $dispatcher->getActionName();
60
61
        /** @scrutinizer ignore-call */
62
        $controllerClass = $this->dispatcher->getHandlerClass();
63
64
        // Define controllers accessible without authentication
65
        $publicControllers = [
66
            SessionController::class,
67
            LanguageController::class,
68
            ErrorsController::class
69
        ];
70
71
        // Handle unauthenticated access to non-public controllers
72
        if (!$isAuthenticated && !in_array($controllerClass, $publicControllers)) {
73
            // AJAX requests receive a 403 response
74
            if ($this->request->isAjax()) {
75
                $this->response->setStatusCode(403, 'Forbidden')->setContent('This user is not authorized')->send();
76
            } else {
77
                // Standard requests are redirected to the login page
78
                $this->forwardToLoginPage($dispatcher);
79
            }
80
81
            return false;
82
        }
83
84
        // Authenticated users: validate access to the requested resource
85
        if ($isAuthenticated) {
86
            // Redirect to home if the controller is missing or irrelevant
87
            if (
88
                !class_exists($controllerClass)
89
                || ($controllerClass === SessionController::class && strtoupper($action) !== 'END')
90
            ) {
91
                $this->redirectToHome($dispatcher);
92
                return true;
93
            }
94
95
            // Restrict access to unauthorized resources
96
            if (
97
                !$this->isLocalHostRequest()
98
                && !$this->isAllowedAction($controllerClass, $action)
99
                && !in_array($controllerClass, $publicControllers)
100
            ) {
101
                $this->forwardTo401Error($dispatcher);
102
                return true;
103
            }
104
        }
105
106
        return true;
107
    }
108
109
    /**
110
     * Checks if the current user is authenticated.
111
     *
112
     * This method checks if the current user is authenticated based on whether they have an existing session or a valid
113
     * "remember me" cookie.
114
     * If the request is from localhost or the user already has an active session, the method returns
115
     * true.
116
     * If a "remember me" cookie exists, the method checks if it matches any active tokens in the AuthTokens table.
117
     * If a match is found, the user's session is set, and the method returns true. If none of these conditions are met,
118
     * the method returns false.
119
     *
120
     * @return bool true if the user is authenticated, false otherwise.
121
     */
122
    private function checkUserAuth(): bool
123
    {
124
        // Check if it is a localhost request or if the user is already authenticated.
125
        if ($this->session->has(SessionController::SESSION_ID)) {
126
            return true;
127
        }
128
129
        // Check if remember me cookie exists.
130
        if (!$this->cookies->has('random_token')) {
131
            return false;
132
        }
133
        try {
134
            $token = $this->cookies->get('random_token')->getValue();
135
            $currentDate = date("Y-m-d H:i:s", time());
136
137
            // Delete expired tokens and check if the token matches any active tokens.
138
            $userTokens = AuthTokens::find();
139
            foreach ($userTokens as $userToken) {
140
                if ($userToken->expiryDate < $currentDate) {
141
                    $userToken->delete();
142
                } elseif ($this->security->checkHash($token, $userToken->tokenHash)) {
143
                    $sessionParams = json_decode($userToken->sessionParams, true);
144
                    $this->session->set(SessionController::SESSION_ID, $sessionParams);
145
                    return true;
146
                }
147
            }
148
149
        } catch (\Throwable $e) {
150
            //CriticalErrorsHandler::handleException($e);
151
            return false;
152
        }
153
154
        return false;
155
    }
156
157
    /**
158
     * Check if the request is coming from localhost.
159
     *
160
     * @return bool
161
     */
162
    public function isLocalHostRequest(): bool
163
    {
164
        return ($_SERVER['REMOTE_ADDR'] === '127.0.0.1');
165
    }
166
167
    /**
168
     * Redirects the user to the login page.
169
     * @param $dispatcher Dispatcher instance for handling the redirection.
170
     */
171
    private function forwardToLoginPage(Dispatcher $dispatcher): void
172
    {
173
        $dispatcher->forward([
174
            'controller' => 'session',
175
            'action' => 'index',
176
            'module' => 'admin-cabinet',
177
            'namespace' => 'MikoPBX\AdminCabinet\Controllers'
178
        ]);
179
    }
180
181
    /**
182
     * Redirects to the user's home page or a default page if the home page is not set.
183
     *
184
     * This method determines the user's home page based on the session data. If the home page path is not set in the session,
185
     * it defaults to '/admin-cabinet/extensions/index'. The method then parses the home page path to extract the module,
186
     * controller, and action, and uses the dispatcher to forward the request to the appropriate route.
187
     *
188
     * @param Dispatcher $dispatcher The dispatcher object used to forward the request.
189
     */
190
    private function redirectToHome(Dispatcher $dispatcher): void
191
    {
192
        // Retrieve the home page path from the session, defaulting to a predefined path if not set
193
        $homePath = $this->session->get(SessionController::SESSION_ID)[SessionController::HOME_PAGE];
194
        if (empty($homePath)) {
195
            $homePath = '/admin-cabinet/extensions/index';
196
        }
197
198
        $redis = $this->di->getShared(ManagedCacheProvider::SERVICE_NAME);
199
200
        $currentPageCacheKey = 'RedirectCount:' . $this->session->getId() . ':' . md5($homePath);
201
202
203
        $redirectCount = $redis->get($currentPageCacheKey) ?? 0;
204
        $redirectCount++;
205
        $redis->set($currentPageCacheKey, $redirectCount, 5);
206
        if ($redirectCount > 25) {
207
            $redis->delete($currentPageCacheKey);
208
            $this->forwardTo401Error($dispatcher);
209
            return;
210
        }
211
212
        // Extract the module, controller, and action from the home page path
213
        $module = explode('/', $homePath)[1];
214
        $controller = explode('/', $homePath)[2];
215
        $action = explode('/', $homePath)[3];
216
        if (str_starts_with($module, 'module-')) {
217
            $camelizedNameSpace = Text::camelize($module);
218
            $namespace = "Modules\\$camelizedNameSpace\\App\\Controllers";
219
220
            // Forward the request to the determined route with namespace
221
            $dispatcher->forward([
222
                'module' => $module,
223
                'controller' => $controller,
224
                'action' => $action,
225
                'namespace' => $namespace
226
            ]);
227
        } else {
228
            // Forward the request to the determined route
229
            $dispatcher->forward([
230
                'module' => $module,
231
                'controller' => $controller,
232
                'action' => $action
233
            ]);
234
        }
235
    }
236
237
    /**
238
     * Redirects the user to a 401 error page.
239
     * @param $dispatcher Dispatcher instance for handling the redirection.
240
     */
241
    private function forwardTo401Error(Dispatcher $dispatcher): void
242
    {
243
        $dispatcher->forward([
244
            'module' => 'admin-cabinet',
245
            'controller' => 'errors',
246
            'action' => 'show401',
247
            'namespace' => 'MikoPBX\AdminCabinet\Controllers'
248
        ]);
249
    }
250
251
    /**
252
     * Checks if an action is allowed for the current user.
253
     *
254
     * This method checks if the specified $action is allowed for the current user based on their role. It gets the user's
255
     * role from the session or sets it to 'guests' if no role is set. It then gets the Access Control List (ACL) and checks
256
     * if the $action is allowed for the current user's role. If the user is a guest or if the $action is not allowed,
257
     * the method returns false. Otherwise, it returns true.
258
     *
259
     * @param string $controller The full name of the controller class.
260
     * @param string $action The name of the action to check.
261
     * @return bool true if the action is allowed for the current user, false otherwise.
262
     */
263
    public function isAllowedAction(string $controller, string $action): bool
264
    {
265
        $role = $this->session->get(SessionController::SESSION_ID)[SessionController::ROLE] ?? AclProvider::ROLE_GUESTS;
266
        $acl = $this->di->get(AclProvider::SERVICE_NAME);
267
        $allowed = $acl->isAllowed($role, $controller, $action);
268
        if ($allowed != AclEnum::ALLOW) {
269
            return false;
270
        } else {
271
            return true;
272
        }
273
    }
274
}
275