Passed
Push — master ( 3ebf33...973b18 )
by Marwan
09:59
created

Session   A

Complexity

Total Complexity 16

Size/Duplication

Total Lines 317
Duplicated Lines 0 %

Test Coverage

Coverage 98.96%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 91
c 3
b 0
f 0
dl 0
loc 317
ccs 95
cts 96
cp 0.9896
rs 10
wmc 16

27 Methods

Rating   Name   Duplication   Size   Complexity  
flash() 0 74 ?
A __construct() 0 3 1
A hp$1 ➔ __toString() 0 3 1
A set() 0 5 1
A clear() 0 13 3
A hp$0 ➔ render() 0 15 2
A start() 0 15 6
A hp$0 ➔ message() 0 17 2
B hp$0 ➔ flash() 0 74 4
A cut() 0 3 1
A destroy() 0 3 1
A hp$1 ➔ html() 0 6 1
A hp$0 ➔ __call() 0 6 1
A unset() 0 3 1
A get() 0 3 1
A hp$0 ➔ __construct() 0 5 1
A hp$1 ➔ __construct() 0 4 1
A hp$0 ➔ __invoke() 0 3 1
A has() 0 3 1
A hp$1 ➔ token() 0 7 2
A hp$0 ➔ __toString() 0 3 1
A hp$1 ➔ isValid() 0 9 3
csrf() 0 70 ?
A hp$1 ➔ isWhitelisted() 0 8 2
A hp$1 ➔ isIdentical() 0 5 2
B hp$1 ➔ csrf() 0 70 2
A hp$1 ➔ check() 0 12 2
1
<?php
2
3
/**
4
 * @author Marwan Al-Soltany <[email protected]>
5
 * @copyright Marwan Al-Soltany 2021
6
 * For the full copyright and license information, please view
7
 * the LICENSE file that was distributed with this source code.
8
 */
9
10
declare(strict_types=1);
11
12
namespace MAKS\Velox\Backend;
13
14
use MAKS\Velox\App;
15
use MAKS\Velox\Frontend\View;
16
use MAKS\Velox\Frontend\HTML;
17
use MAKS\Velox\Helper\Misc;
18
19
/**
20
 * A class that offers a simple interface to work with sessions.
21
 *
22
 * Example:
23
 * ```
24
 * // start a session
25
 * Session::start();
26
 *
27
 * // check for variable availability
28
 * $someVarExists = Session::has('someVar');
29
 *
30
 * // set a session variable
31
 * Session::set('someVar', $value);
32
 *
33
 * // get a session variable
34
 * $someVar = Session::get('someVar');
35
 *
36
 * // destroy a session
37
 * Session::destroy();
38
 * ```
39
 *
40
 * @package Velox\Backend
41
 * @since 1.3.0
42
 * @api
43
 */
44
final class Session
45
{
46
    /**
47
     * Class constructor.
48
     *
49
     * @param int|null $expiration Session expiration time in minutes.
50
     * @param string|null $limiter Session limiter.
51
     * @param string|null $path Session save path.
52
     */
53 27
    public function __construct(?int $expiration = null, ?string $limiter = null, ?string $path = null)
54
    {
55 27
        $this->start($expiration, $limiter, $path);
56 27
    }
57
58
    /**
59
     * Starts the session if it is not already started.
60
     *
61
     * @param int|null [optional] $expiration Session expiration time in minutes.
62
     * @param string|null [optional] $limiter Session limiter.
63
     * @param string|null [optional] $path Session save path.
64
     *
65
     * @return bool True if the session was started, false otherwise.
66
     */
67 34
    public static function start(?int $expiration = null, ?string $limiter = null, ?string $path = null): bool
68
    {
69 34
        $path       ??= Config::get('session.path', Config::get('global.paths.storage') . '/sessions');
70 34
        $limiter    ??= Config::get('session.cache.limiter', 'nocache');
71 34
        $expiration ??= Config::get('session.cache.expiration', 180);
72
73 34
        file_exists($path) || mkdir($path, 0744, true);
74
75 34
        session_save_path() != $path && session_save_path($path);
76 34
        session_cache_expire() != $expiration && session_cache_expire($expiration);
77 34
        session_cache_limiter() != $limiter && session_cache_limiter($limiter);
78
79 34
        $status = session_status() != PHP_SESSION_NONE || session_start(['name' => 'VELOX']);
80
81 34
        return $status;
82
    }
83
84
    /**
85
     * Destroys all of the data associated with the current session.
86
     * This method does not unset any of the global variables associated with the session, or unset the session cookie.
87
     *
88
     * @return bool True if the session was destroyed, false otherwise.
89
     */
90 1
    public static function destroy(): bool
91
    {
92 1
        return session_destroy();
93
    }
94
95
    /**
96
     * Unsets the session superglobal
97
     * This method deletes (truncates) only the variables in the session, session still exists.
98
     *
99
     * @return bool True if the session was unset, false otherwise.
100
     */
101 1
    public static function unset(): bool
102
    {
103 1
        return session_unset();
104
    }
105
106
    /**
107
     * Clears the session entirely.
108
     * This method will unset the session, destroy the session, commit (close writing) to the session, and reset the session cookie (new expiration).
109
     *
110
     * @return bool True if the session was cleared, false otherwise.
111
     */
112 1
    public static function clear(): bool
113
    {
114 1
        $name   = session_name();
115 1
        $cookie = session_get_cookie_params();
116
117 1
        setcookie($name, '', 0, $cookie['path'], $cookie['domain'], $cookie['secure'], $cookie['httponly'] ?? false);
118
        // not testable in CLI, headers already sent
119
        // @codeCoverageIgnoreStart
120
        $unset   = session_unset();
121
        $destroy = session_destroy();
122
        $commit  = session_commit();
123
124
        return ($unset && $destroy && $commit);
125
        // @codeCoverageIgnoreEnd
126
    }
127
128
    /**
129
     * Checks if a value exists in the session.
130
     *
131
     * @param string $key The key to check. Dot-notation can be used with nested arrays.
132
     *
133
     * @return bool True if the key exists, false otherwise.
134
     */
135 12
    public static function has(string $key): bool
136
    {
137 12
        return Globals::getSession($key) !== null;
138
    }
139
140
    /**
141
     * Gets a value from the session.
142
     *
143
     * @param string $key The key to get. Dot-notation can be used with nested arrays.
144
     *
145
     * @return mixed The value of the key, or null if the key does not exist.
146
     */
147 12
    public static function get(string $key)
148
    {
149 12
        return Globals::getSession($key);
150
    }
151
152
    /**
153
     * Sets a value in the session.
154
     *
155
     * @param string $key The key to set. Dot-notation can be used with nested arrays.
156
     * @param mixed $value The value to set.
157
     *
158
     * @return static The current instance.
159
     */
160 7
    public static function set(string $key, $value)
161
    {
162 7
        Globals::setSession($key, $value);
163
164 7
        return new static();
165
    }
166
167
    /**
168
     * Cuts a value from the session. The value will be returned and the key will be unset from the array.
169
     *
170
     * @param string $key The key to cut. Dot-notation can be used with nested arrays.
171
     *
172
     * @return mixed The value of the key, or null if the key does not exist.
173
     */
174 12
    public static function cut(string $key)
175
    {
176 12
        return Globals::cutSession($key);
177
    }
178
179
180
    /**
181
     * Writes a flash message to the session.
182
     *
183
     * This method can be invoked without arguments, in that case a `Flash` object will be returned.
184
     * The `Flash` object has the following methods:
185
     * - `message(string $type, string $text, bool $now = false): static`: Writes a flash message to the session.
186
     * - `render(?callable $callback = null): ?string`: Renders the flash messages using the default callback or the passed one (callback will be passed: `$text`, `$type`).
187
     * The `render()` method will be called automatically if the object is casted to a string.
188
     *
189
     * The `Flash` object consists also of magic methods with the following signature:
190
     * - `{type}(string $text, bool $now = false)`
191
     * Where `{type}` is a message type like [`success`, `info`, `warning`, `error`] or any other value.
192
     * The `{type}` will also be used as a CSS class if the default rendering callback is used (camelCase will be changed to kebab-case automatically).
193
     *
194
     * @param string $type [optional] Message type.
195
     * @param string $text [optional] Message text.
196
     * @param bool $now [optional] Whether to write and make the message available for rendering immediately or wait for the next request.
197
     *
198
     * @return object
199
     */
200 1
    public static function flash(string $text = '', string $type = '', bool $now = false): object
201
    {
202 1
        self::start();
203
204 1
        static $flash = null;
205
206 1
        if ($flash === null) {
207 1
            $flash = new class () {
208
                private string $name = '_flash';
209
                private array $messages = [];
210
                public function __construct()
211
                {
212 1
                    $this->messages = Globals::getSession($this->name) ?? [];
213
214 1
                    Globals::setSession($this->name, []);
215 1
                }
216
                public function __invoke()
217
                {
218 1
                    return $this->message(...func_get_args());
219
                }
220
                public function __call(string $method, array $arguments)
221
                {
222 1
                    return $this->message(
223 1
                        Misc::transform($method, 'kebab'),
224 1
                        $arguments[0] ?? '',
225 1
                        $arguments[1] ?? false
226
                    );
227
                }
228
                public function __toString()
229
                {
230 1
                    return $this->render();
231
                }
232
                public function message(string $type, string $text, bool $now = false)
233
                {
234 1
                    if ($now) {
235 1
                        $this->messages[md5(uniqid($text))] = [
236 1
                            'type' => $type,
237 1
                            'text' => $text
238
                        ];
239
240 1
                        return $this;
241
                    }
242
243 1
                    Globals::setSession($this->name . '.' . md5(uniqid($text)), [
244 1
                        'type' => $type,
245 1
                        'text' => $text
246
                    ]);
247
248 1
                    return $this;
249
                }
250
                public function render(?callable $callback = null): string
251
                {
252 1
                    $callback = $callback ?? function ($text, $type) {
253 1
                        return HTML::div($text, [
254 1
                            'class' => 'flash-message ' . $type
255
                        ]);
256 1
                    };
257
258 1
                    $html = '';
259
260 1
                    foreach ($this->messages as $message) {
261 1
                        $html .= $callback($message['text'], $message['type']);
262
                    }
263
264 1
                    return $html;
265
                }
266
            };
267
        }
268
269 1
        if (strlen(trim($text))) {
270 1
            $flash($type, $text, $now);
271
        }
272
273 1
        return $flash;
274
    }
275
276
    /**
277
     * Generates and checks CSRF tokens.
278
     *
279
     * This method will return a `CSRF` object.
280
     * The `CSRF` object has the following methods:
281
     * - `isValid(): bool`: Validate the request token with the token stored in the session.
282
     * - `token(): string`: Generates a CSRF token, stores it in the session and returns it.
283
     * - `html(): string`: Returns an HTML input element containing a CSRF token after storing it in the session.
284
     * The `html()` method will be called automatically if the object is casted to a string.
285
     *
286
     * @param string $name [optional] The name of the CSRF token. Default to `{session.csrf.name}` configuration value.
287
     * If a token name other than the default is specified, validation of this token has to be implemented manually.
288
     *
289
     * @return object
290
     */
291 7
    public static function csrf(?string $name = null): object
292
    {
293 7
        self::start();
294
295 7
        return new class ($name) {
296
            private string $name;
297
            private string $token;
298
            public function __construct(?string $name = null)
299
            {
300 7
                $this->name  = $name ?? Config::get('session.csrf.name', '_token');
301 7
                $this->token = Globals::getSession($this->name) ?? '';
302 7
            }
303
            public function __toString()
304
            {
305 1
                return $this->html();
306
            }
307
            public function token(): string
308
            {
309 1
                $this->token = empty($this->token) ? bin2hex(random_bytes(64)) : $this->token;
310
311 1
                Globals::setSession($this->name, $this->token);
312
313 1
                return $this->token;
314
            }
315
            public function html(): string
316
            {
317 1
                return HTML::input(null, [
318 1
                    'type'  => 'hidden',
319 1
                    'name'  => $this->name,
320 1
                    'value' => $this->token()
321
                ]);
322
            }
323
            public function check(): void
324
            {
325 7
                if ($this->isValid()) {
326 6
                    return;
327
                }
328
329 1
                App::log('Responded with 403 to the request for "{uri}". CSRF is detected. Client IP address {ip}', [
330 1
                    'uri' => Globals::getServer('REQUEST_URI'),
331 1
                    'ip'  => Globals::getServer('REMOTE_ADDR'),
332 1
                ], 'system');
333
334 1
                App::abort(403, null, 'Invalid CSRF token!');
335
            }
336
            public function isValid(): bool
337
            {
338 7
                if ($this->isWhitelisted() || $this->isIdentical()) {
339 7
                    return true;
340
                }
341
342 1
                Globals::cutSession($this->name);
343
344 1
                return false;
345
            }
346
            private function isWhitelisted(): bool
347
            {
348 7
                $method = Globals::getServer('REQUEST_METHOD');
349 7
                $client = Globals::getServer('REMOTE_HOST') ?? Globals::getServer('REMOTE_ADDR');
350
351
                return (
352 7
                    in_array($client, Config::get('session.csrf.whitelisted', [])) ||
353 7
                    !in_array($method, Config::get('session.csrf.methods', []))
354
                );
355
            }
356
            private function isIdentical(): bool
357
            {
358 1
                $token = Globals::cutPost($this->name) ?? Globals::cutGet($this->name) ?? '';
359
360 1
                return empty($this->token) || hash_equals($this->token, $token);
361
            }
362
        };
363
    }
364
}
365