Completed
Push — master ( 8afd01...519e29 )
by Oscar
10s
created

Csrf::randomToken()   B

Complexity

Conditions 6
Paths 8

Size

Total Lines 16
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 16
rs 8.8571
c 0
b 0
f 0
cc 6
eloc 10
nc 8
nop 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, ClientIp::KEY)) {
61
            throw new RuntimeException('Csrf middleware needs ClientIp executed before');
62
        }
63
64
        if (Utils\Helpers::getMimeType($response) !== 'text/html') {
65
            return $next($request, $response);
66
        }
67
68
        $tokens = &self::getStorage($request, self::KEY);
69
70
        if (Utils\Helpers::isPost($request) && !$this->validateRequest($request, $tokens)) {
71
            return $response->withStatus(403);
72
        }
73
74
        $generator = function ($action = null) use ($request, &$tokens) {
75
            if (empty($action)) {
76
                $action = $request->getUri()->getPath();
77
            }
78
79
            return $this->generateTokens($request, $action, $tokens);
80
        };
81
82
        if (!$this->autoInsert) {
83
            $request = self::setAttribute($request, self::KEY_GENERATOR, $generator);
84
85
            return $next($request, $response);
86
        }
87
88
        $response = $next($request, $response);
89
90
        return $this->insertIntoPostForms($response, function ($match) use ($generator) {
91
            preg_match('/action=["\']?([^"\'\s]+)["\']?/i', $match[0], $matches);
92
93
            return $match[0].$generator(isset($matches[1]) ? $matches[1] : null);
94
        });
95
    }
96
97
    /**
98
     * Generate and retrieve the tokens.
99
     *
100
     * @param ServerRequestInterface $request
101
     * @param string                 $lockTo
102
     * @param array                  $tokens
103
     *
104
     * @return string
105
     */
106
    private function generateTokens(ServerRequestInterface $request, $lockTo, array &$tokens)
107
    {
108
        $index = self::encode($this->randomToken(18));
109
        $token = self::encode($this->randomToken(32));
110
111
        $tokens[$index] = [
112
            'uri' => $request->getUri()->getPath(),
113
            'token' => $token,
114
            'lockTo' => $lockTo,
115
        ];
116
117
        if ($this->maxTokens > 0 && ($total = count($tokens)) > $this->maxTokens) {
118
            array_splice($tokens, 0, $total - $this->maxTokens);
119
        }
120
121
        $token = self::encode(hash_hmac('sha256', ClientIp::getIp($request), base64_decode($token), true));
122
123
        return '<input type="hidden" name="'.$this->formIndex.'" value="'.htmlentities($index, ENT_QUOTES, 'UTF-8').'">'
124
               .'<input type="hidden" name="'.$this->formToken.'" value="'.htmlentities($token, ENT_QUOTES, 'UTF-8').'">';
125
    }
126
127
    /**
128
     * Validate the request.
129
     *
130
     * @param ServerRequestInterface $request
131
     * @param array                  &$tokens
132
     *
133
     * @return bool
134
     */
135
    private function validateRequest(ServerRequestInterface $request, array &$tokens)
136
    {
137
        $data = $request->getParsedBody();
138
139
        if (!isset($data[$this->formIndex]) || !isset($data[$this->formToken])) {
140
            return false;
141
        }
142
143
        $index = $data[$this->formIndex];
144
        $token = $data[$this->formToken];
145
146
        if (!isset($tokens[$index])) {
147
            return false;
148
        }
149
150
        $stored = $tokens[$index];
151
        unset($tokens[$index]);
152
153
        $lockTo = $request->getUri()->getPath();
154
155
        if (!Utils\Helpers::hashEquals($lockTo, $stored['lockTo'])) {
156
            return false;
157
        }
158
159
        $expected = self::encode(hash_hmac('sha256', ClientIp::getIp($request), base64_decode($stored['token']), true));
160
161
        return Utils\Helpers::hashEquals($token, $expected);
162
    }
163
164
    /**
165
     * Encode string with base64, but strip padding.
166
     * PHP base64_decode does not croak on that.
167
     *
168
     * @param string $value
169
     *
170
     * @return string
171
     */
172
    private static function encode($value)
173
    {
174
        return rtrim(base64_encode($value), '=');
175
    }
176
177
    /**
178
     * Return a random token.
179
     *
180
     * @param int $length The length of the random string that should be returned in bytes.
181
     *
182
     * @return string
183
     */
184
    private function randomToken($length = 32)
185
    {
186
        if (!isset($length) || intval($length) <= 8) {
187
            $length = 32;
188
        }
189
        if (function_exists('random_bytes')) {
190
            return random_bytes($length);
191
        }
192
        if (function_exists('mcrypt_create_iv')) {
193
            return mcrypt_create_iv($length, MCRYPT_DEV_URANDOM);
194
        }
195
        if (function_exists('openssl_random_pseudo_bytes')) {
196
            return openssl_random_pseudo_bytes($length);
197
        }
198
        return @crypt(uniqid());
199
    }
200
}
201