Passed
Push — master ( ddbf8b...086098 )
by Robbie
11:17
created

Storage   B

Complexity

Total Complexity 47

Size/Duplication

Total Lines 423
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 113
dl 0
loc 423
rs 8.64
c 0
b 0
f 0
wmc 47

22 Methods

Rating   Name   Duplication   Size   Complexity  
A getSessionSalt() 0 10 2
A getFailureUrl() 0 3 1
A getItems() 0 3 2
A getNamespace() 0 7 2
A setSuccessRequest() 0 10 2
A getTokenHash() 0 8 1
A setSuccessPostVars() 0 22 2
A confirm() 0 15 4
A getCsrfToken() 0 5 1
A cleanup() 0 4 1
A getSuccessUrl() 0 3 1
A generateSalt() 0 3 1
A getHashedItems() 0 11 2
A __construct() 0 12 3
A getCookieKey() 0 5 1
B getSuccessPostVars() 0 45 8
A check() 0 19 5
A getItem() 0 5 3
A setFailureUrl() 0 4 1
A getHttpMethod() 0 3 1
A setSuccessUrl() 0 4 1
A putItem() 0 11 2

How to fix   Complexity   

Complex Class

Complex classes like Storage often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Storage, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace SilverStripe\Security\Confirmation;
4
5
use SilverStripe\Control\Cookie;
6
use SilverStripe\Control\HTTPRequest;
7
use SilverStripe\Control\Session;
8
use SilverStripe\Security\SecurityToken;
9
10
/**
11
 * Confirmation Storage implemented on top of SilverStripe Session and Cookie
12
 *
13
 * The storage keeps the information about the items requiring
14
 * confirmation and their status (confirmed or not) in Session
15
 *
16
 * User data, such as the original request parameters, may be kept in
17
 * Cookie so that session storage cannot be exhausted easily by a malicious user
18
 */
19
class Storage
20
{
21
    const HASH_ALGO = 'sha512';
22
23
    /**
24
     * @var \SilverStripe\Control\Session
25
     */
26
    protected $session;
27
28
    /**
29
     * Identifier of the storage within the session
30
     *
31
     * @var string
32
     */
33
    protected $id;
34
35
    /**
36
     * @param Session $session active session
37
     * @param string $id Unique storage identifier within the session
38
     * @param bool $new Cleanup the storage
39
     */
40
    public function __construct(Session $session, $id, $new = true)
41
    {
42
        $id = trim((string) $id);
43
        if (!strlen($id)) {
44
            throw new \InvalidArgumentException('Storage ID must not be empty');
45
        }
46
47
        $this->session = $session;
48
        $this->id = $id;
49
50
        if ($new) {
51
            $this->cleanup();
52
        }
53
    }
54
55
    /**
56
     * Remove all the data from the storage
57
     * Cleans up Session and Cookie related to this storage
58
     */
59
    public function cleanup()
60
    {
61
        Cookie::force_expiry($this->getCookieKey());
62
        $this->session->clear($this->getNamespace());
63
    }
64
65
    /**
66
     * Gets user input data (usually POST array), checks all the items in the storage
67
     * has been confirmed and marks them as such.
68
     *
69
     * @param array $data User input to look at for items. Usually POST array
70
     *
71
     * @return bool whether all items have been confirmed
72
     */
73
    public function confirm($data)
74
    {
75
        foreach ($this->getItems() as $item) {
76
            $key = base64_encode($this->getTokenHash($item));
77
78
            if (!isset($data[$key]) || $data[$key] !== '1') {
79
                return false;
80
            }
81
82
            $item->confirm();
83
84
            $this->putItem($item);
85
        }
86
87
        return true;
88
    }
89
90
    /**
91
     * Returns the dictionary with the item hashes
92
     *
93
     * The {@see SilverStripe\Security\Confirmation\Storage::confirm} function
94
     * expects exactly same dictionary as its argument for successful confirmation
95
     *
96
     * Keys of the dictionary are salted item token hashes
97
     * All values are the string "1" constantly
98
     *
99
     * @return array
100
     */
101
    public function getHashedItems()
102
    {
103
        $items = [];
104
105
        foreach ($this->getItems() as $item) {
106
            $hash = base64_encode($this->getTokenHash($item));
107
108
            $items[$hash] = '1';
109
        }
110
111
        return $items;
112
    }
113
114
    /**
115
     * Returns salted and hashed version of the item token
116
     *
117
     * @param Item $item
118
     *
119
     * @return string
120
     */
121
    public function getTokenHash(Item $item)
122
    {
123
        $token = $item->getToken();
124
        $salt = $this->getSessionSalt();
125
126
        $salted = $salt.$token;
127
128
        return hash(static::HASH_ALGO, $salted, true);
129
    }
130
131
    /**
132
     * Returns the unique cookie key generated from the session salt
133
     *
134
     * @return string
135
     */
136
    public function getCookieKey()
137
    {
138
        $salt = $this->getSessionSalt();
139
140
        return bin2hex(hash(static::HASH_ALGO, $salt.'cookie key', true));
141
    }
142
143
    /**
144
     * Returns a unique token to use as a CSRF token
145
     *
146
     * @return string
147
     */
148
    public function getCsrfToken()
149
    {
150
        $salt = $this->getSessionSalt();
151
152
        return base64_encode(hash(static::HASH_ALGO, $salt.'csrf token', true));
153
    }
154
155
    /**
156
     * Returns the salt generated for the current session
157
     *
158
     * @return string
159
     */
160
    public function getSessionSalt()
161
    {
162
        $key = $this->getNamespace('salt');
163
164
        if (!$salt = $this->session->get($key)) {
165
            $salt = $this->generateSalt();
166
            $this->session->set($key, $salt);
167
        }
168
169
        return $salt;
170
    }
171
172
    /**
173
     * Returns randomly generated salt
174
     *
175
     * @return string
176
     */
177
    protected function generateSalt()
178
    {
179
        return random_bytes(64);
180
    }
181
182
    /**
183
     * Adds a new object to the list of confirmation items
184
     * Replaces the item if there is already one with the same token
185
     *
186
     * @param Item $item Item requiring confirmation
187
     *
188
     * @return $this
189
     */
190
    public function putItem(Item $item)
191
    {
192
        $key = $this->getNamespace('items');
193
194
        $items = $this->session->get($key) ?: [];
195
196
        $token = $this->getTokenHash($item);
197
        $items[$token] = $item;
198
        $this->session->set($key, $items);
199
200
        return $this;
201
    }
202
203
    /**
204
     * Returns the list of registered confirmation items
205
     *
206
     * @return Item[]
207
     */
208
    public function getItems()
209
    {
210
        return $this->session->get($this->getNamespace('items')) ?: [];
211
    }
212
213
    /**
214
     * Look up an item by its token key
215
     *
216
     * @param string $key Item token key
217
     *
218
     * @return null|Item
219
     */
220
    public function getItem($key)
221
    {
222
        foreach ($this->getItems() as $item) {
223
            if ($item->getToken() === $key) {
224
                return $item;
225
            }
226
        }
227
    }
228
229
    /**
230
     * This request should be performed on success
231
     * Usually the original request which triggered the confirmation
232
     *
233
     * @param HTTPRequest $request
234
     *
235
     * @return $this
236
     */
237
    public function setSuccessRequest(HTTPRequest $request)
238
    {
239
        $this->setSuccessUrl($request->getURL(true));
240
241
        $httpMethod = $request->httpMethod();
242
        $this->session->set($this->getNamespace('httpMethod'), $httpMethod);
243
244
        if ($httpMethod === 'POST') {
245
            $checksum = $this->setSuccessPostVars($request->postVars());
246
            $this->session->set($this->getNamespace('postChecksum'), $checksum);
247
        }
248
    }
249
250
    /**
251
     * Save the post data in the storage (browser Cookies by default)
252
     * Returns the control checksum of the data preserved
253
     *
254
     * Keeps data in Cookies to avoid potential DDoS targeting
255
     * session storage exhaustion
256
     *
257
     * @param array $data
258
     *
259
     * @return string checksum
260
     */
261
    protected function setSuccessPostVars(array $data)
262
    {
263
        $checksum = hash_init(static::HASH_ALGO);
264
        $cookieData = [];
265
266
        foreach ($data as $key => $val) {
267
            $key = strval($key);
268
            $val = strval($val);
269
270
            hash_update($checksum, $key);
271
            hash_update($checksum, $val);
272
273
            $cookieData[] = [$key, $val];
274
        }
275
276
        $checksum = hash_final($checksum, true);
277
        $cookieData = json_encode($cookieData, 0, 2);
278
279
        $cookieKey = $this->getCookieKey();
280
        Cookie::set($cookieKey, $cookieData, 0);
281
282
        return $checksum;
283
    }
284
285
    /**
286
     * Returns HTTP method of the success request
287
     *
288
     * @return string
289
     */
290
    public function getHttpMethod()
291
    {
292
        return $this->session->get($this->getNamespace('httpMethod'));
293
    }
294
295
    /**
296
     * Returns the list of success request post parameters
297
     *
298
     * Returns null if no parameters was persisted initially or
299
     * if the checksum is incorrect.
300
     *
301
     * WARNING! If HTTP Method is POST and this function returns null,
302
     * you MUST assume the Cookie parameter either has been forged or
303
     * expired.
304
     *
305
     * @return array|null
306
     */
307
    public function getSuccessPostVars()
308
    {
309
        $controlChecksum = $this->session->get($this->getNamespace('postChecksum'));
310
311
        if (!$controlChecksum) {
312
            return null;
313
        }
314
315
        $cookieKey = $this->getCookieKey();
316
        $cookieData = Cookie::get($cookieKey);
317
318
        if (!$cookieData) {
319
            return null;
320
        }
321
322
        $cookieData = json_decode($cookieData, true, 3);
323
324
        if (!is_array($cookieData)) {
325
            return null;
326
        }
327
328
        $checksum = hash_init(static::HASH_ALGO);
329
330
        $data = [];
331
        foreach ($cookieData as $pair) {
332
            if (!isset($pair[0]) || !isset($pair[1])) {
333
                return null;
334
            }
335
336
            $key = $pair[0];
337
            $val = $pair[1];
338
339
            hash_update($checksum, $key);
340
            hash_update($checksum, $val);
341
342
            $data[$key] = $val;
343
        }
344
345
        $checksum = hash_final($checksum, true);
346
347
        if ($checksum !== $controlChecksum) {
348
            return null;
349
        }
350
351
        return $data;
352
    }
353
354
    /**
355
     * The URL the form should redirect to on success
356
     *
357
     * @param string $url Success URL
358
     *
359
     * @return $this;
360
     */
361
    public function setSuccessUrl($url)
362
    {
363
        $this->session->set($this->getNamespace('successUrl'), $url);
364
        return $this;
365
    }
366
367
    /**
368
     * Returns the URL registered by {@see self::setSuccessUrl} as a success redirect target
369
     *
370
     * @return string
371
     */
372
    public function getSuccessUrl()
373
    {
374
        return $this->session->get($this->getNamespace('successUrl'));
375
    }
376
377
    /**
378
     * The URL the form should redirect to on failure
379
     *
380
     * @param string $url Failure URL
381
     *
382
     * @return $this;
383
     */
384
    public function setFailureUrl($url)
385
    {
386
        $this->session->set($this->getNamespace('failureUrl'), $url);
387
        return $this;
388
    }
389
390
    /**
391
     * Returns the URL registered by {@see self::setFailureUrl} as a success redirect target
392
     *
393
     * @return string
394
     */
395
    public function getFailureUrl()
396
    {
397
        return $this->session->get($this->getNamespace('failureUrl'));
398
    }
399
400
    /**
401
     * Check all items to be confirmed in the storage
402
     *
403
     * @param Item[] $items List of items to be checked
404
     *
405
     * @return bool
406
     */
407
    public function check(array $items)
408
    {
409
        foreach ($items as $itemToConfirm) {
410
            foreach ($this->getItems() as $item) {
411
                if ($item->getToken() !== $itemToConfirm->getToken()) {
412
                    continue;
413
                }
414
415
                if ($item->isConfirmed()) {
416
                    continue 2;
417
                }
418
419
                break;
420
            }
421
422
            return false;
423
        }
424
425
        return true;
426
    }
427
428
    /**
429
     * Returns the namespace of the storage in the session
430
     *
431
     * @param string|null $key Optional key within the storage
432
     *
433
     * @return string
434
     */
435
    protected function getNamespace($key = null)
436
    {
437
        return sprintf(
438
            '%s.%s%s',
439
            str_replace('\\', '.', __CLASS__),
440
            $this->id,
441
            $key ? '.'.$key : ''
442
        );
443
    }
444
}
445