Completed
Push — master ( 093d50...ba2b08 )
by Oscar
03:17
created

Csrf::recycleTokens()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 4
Bugs 1 Features 0
Metric Value
c 4
b 1
f 0
dl 0
loc 14
rs 9.2
cc 4
eloc 7
nc 3
nop 1

1 Method

Rating   Name   Duplication   Size   Complexity  
A Csrf::encode() 0 4 1
1
<?php
2
3
namespace Psr7Middlewares\Middleware;
4
5
use Psr7Middlewares\Utils;
6
use Psr\Http\Message\ServerRequestInterface;
7
use Psr\Http\Message\ResponseInterface;
8
use RuntimeException;
9
10
/**
11
 * Middleware for CSRF protection
12
 * Code inspired from https://github.com/paragonie/anti-csrf.
13
 */
14
class Csrf
15
{
16
    use Utils\FormTrait;
17
    use Utils\StorageTrait;
18
19
    const KEY = 'CSRF';
20
    const KEY_GENERATOR = 'CSRF_GENERATOR';
21
22
    /**
23
     * @var int Max number of CSRF tokens
24
     */
25
    private $maxTokens = 100;
26
27
    /**
28
     * @var string field name with the CSRF index
29
     */
30
    private $formIndex = '_CSRF_INDEX';
31
32
    /**
33
     * @var string field name with the CSRF token
34
     */
35
    private $formToken = '_CSRF_TOKEN';
36
37
    /**
38
     * Returns a callable to generate the inputs.
39
     *
40
     * @param ServerRequestInterface $request
41
     *
42
     * @return callable|null
43
     */
44
    public static function getGenerator(ServerRequestInterface $request)
45
    {
46
        return self::getAttribute($request, self::KEY_GENERATOR);
47
    }
48
49
    /**
50
     * Execute the middleware.
51
     *
52
     * @param ServerRequestInterface $request
53
     * @param ResponseInterface      $response
54
     * @param callable               $next
55
     *
56
     * @return ResponseInterface
57
     */
58
    public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next)
59
    {
60
        if (!self::hasAttribute($request, FormatNegotiator::KEY)) {
61
            throw new RuntimeException('Csrf middleware needs FormatNegotiator executed before');
62
        }
63
64
        if (!self::hasAttribute($request, ClientIp::KEY)) {
65
            throw new RuntimeException('Csrf middleware needs ClientIp executed before');
66
        }
67
68
        if (FormatNegotiator::getFormat($request) !== 'html') {
69
            return $next($request, $response);
70
        }
71
72
        $tokens =& self::getStorage($request, self::KEY);
73
74
        if (Utils\Helpers::isPost($request) && !$this->validateRequest($request, $tokens)) {
75
            return $response->withStatus(403);
76
        }
77
78
        $generator = function ($action = null) use ($request, &$tokens) {
79
            if (empty($action)) {
80
                $action = $request->getUri()->getPath();
81
            }
82
83
            return $this->generateTokens($request, $action, $tokens);
84
        };
85
86
        if (!$this->autoInsert) {
87
            $request = self::setAttribute($request, self::KEY_GENERATOR, $generator);
88
            return $next($request, $response);
89
        }
90
91
        $response = $next($request, $response);
92
93
        return $this->insertIntoPostForms($response, function ($match) use ($generator) {
94
            preg_match('/action=["\']?([^"\'\s]+)["\']?/i', $match[0], $matches);
95
96
            return $match[0].$generator(isset($matches[1]) ? $matches[1] : null);
97
        });
98
    }
99
100
    /**
101
     * Generate and retrieve the tokens.
102
     * 
103
     * @param ServerRequestInterface $request
104
     * @param string                 $lockTo
105
     * @param array                  $tokens
106
     *
107
     * @return string
108
     */
109
    private function generateTokens(ServerRequestInterface $request, $lockTo, array &$tokens)
110
    {
111
        $index = self::encode(random_bytes(18));
112
        $token = self::encode(random_bytes(32));
113
114
        $tokens[$index] = [
115
            'uri' => $request->getUri()->getPath(),
116
            'token' => $token,
117
            'lockTo' => $lockTo,
118
        ];
119
120
        if ($this->maxTokens > 0 && ($total = count($tokens)) > $this->maxTokens) {
121
            array_splice($tokens, 0, $total - $this->maxTokens);
122
        }
123
124
        $token = self::encode(hash_hmac('sha256', ClientIp::getIp($request), base64_decode($token), true));
125
126
        return '<input type="hidden" name="'.$this->formIndex.'" value="'.htmlentities($index, ENT_QUOTES, 'UTF-8').'">'
127
               .'<input type="hidden" name="'.$this->formToken.'" value="'.htmlentities($token, ENT_QUOTES, 'UTF-8').'">';
128
    }
129
130
    /**
131
     * Validate the request.
132
     * 
133
     * @param ServerRequestInterface $request
134
     * @param array                  &$tokens
135
     *
136
     * @return bool
137
     */
138
    private function validateRequest(ServerRequestInterface $request, array &$tokens)
139
    {
140
        $data = $request->getParsedBody();
141
142
        if (!isset($data[$this->formIndex]) || !isset($data[$this->formToken])) {
143
            return false;
144
        }
145
146
        $index = $data[$this->formIndex];
147
        $token = $data[$this->formToken];
148
149
        if (!isset($tokens[$index])) {
150
            return false;
151
        }
152
153
        $stored = $tokens[$index];
154
        unset($tokens[$index]);
155
156
        $lockTo = $request->getUri()->getPath();
157
158
        if (!Utils\Helpers::hashEquals($lockTo, $stored['lockTo'])) {
159
            return false;
160
        }
161
162
        $expected = self::encode(hash_hmac('sha256', ClientIp::getIp($request), base64_decode($stored['token']), true));
163
164
        return Utils\Helpers::hashEquals($token, $expected);
165
    }
166
167
    /**
168
     * Encode string with base64, but strip padding.
169
     * PHP base64_decode does not croak on that.
170
     *
171
     * @param string $value
172
     *
173
     * @return string
174
     */
175
    private static function encode($value)
176
    {
177
        return rtrim(base64_encode($value), '=');
178
    }
179
}
180