Passed
Push — phpunit8 ( f24111 )
by Sam
08:30
created

Storage::getSessionSalt()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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