Issues (326)

src/Controller/Component/AuthUserComponent.php (7 issues)

1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * Saito - The Threaded Web Forum
7
 *
8
 * @copyright Copyright (c) the Saito Project Developers
9
 * @link https://github.com/Schlaefer/Saito
10
 * @license http://opensource.org/licenses/MIT
11
 */
12
13
namespace App\Controller\Component;
14
15
use App\Controller\AppController;
16
use App\Model\Entity\User;
17
use App\Model\Table\UsersTable;
18
use Authentication\Authenticator\CookieAuthenticator;
19
use Authentication\Controller\Component\AuthenticationComponent;
20
use Cake\Controller\Component;
21
use Cake\Controller\Controller;
22
use Cake\Core\Configure;
23
use Cake\Event\Event;
24
use Cake\Http\Exception\ForbiddenException;
25
use Cake\ORM\TableRegistry;
26
use DateTimeImmutable;
27
use Firebase\JWT\JWT;
28
use Saito\Exception\SaitoForbiddenException;
29
use Saito\RememberTrait;
30
use Saito\User\Cookie\Storage;
31
use Saito\User\CurrentUser\CurrentUser;
32
use Saito\User\CurrentUser\CurrentUserFactory;
33
use Saito\User\CurrentUser\CurrentUserInterface;
34
use Stopwatch\Lib\Stopwatch;
35
36
/**
37
 * Authenticates the current user and bootstraps the CurrentUser information
38
 *
39
 * @property AuthenticationComponent $Authentication
40
 */
41
class AuthUserComponent extends Component
42
{
43
    use RememberTrait;
44
45
    /**
46
     * Component name
47
     *
48
     * @var string
49
     */
50
    public $name = 'CurrentUser';
51
52
    /**
53
     * Component's components
54
     *
55
     * @var array
56
     */
57
    public $components = [
58
        'Authentication.Authentication',
59
    ];
60
61
    /**
62
     * Current user
63
     *
64
     * @var CurrentUserInterface
65
     */
66
    protected $CurrentUser;
67
68
    /**
69
     * UsersTableInstance
70
     *
71
     * @var UsersTable
72
     */
73
    protected $UsersTable = null;
74
75
    /**
76
     * Array of authorized actions 'action' => 'resource'
77
     *
78
     * @var array
79
     */
80
    private $actionAuthorizationResources = [];
81
82
    /**
83
     * {@inheritDoc}
84
     */
85
    public function initialize(array $config)
86
    {
87
        Stopwatch::start('CurrentUser::initialize()');
88
89
        /** @var UsersTable */
90
        $UsersTable = TableRegistry::getTableLocator()->get('Users');
91
        $this->UsersTable = $UsersTable;
92
93
        if ($this->isBot()) {
94
            $CurrentUser = CurrentUserFactory::createDummy();
95
        } else {
96
            $controller = $this->getController();
97
            $request = $controller->getRequest();
98
99
            $user = $this->authenticate();
100
            if (!empty($user)) {
101
                $CurrentUser = CurrentUserFactory::createLoggedIn($user->toArray());
102
                $userId = (string)$CurrentUser->getId();
103
                $isLoggedIn = true;
104
            } else {
105
                $CurrentUser = CurrentUserFactory::createVisitor($controller);
106
                $userId = $request->getSession()->id();
107
                $isLoggedIn = false;
108
            }
109
110
            $this->UsersTable->UserOnline->setOnline($userId, $isLoggedIn);
111
        }
112
113
        $this->setCurrentUser($CurrentUser);
114
115
        Stopwatch::stop('CurrentUser::initialize()');
116
    }
117
118
    /**
119
     * {@inheritDoc}
120
     */
121
    public function startup()
122
    {
123
        if (!$this->isAuthorized($this->CurrentUser)) {
124
            throw new SaitoForbiddenException(null, ['CurrentUser' => $this->CurrentUser]);
125
        }
126
    }
127
128
    /**
129
     * Detects if the current user is a bot
130
     *
131
     * @return bool
132
     */
133
    public function isBot()
134
    {
135
        return $this->remember('isBot', $this->getController()->getRequest()->is('bot'));
136
    }
137
138
    /**
139
     * Tries to log-in a user
140
     *
141
     * Call this from controllers to authenticate manually (from login-form-data).
142
     *
143
     * @return bool Was login successfull?
144
     */
145
    public function login(): bool
146
    {
147
        // destroy any existing session or Authentication-data
148
        $this->logout();
149
150
        // non-logged in session-id is lost after Authentication
151
        $originalSessionId = session_id();
152
153
        $user = $this->authenticate();
154
155
        if (!$user) {
0 ignored issues
show
$user is of type App\Model\Entity\User, thus it always evaluated to true.
Loading history...
156
            // login failed
157
            return false;
158
        }
159
160
        $this->Authentication->setIdentity($user);
161
        $CurrentUser = CurrentUserFactory::createLoggedIn($user->toArray());
162
        $this->setCurrentUser($CurrentUser);
163
164
        $this->UsersTable->incrementLogins($user);
165
        $this->UsersTable->UserOnline->setOffline($originalSessionId);
166
167
        /// password update
168
        $password = (string)$this->request->getData('password');
0 ignored issues
show
Deprecated Code introduced by
The property Cake\Controller\Component::$request has been deprecated: 3.4.0 Storing references to the request is deprecated. Use Component::getController() or callback $event->getSubject() to access the controller & request instead. ( Ignorable by Annotation )

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

168
        $password = (string)/** @scrutinizer ignore-deprecated */ $this->request->getData('password');

This property has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the property will be removed from the class and what other property to use instead.

Loading history...
169
        if ($password) {
170
            $this->UsersTable->autoUpdatePassword($this->CurrentUser->getId(), $password);
171
        }
172
173
        return true;
174
    }
175
176
    /**
177
     * Tries to authenticate and login the user.
178
     *
179
     * @return null|User User if is logged-in, null otherwise.
180
     */
181
    protected function authenticate(): ?User
182
    {
183
        $result = $this->Authentication->getResult();
184
185
        $loginFailed = !$result->isValid();
186
        if ($loginFailed) {
187
            return null;
188
        }
189
190
        /** @var User User is always retrieved from ORM */
191
        $user = $result->getData();
192
193
        $isUnactivated = $user['activate_code'] !== 0;
194
        $isLocked = $user['user_lock'] == true;
195
196
        if ($isUnactivated || $isLocked) {
197
            /// User isn't allowed to be logged-in
198
            // Destroy any existing (session) storage information.
199
            $this->logout();
200
201
            return null;
202
        }
203
204
        $this->refreshAuthenticationProvider();
205
206
        return $user;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $user could return the type array which is incompatible with the type-hinted return App\Model\Entity\User|null. Consider adding an additional type-check to rule them out.
Loading history...
207
    }
208
209
    /**
210
     * Logs-out user: clears session data and cookies.
211
     *
212
     * @return void
213
     */
214
    public function logout(): void
215
    {
216
        if (!empty($this->CurrentUser)) {
217
            if ($this->CurrentUser->isLoggedIn()) {
218
                $this->UsersTable->UserOnline->setOffline($this->CurrentUser->getId());
219
            }
220
            $this->setCurrentUser(CurrentUserFactory::createVisitor($this->getController()));
221
        }
222
        $this->Authentication->logout();
223
    }
224
225
    /**
226
     * {@inheritDoc}
227
     */
228
    public function shutdown(Event $event)
229
    {
230
        $this->setJwtCookie($event->getSubject());
231
    }
232
233
    /**
234
     * Update persistent authentication providers for regular visitors.
235
     *
236
     * Users who visit somewhat regularly shall not be logged-out.
237
     *
238
     * @return void
239
     */
240
    private function refreshAuthenticationProvider()
241
    {
242
        // Get current authentication provider
243
        $authenticationProvider = $this->Authentication
244
            ->getAuthenticationService()
245
            ->getAuthenticationProvider();
246
247
        // Persistent login provider is cookie based. Every time that cookie is
248
        // used for a login its expiry is pushed forward.
249
        if ($authenticationProvider instanceof CookieAuthenticator) {
250
            $controller = $this->getController();
251
252
            $cookieKey = $authenticationProvider->getConfig('cookie.name');
253
            $cookie = $controller->getRequest()->getCookieCollection()->get($cookieKey);
254
            if (empty($cookieKey) || empty($cookie)) {
255
                throw new \RuntimeException(
256
                    sprintf('Auth-cookie "%s" not found for refresh.', $cookieKey),
257
                    1569739698
258
                );
259
            }
260
261
            $expire = $authenticationProvider->getConfig('cookie.expire');
262
            $refreshedCookie = $cookie
263
                ->withExpiry($expire)
264
                // Can't read path from cookies, so the default would be root '/'.
265
                ->withPath($this->getController()->getRequest()->getAttribute('webroot'));
266
267
            $response = $controller->getResponse()->withCookie($refreshedCookie);
268
            $controller->setResponse($response);
269
        }
270
    }
271
272
    /**
273
     * Stores (or deletes) the JS-Web-Token as Cookie for access in front-end
274
     *
275
     * @param Controller $controller The controller
276
     * @return void
277
     */
278
    private function setJwtCookie(Controller $controller): void
279
    {
280
        $expire = '+1 day';
281
        $cookieKey = Configure::read('Session.cookie') . '-JWT';
282
        $cookie = new Storage(
283
            $controller,
284
            $cookieKey,
285
            ['http' => false, 'expire' => $expire]
286
        );
287
288
        $existingToken = $cookie->read();
289
290
        // User not logged-in: No JWT-cookie for you!
291
        if (!$this->CurrentUser->isLoggedIn()) {
292
            if ($existingToken) {
293
                $cookie->delete();
294
            }
295
296
            return;
297
        }
298
299
        if ($existingToken) {
300
            // Encoded JWT token format: <header>.<payload>.<signature>
301
            $parts = explode('.', $existingToken);
0 ignored issues
show
It seems like $existingToken can also be of type array; however, parameter $string of explode() does only seem to accept string, 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

301
            $parts = explode('.', /** @scrutinizer ignore-type */ $existingToken);
Loading history...
302
            $payloadEncoded = $parts[1];
303
            // [performance] Done every logged-in request. Don't decrypt whole
304
            // token with signature. We only make sure it exists, the auth
305
            // happens elsewhere.
306
            $payload = Jwt::jsonDecode(Jwt::urlsafeB64Decode($payloadEncoded));
307
            $isCurrentUser = $payload->sub === $this->CurrentUser->getId();
308
            // Assume expired if within the next two hours.
309
            $aboutToExpire = $payload->exp > (time() - 7200);
310
            // Token doesn't require an update if it belongs to current user and
311
            // isn't about to expire.
312
            if ($isCurrentUser && !$aboutToExpire) {
313
                return;
314
            }
315
        }
316
317
        /// Set new token
318
        // Use easy to change cookieSalt to allow emergency invalidation of all
319
        // existing tokens.
320
        $jwtKey = Configure::read('Security.cookieSalt');
321
        $jwtPayload = [
322
            'sub' => $this->CurrentUser->getId(),
323
            // Token is valid for one day.
324
            'exp' => (new DateTimeImmutable($expire))->getTimestamp(),
325
        ];
326
        $jwtToken = \Firebase\JWT\JWT::encode($jwtPayload, $jwtKey);
327
        $cookie->write($jwtToken);
328
    }
329
330
    /**
331
     * Returns the current-user
332
     *
333
     * @return CurrentUserInterface
334
     */
335
    public function getUser(): CurrentUserInterface
336
    {
337
        return $this->CurrentUser;
338
    }
339
340
    /**
341
     * Makes the current user available throughout the application
342
     *
343
     * @param CurrentUserInterface $CurrentUser current-user to set
344
     * @return void
345
     */
346
    private function setCurrentUser(CurrentUserInterface $CurrentUser): void
347
    {
348
        $this->CurrentUser = $CurrentUser;
349
350
        /** @var AppController */
351
        $controller = $this->getController();
352
        // makes CurrentUser available in Controllers
353
        $controller->CurrentUser = $this->CurrentUser;
354
        // makes CurrentUser available as View var in templates
355
        $controller->set('CurrentUser', $this->CurrentUser);
356
    }
357
358
    /**
359
     * The controller action will be authorized with a permission resource.
360
     *
361
     * @param string $action The controller action to authorize.
362
     * @param string $resource The permission resource token.
363
     * @return void
364
     */
365
    public function authorizeAction(string $action, string $resource)
366
    {
367
        $this->actionAuthorizationResources[$action] = $resource;
368
    }
369
370
    /**
371
     * Check if user is authorized to access the current action.
372
     *
373
     * @param CurrentUser $user The current user.
374
     * @return bool True if authorized False otherwise.
375
     */
376
    private function isAuthorized(CurrentUser $user)
377
    {
378
        /// Authorize action through resource
379
        $action = $this->getController()->getRequest()->getParam('action');
380
        if (isset($this->actionAuthorizationResources[$action])) {
381
            return $user->permission($this->actionAuthorizationResources[$action]);
382
        }
383
384
        /// Authorize admin area
385
        $prefix = $this->request->getParam('prefix');
0 ignored issues
show
Deprecated Code introduced by
The property Cake\Controller\Component::$request has been deprecated: 3.4.0 Storing references to the request is deprecated. Use Component::getController() or callback $event->getSubject() to access the controller & request instead. ( Ignorable by Annotation )

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

385
        $prefix = /** @scrutinizer ignore-deprecated */ $this->request->getParam('prefix');

This property has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the property will be removed from the class and what other property to use instead.

Loading history...
386
        $plugin = $this->request->getParam('plugin');
0 ignored issues
show
Deprecated Code introduced by
The property Cake\Controller\Component::$request has been deprecated: 3.4.0 Storing references to the request is deprecated. Use Component::getController() or callback $event->getSubject() to access the controller & request instead. ( Ignorable by Annotation )

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

386
        $plugin = /** @scrutinizer ignore-deprecated */ $this->request->getParam('plugin');

This property has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the property will be removed from the class and what other property to use instead.

Loading history...
387
        $isAdminRoute = ($prefix && strtolower($prefix) === 'admin')
0 ignored issues
show
Consider adding parentheses for clarity. Current Interpretation: $isAdminRoute = ($prefix...r($plugin) === 'admin'), Probably Intended Meaning: $isAdminRoute = $prefix ...r($plugin) === 'admin')
Loading history...
388
            || ($plugin && strtolower($plugin) === 'admin');
389
        if ($isAdminRoute) {
390
            return $user->permission('saito.core.admin.backend');
391
        }
392
393
        return true;
394
    }
395
}
396