Completed
Branch feature/phpstanLevel3 (f0c768)
by Schlaefer
02:32
created

AuthUserComponent::setJwtCookie()   B

Complexity

Conditions 6
Paths 5

Size

Total Lines 34

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
nc 5
nop 1
dl 0
loc 34
rs 8.7537
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\Model\Table\UsersTable;
16
use Cake\Controller\Component;
17
use Cake\Controller\Component\AuthComponent;
18
use Cake\Controller\Controller;
19
use Cake\Core\Configure;
20
use Cake\Event\Event;
21
use Cake\ORM\TableRegistry;
22
use Firebase\JWT\JWT;
23
use Saito\App\Registry;
24
use Saito\User\Cookie\CurrentUserCookie;
25
use Saito\User\Cookie\Storage;
26
use Saito\User\CurrentUser\CurrentUserFactory;
27
use Saito\User\CurrentUser\CurrentUserInterface;
28
use Stopwatch\Lib\Stopwatch;
29
30
/**
31
 * Authenticates the current user and bootstraps the CurrentUser information
32
 *
33
 * @property AuthComponent $Auth
34
 */
35
class AuthUserComponent extends Component
36
{
37
    /**
38
     * Component name
39
     *
40
     * @var string
41
     */
42
    public $name = 'CurrentUser';
43
44
    /**
45
     * Component's components
46
     *
47
     * @var array
48
     */
49
    public $components = ['Auth', 'Cron.Cron'];
50
51
    /**
52
     * Current user
53
     *
54
     * @var CurrentUserInterface
55
     */
56
    protected $CurrentUser;
57
58
    /**
59
     * UsersTableInstance
60
     *
61
     * @var UsersTable
62
     */
63
    protected $UsersTable = null;
64
65
    /**
66
     * {@inheritDoc}
67
     */
68
    public function initialize(array $config)
69
    {
70
        Stopwatch::start('CurrentUser::initialize()');
71
72
        $this->UsersTable = TableRegistry::getTableLocator()->get('Users');
73
74
        $this->initSessionAuth($this->Auth);
75
76
        if ($this->isBot()) {
77
            $this->CurrentUser = CurrentUserFactory::createDummy();
78
        } else {
79
            $user = $this->_login();
80
            $controller = $this->getController();
81
            $isLoggedIn = !empty($user);
82
            $excluded = ['login', 'register']; // don't auto-login on login related pages
83
            if ($isLoggedIn && !in_array($controller->getRequest()->getParam('action'), $excluded)) {
84
                $this->CurrentUser = CurrentUserFactory::createLoggedIn($user);
85
                $userId = $this->CurrentUser->getId();
86
            } else {
87
                $this->CurrentUser = CurrentUserFactory::createVisitor($controller);
88
                $userId = $this->request->getSession()->id();
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...
89
            }
90
91
            $this->UsersTable->UserOnline->setOnline($userId, $isLoggedIn);
92
        }
93
94
        Registry::set('CU', $this->CurrentUser);
95
96
        Stopwatch::stop('CurrentUser::initialize()');
97
    }
98
99
    /**
100
     * Detects if the current user is a bot
101
     *
102
     * @return bool
103
     */
104
    public function isBot()
105
    {
106
        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...
107
    }
108
109
    /**
110
     * Tries to authenticate a user provided by credentials in request (usually form-data)
111
     *
112
     * @return bool Was login successfull
113
     */
114
    public function login(): bool
115
    {
116
        // destroy any existing session or auth-data
117
        $this->logout();
118
119
        // non-logged in session-id is lost after Auth::setUser()
120
        $originalSessionId = session_id();
121
122
        $user = $this->_login();
123
124
        if (empty($user)) {
125
            return false;
126
        }
127
128
        $this->CurrentUser->setSettings($user);
129
        $user = $this->UsersTable->get($this->CurrentUser->getId());
130
        $this->UsersTable->incrementLogins($user);
131
        $this->UsersTable->UserOnline->setOffline($originalSessionId);
132
133
        //= password update
134
        $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...
135
        if ($password) {
136
            $this->UsersTable->autoUpdatePassword($this->CurrentUser->getId(), $password);
137
        }
138
139
        //= set persistent Cookie
140
        $setCookie = (bool)$this->request->getData('remember_me');
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...
141
        if ($setCookie) {
142
            (new CurrentUserCookie($this->getController()))->write($this->CurrentUser->getId());
143
        };
144
145
        return true;
146
    }
147
148
    /**
149
     * Tries to login the user.
150
     *
151
     * @return null|array if user is logged-in null otherwise
152
     */
153
    protected function _login(): ?array
154
    {
155
        // Check if AuthComponent knows user from session-storage (usually
156
        // compare session-cookie)
157
        // Notice: Will hit session storage. Usually files.
158
        $user = $this->Auth->user();
159
160
        if (!$user) {
161
            // Check if user is authenticated via one of the Authenticators
162
            // (cookie, token, …).
163
            // Notice: Authenticators may hit DB to find user
164
            $user = $this->Auth->identify();
165
166
            if (!empty($user)) {
167
                // set user in session-storage to be available in subsequent requests
168
                // Notice: on write Cake 3 will start a new session (new session-id)
169
                $this->Auth->setUser($user);
0 ignored issues
show
Bug introduced by
It seems like $user defined by $this->Auth->identify() on line 164 can also be of type boolean; however, Cake\Controller\Component\AuthComponent::setUser() does only seem to accept array|object<ArrayAccess>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
170
            }
171
        }
172
173
        if (empty($user)) {
174
            // Authentication failed.
175
            return null;
176
        }
177
178
        // Session-data may be outdated. Make sure that user-data is up-to-date:
179
        // user not locked/user-type wasn't changend/… since session-storage was written.
180
        // Notice: is going to hit DB
181
        Stopwatch::start('CurrentUser read user from DB');
182
        $user = $this->UsersTable
0 ignored issues
show
Documentation Bug introduced by
The method findAllowedToLoginById does not exist on object<App\Model\Table\UsersTable>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
183
            ->findAllowedToLoginById($user['id'])
184
            ->first();
185
        Stopwatch::stop('CurrentUser read user from DB');
186
187
        if (empty($user)) {
188
            //// no user allowed to login
189
            // destroy any existing (session) storage information
190
            $this->logout();
191
            // send to logout form for formal logout procedure
192
            $this->getController()->redirect(['_name' => 'logout']);
193
194
            return null;
195
        }
196
197
        return $user->toArray();
198
    }
199
200
    /**
201
     * Logs-out user: clears session data and cookies.
202
     *
203
     * @return void
204
     */
205
    public function logout(): void
206
    {
207
        if (!empty($this->CurrentUser)) {
208
            if ($this->CurrentUser->isLoggedIn()) {
209
                $this->UsersTable->UserOnline->setOffline($this->CurrentUser->getId());
210
            }
211
            $this->CurrentUser->setSettings([]);
212
        }
213
        $this->Auth->logout();
214
    }
215
216
    /**
217
     * Configures CakePHP's authentication-component
218
     *
219
     * @param AuthComponent $auth auth-component to configure
220
     * @return void
221
     */
222
    public function initSessionAuth(AuthComponent $auth): void
223
    {
224
        if ($auth->getConfig('authenticate')) {
225
            // different auth configuration already in place (e.g. API)
226
            return;
227
        };
228
229
        $auth->setConfig(
230
            'authenticate',
231
            [
232
                AuthComponent::ALL => ['finder' => 'allowedToLogin'],
233
                'Cookie',
234
                'Mlf',
235
                'Mlf2',
236
                'Form'
237
            ]
238
        );
239
240
        $auth->setConfig('authorize', ['Controller']);
241
        $auth->setConfig('loginAction', '/login');
242
243
        $here = urlencode($this->getController()->getRequest()->getRequestTarget());
244
        $auth->setConfig('unauthorizedRedirect', '/login?redirect=' . $here);
245
246
        $auth->deny();
247
        $auth->setConfig('authError', __('authentication.error'));
248
    }
249
250
    /**
251
     * {@inheritDoc}
252
     */
253
    public function shutdown(Event $event)
254
    {
255
        $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...
256
    }
257
258
    /**
259
     * Sets (or deletes) the JS-Web-Token in Cookie for access in front-end
260
     *
261
     * @param Controller $controller The controller
262
     * @return void
263
     */
264
    private function setJwtCookie(Controller $controller): void
265
    {
266
        $cookieKey = Configure::read('Session.cookie') . '-jwt';
267
        $cookie = (new Storage($controller, $cookieKey, ['http' => false, 'expire' => '+1 week']));
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
            //// check that token belongs to current-user
282
            $parts = explode('.', $existingToken);
283
            // [performance] Done every logged-in request. Don't decrypt whole token with signature.
284
            // We only make sure it exists, the auth happens elsewhere.
285
            $payload = Jwt::jsonDecode(Jwt::urlsafeB64Decode($parts[1]));
286
            if ($payload->sub === $this->CurrentUser->getId() && $payload->exp > time()) {
287
                return;
288
            }
289
        }
290
291
        // use easy to change cookieSalt to allow emergency invalidation of all existing tokens
292
        $jwtKey = Configure::read('Security.cookieSalt');
293
        // cookie expires before JWT (7 days < 14 days): JWT exp should always be valid
294
        $jwtPayload = ['sub' => $this->CurrentUser->getId(), 'exp' => time() + (86400 * 14)];
295
        $jwtToken = \Firebase\JWT\JWT::encode($jwtPayload, $jwtKey);
296
        $cookie->write($jwtToken);
297
    }
298
299
    /**
300
     * Returns the current-user
301
     *
302
     * @return CurrentUserInterface
303
     */
304
    public function getUser(): CurrentUserInterface
305
    {
306
        return $this->CurrentUser;
307
    }
308
}
309