Completed
Branch feature/Authentication4 (554da3)
by Schlaefer
03:43
created

AuthUserComponent::authenticate()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
nc 3
nop 0
dl 0
loc 27
rs 9.488
c 0
b 0
f 0
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\App\Registry;
29
use Saito\User\Cookie\Storage;
30
use Saito\User\CurrentUser\CurrentUser;
31
use Saito\User\CurrentUser\CurrentUserFactory;
32
use Saito\User\CurrentUser\CurrentUserInterface;
33
use Stopwatch\Lib\Stopwatch;
34
35
/**
36
 * Authenticates the current user and bootstraps the CurrentUser information
37
 *
38
 * @property AuthenticationComponent $Authentication
39
 */
40
class AuthUserComponent extends Component
41
{
42
    /**
43
     * Component name
44
     *
45
     * @var string
46
     */
47
    public $name = 'CurrentUser';
48
49
    /**
50
     * Component's components
51
     *
52
     * @var array
53
     */
54
    public $components = [
55
        'Authentication.Authentication',
56
    ];
57
58
    /**
59
     * Current user
60
     *
61
     * @var CurrentUserInterface
62
     */
63
    protected $CurrentUser;
64
65
    /**
66
     * UsersTableInstance
67
     *
68
     * @var UsersTable
69
     */
70
    protected $UsersTable = null;
71
72
    /**
73
     * {@inheritDoc}
74
     */
75
    public function initialize(array $config)
76
    {
77
        Stopwatch::start('CurrentUser::initialize()');
78
79
        /** @var UsersTable */
80
        $UsersTable = TableRegistry::getTableLocator()->get('Users');
81
        $this->UsersTable = $UsersTable;
82
83
        if ($this->isBot()) {
84
            $CurrentUser = CurrentUserFactory::createDummy();
85
        } else {
86
            $controller = $this->getController();
87
            $request = $controller->getRequest();
88
89
            $user = $this->authenticate();
90
            if (!empty($user)) {
91
                $CurrentUser = CurrentUserFactory::createLoggedIn($user->toArray());
92
                $userId = (string)$CurrentUser->getId();
93
                $isLoggedIn = true;
94
            } else {
95
                $CurrentUser = CurrentUserFactory::createVisitor($controller);
96
                $userId = $request->getSession()->id();
97
                $isLoggedIn = false;
98
            }
99
100
            $this->UsersTable->UserOnline->setOnline($userId, $isLoggedIn);
101
        }
102
103
        $this->setCurrentUser($CurrentUser);
104
105
        if (!$this->isAuthorized($this->CurrentUser)) {
0 ignored issues
show
Compatibility introduced by
$this->CurrentUser of type object<Saito\User\Curren...r\CurrentUserInterface> is not a sub-type of object<Saito\User\CurrentUser\CurrentUser>. It seems like you assume a concrete implementation of the interface Saito\User\CurrentUser\CurrentUserInterface to be always present.

This check looks for parameters that are defined as one type in their type hint or doc comment but seem to be used as a narrower type, i.e an implementation of an interface or a subclass.

Consider changing the type of the parameter or doing an instanceof check before assuming your parameter is of the expected type.

Loading history...
106
            throw new ForbiddenException();
107
        }
108
109
        Stopwatch::stop('CurrentUser::initialize()');
110
    }
111
112
    /**
113
     * Detects if the current user is a bot
114
     *
115
     * @return bool
116
     */
117
    public function isBot()
118
    {
119
        return $this->request->is('bot');
0 ignored issues
show
Deprecated Code introduced by
The property Cake\Controller\Component::$request has been deprecated with message: 3.4.0 Storing references to the request is deprecated. Use Component::getController() or callback $event->getSubject() to access the controller & request instead.

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...
120
    }
121
122
    /**
123
     * Tries to log-in a user
124
     *
125
     * Call this from controllers to authenticate manually (from login-form-data).
126
     *
127
     * @return bool Was login successfull?
128
     */
129
    public function login(): bool
130
    {
131
        // destroy any existing session or Authentication-data
132
        $this->logout();
133
134
        // non-logged in session-id is lost after Authentication
135
        $originalSessionId = session_id();
136
137
        $user = $this->authenticate();
138
139
        if (!$user) {
140
            // login failed
141
            return false;
142
        }
143
144
        $this->Authentication->setIdentity($user);
145
        $CurrentUser = CurrentUserFactory::createLoggedIn($user->toArray());
146
        $this->setCurrentUser($CurrentUser);
147
148
        $this->UsersTable->incrementLogins($user);
149
        $this->UsersTable->UserOnline->setOffline($originalSessionId);
150
151
        /// password update
152
        $password = (string)$this->request->getData('password');
0 ignored issues
show
Deprecated Code introduced by
The property Cake\Controller\Component::$request has been deprecated with message: 3.4.0 Storing references to the request is deprecated. Use Component::getController() or callback $event->getSubject() to access the controller & request instead.

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...
153
        if ($password) {
154
            $this->UsersTable->autoUpdatePassword($this->CurrentUser->getId(), $password);
155
        }
156
157
        return true;
158
    }
159
160
    /**
161
     * Tries to authenticate and login the user.
162
     *
163
     * @return null|User User if is logged-in, null otherwise.
164
     */
165
    protected function authenticate(): ?User
166
    {
167
        $result = $this->Authentication->getResult();
168
169
        $loginFailed = !$result->isValid();
170
        if ($loginFailed) {
171
            return null;
172
        }
173
174
        /** @var User User is always retrieved from ORM */
175
        $user = $result->getData();
176
177
        $isUnactivated = $user['activate_code'] !== 0;
178
        $isLocked = $user['user_lock'] == true;
179
180
        if ($isUnactivated || $isLocked) {
181
            /// User isn't allowed to be logged-in
182
            // Destroy any existing (session) storage information.
183
            $this->logout();
184
185
            return null;
186
        }
187
188
        $this->refreshAuthenticationProvider();
189
190
        return $user;
191
    }
192
193
    /**
194
     * Logs-out user: clears session data and cookies.
195
     *
196
     * @return void
197
     */
198
    public function logout(): void
199
    {
200
        if (!empty($this->CurrentUser)) {
201
            if ($this->CurrentUser->isLoggedIn()) {
202
                $this->UsersTable->UserOnline->setOffline($this->CurrentUser->getId());
203
            }
204
            $this->setCurrentUser(CurrentUserFactory::createVisitor($this->getController()));
205
        }
206
        $this->Authentication->logout();
207
    }
208
209
    /**
210
     * {@inheritDoc}
211
     */
212
    public function shutdown(Event $event)
213
    {
214
        $this->setJwtCookie($event->getSubject());
0 ignored issues
show
Documentation introduced by
$event->getSubject() is of type object|null, but the function expects a object<Cake\Controller\Controller>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
215
    }
216
217
    /**
218
     * Update persistent authentication providers for regular visitors.
219
     *
220
     * Users who visit somewhat regularly shall not be logged-out.
221
     *
222
     * @return void
223
     */
224
    private function refreshAuthenticationProvider()
225
    {
226
        // Get current authentication provider
227
        $authenticationProvider = $this->Authentication
228
            ->getAuthenticationService()
229
            ->getAuthenticationProvider();
230
231
        // Persistent login provider is cookie based. Every time that cookie is
232
        // used for a login its expiry is pushed forward.
233
        if ($authenticationProvider instanceof CookieAuthenticator) {
234
            $controller = $this->getController();
235
236
            $cookieKey = $authenticationProvider->getConfig('cookie.name');
237
            $cookie = $controller->getRequest()->getCookieCollection()->get($cookieKey);
238
            if (empty($cookieKey) || empty($cookie)) {
239
                throw new \RuntimeException(
240
                    sprintf('Auth-cookie "%s" not found for refresh.', $cookieKey),
241
                    1569739698
242
                );
243
            }
244
245
            $expire = $authenticationProvider->getConfig('cookie.expire');
246
            $refreshedCookie = $cookie->withExpiry($expire);
247
248
            $response = $controller->getResponse()->withCookie($refreshedCookie);
249
            $controller->setResponse($response);
250
        }
251
    }
252
253
    /**
254
     * Stores (or deletes) the JS-Web-Token as Cookie for access in front-end
255
     *
256
     * @param Controller $controller The controller
257
     * @return void
258
     */
259
    private function setJwtCookie(Controller $controller): void
260
    {
261
        $expire = '+1 day';
262
        $cookieKey = Configure::read('Session.cookie') . '-JWT';
263
        $cookie = new Storage(
264
            $controller,
265
            $cookieKey,
266
            ['http' => false, 'expire' => $expire]
267
        );
268
269
        $existingToken = $cookie->read();
270
271
        // User not logged-in: No JWT-cookie for you!
272
        if (!$this->CurrentUser->isLoggedIn()) {
273
            if ($existingToken) {
274
                $cookie->delete();
275
            }
276
277
            return;
278
        }
279
280
        if ($existingToken) {
281
            // Encoded JWT token format: <header>.<payload>.<signature>
282
            $parts = explode('.', $existingToken);
283
            $payloadEncoded = $parts[1];
284
            // [performance] Done every logged-in request. Don't decrypt whole
285
            // token with signature. We only make sure it exists, the auth
286
            // happens elsewhere.
287
            $payload = Jwt::jsonDecode(Jwt::urlsafeB64Decode($payloadEncoded));
288
            $isCurrentUser = $payload->sub === $this->CurrentUser->getId();
289
            // Assume expired if within the next two hours.
290
            $aboutToExpire = $payload->exp > (time() - 7200);
291
            // Token doesn't require an update if it belongs to current user and
292
            // isn't about to expire.
293
            if ($isCurrentUser && !$aboutToExpire) {
294
                return;
295
            }
296
        }
297
298
        /// Set new token
299
        // Use easy to change cookieSalt to allow emergency invalidation of all
300
        // existing tokens.
301
        $jwtKey = Configure::read('Security.cookieSalt');
302
        $jwtPayload = [
303
            'sub' => $this->CurrentUser->getId(),
304
            // Token is valid for one day.
305
            'exp' => (new DateTimeImmutable($expire))->getTimestamp(),
306
        ];
307
        $jwtToken = \Firebase\JWT\JWT::encode($jwtPayload, $jwtKey);
308
        $cookie->write($jwtToken);
309
    }
310
311
    /**
312
     * Returns the current-user
313
     *
314
     * @return CurrentUserInterface
315
     */
316
    public function getUser(): CurrentUserInterface
317
    {
318
        return $this->CurrentUser;
319
    }
320
321
    /**
322
     * Makes the current user available throughout the application
323
     *
324
     * @param CurrentUserInterface $CurrentUser current-user to set
325
     * @return void
326
     */
327
    private function setCurrentUser(CurrentUserInterface $CurrentUser): void
328
    {
329
        $this->CurrentUser = $CurrentUser;
330
331
        /** @var AppController */
332
        $controller = $this->getController();
333
        // makes CurrentUser available in Controllers
334
        $controller->CurrentUser = $this->CurrentUser;
335
        // makes CurrentUser available as View var in templates
336
        $controller->set('CurrentUser', $this->CurrentUser);
337
        Registry::set('CU', $this->CurrentUser);
338
    }
339
340
    /**
341
     * Check if user is authorized to access the current action.
342
     *
343
     * @param CurrentUser $user The current user.
344
     * @return bool True if authorized False otherwise.
345
     */
346
    private function isAuthorized(CurrentUser $user)
347
    {
348
        $controller = $this->getController();
349
        $action = $controller->getRequest()->getParam('action');
350
351
        if (isset($controller->actionAuthConfig)
352
            && isset($controller->actionAuthConfig[$action])) {
353
            $requiredRole = $controller->actionAuthConfig[$action];
354
355
            return Registry::get('Permission')
356
                ->check($user->getRole(), $requiredRole);
357
        }
358
359
        $prefix = $this->request->getParam('prefix');
0 ignored issues
show
Deprecated Code introduced by
The property Cake\Controller\Component::$request has been deprecated with message: 3.4.0 Storing references to the request is deprecated. Use Component::getController() or callback $event->getSubject() to access the controller & request instead.

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...
360
        $plugin = $this->request->getParam('plugin');
0 ignored issues
show
Deprecated Code introduced by
The property Cake\Controller\Component::$request has been deprecated with message: 3.4.0 Storing references to the request is deprecated. Use Component::getController() or callback $event->getSubject() to access the controller & request instead.

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...
361
        $isAdminRoute = ($prefix && strtolower($prefix) === 'admin')
362
            || ($plugin && strtolower($plugin) === 'admin');
363
        if ($isAdminRoute) {
364
            return $user->permission('saito.core.admin.backend');
365
        }
366
367
        return true;
368
    }
369
}
370