12
12
namespace Symfony \Component \Security \Csrf \TokenStorage ;
13
13
14
14
use Symfony \Component \HttpFoundation \Cookie ;
15
- use Symfony \Component \HttpFoundation \ParameterBag ;
16
15
use Symfony \Component \Security \Core \Exception \InvalidArgumentException ;
17
16
use Symfony \Component \Security \Csrf \Exception \TokenNotFoundException ;
18
17
@@ -31,22 +30,22 @@ class CookieTokenStorage implements TokenStorageInterface
31
30
const COOKIE_DELIMITER = '_ ' ;
32
31
33
32
/**
34
- * @var array
33
+ * @var array A map of tokens to be written in the response
35
34
*/
36
35
private $ transientTokens = array ();
37
36
38
37
/**
39
- * @var array
38
+ * @var array A map of tokens extracted from cookies and verified
40
39
*/
41
- private $ resolvedTokens = array ();
40
+ private $ extractedTokens = array ();
42
41
43
42
/**
44
43
* @var array
45
44
*/
46
- private $ refreshTokens = array ();
45
+ private $ nonces = array ();
47
46
48
47
/**
49
- * @var ParameterBag
48
+ * @var array
50
49
*/
51
50
private $ cookies ;
52
51
@@ -66,14 +65,14 @@ class CookieTokenStorage implements TokenStorageInterface
66
65
private $ ttl ;
67
66
68
67
/**
69
- * @param ParameterBag $cookies
70
- * @param bool $secure
71
- * @param string $secret
72
- * @param int $ttl
68
+ * @param string $cookies The raw HTTP Cookie header
69
+ * @param bool $secure
70
+ * @param string $secret
71
+ * @param int $ttl
73
72
*/
74
- public function __construct (ParameterBag $ cookies , $ secure , $ secret , $ ttl = null )
73
+ public function __construct ($ cookies , $ secure , $ secret , $ ttl = null )
75
74
{
76
- $ this ->cookies = $ cookies ;
75
+ $ this ->cookies = self :: parseCookieHeader ( $ cookies) ;
77
76
$ this ->secure = (bool ) $ secure ;
78
77
$ this ->secret = (string ) $ secret ;
79
78
$ this ->ttl = $ ttl === null ? 60 * 60 : (int ) $ ttl ;
@@ -120,7 +119,10 @@ public function setToken($tokenId, $token)
120
119
throw new InvalidArgumentException ('Empty tokens are not allowed ' );
121
120
}
122
121
123
- $ this ->updateToken ($ tokenId , $ token );
122
+ // we need to resolve the token first to record the nonces
123
+ $ this ->resolveToken ($ tokenId );
124
+
125
+ $ this ->transientTokens [$ tokenId ] = $ token ;
124
126
}
125
127
126
128
/**
@@ -130,106 +132,119 @@ public function removeToken($tokenId)
130
132
{
131
133
$ token = $ this ->resolveToken ($ tokenId );
132
134
133
- $ this ->updateToken ( $ tokenId, '' ) ;
135
+ $ this ->transientTokens [ $ tokenId] = '' ;
134
136
135
137
return '' === $ token ? null : $ token ;
136
138
}
137
139
138
140
/**
139
- * @return array
141
+ * @return Cookie[]
140
142
*/
141
143
public function createCookies ()
142
144
{
143
145
$ cookies = array ();
144
146
145
147
foreach ($ this ->transientTokens as $ tokenId => $ token ) {
146
- // FIXME empty tokens are handled by the http foundations cookie class
147
- // and are recognized as a "delete" cookie
148
- // the problem is the that the value of deleted cookies get set to
149
- // the string "deleted" and not the empty string
150
- $ cookies [] = $ this ->createTokenCookie ($ tokenId , $ token );
151
- $ cookies [] = $ this ->createVerificationCookie ($ tokenId , $ token );
152
- }
153
-
154
- foreach ($ this ->refreshTokens as $ tokenId => $ token ) {
155
- if (isset ($ this ->transientTokens [$ tokenId ])) {
156
- continue ;
148
+ if (isset ($ this ->nonces [$ tokenId ])) {
149
+ foreach (array_keys ($ this ->nonces [$ tokenId ]) as $ nonce ) {
150
+ $ cookies [] = $ this ->createDeleteCookie ($ tokenId , $ nonce );
151
+ }
157
152
}
158
153
159
- $ cookies [] = $ this ->createVerificationCookie ($ tokenId , $ token );
154
+ if ($ token !== '' ) {
155
+ $ cookies [] = $ this ->createCookie ($ tokenId , $ token );
156
+ }
160
157
}
161
158
162
159
return $ cookies ;
163
160
}
164
161
165
162
/**
166
163
* @param string $tokenId
167
- * @param bool $excludeTransient
168
164
*
169
165
* @return string
170
166
*/
171
- protected function resolveToken ($ tokenId, $ excludeTransient = false )
167
+ protected function resolveToken ($ tokenId )
172
168
{
173
- if (! $ excludeTransient && isset ($ this ->transientTokens [$ tokenId ])) {
169
+ if (isset ($ this ->transientTokens [$ tokenId ])) {
174
170
return $ this ->transientTokens [$ tokenId ];
175
171
}
176
172
177
- if (isset ($ this ->resolvedTokens [$ tokenId ])) {
178
- return $ this ->resolvedTokens [$ tokenId ];
173
+ if (isset ($ this ->extractedTokens [$ tokenId ])) {
174
+ return $ this ->extractedTokens [$ tokenId ];
179
175
}
180
176
181
- $ this ->resolvedTokens [$ tokenId ] = '' ;
177
+ $ this ->extractedTokens [$ tokenId ] = '' ;
182
178
183
- $ token = $ this ->getTokenCookieValue ($ tokenId );
184
- if ('' === $ token ) {
179
+ $ prefix = $ this ->generateCookieName ($ tokenId , '' );
180
+ $ prefixLength = strlen ($ prefix );
181
+ $ cookies = $ this ->findCookiesByPrefix ($ prefix );
182
+
183
+ // record the nonces used, so we can delete all obsolete cookies of this
184
+ // token id, if necessary
185
+ foreach ($ cookies as $ cookie ) {
186
+ $ this ->nonces [$ tokenId ][substr ($ cookie [0 ], $ prefixLength )] = true ;
187
+ }
188
+
189
+ // if there is more than one cookie for the prefix, we get cookie tossed maybe
190
+ if (count ($ cookies ) != 1 ) {
185
191
return '' ;
186
192
}
187
193
188
- $ parts = explode (self ::COOKIE_DELIMITER , $ this -> getVerificationCookieValue ( $ tokenId ), 2 );
189
- if (count ($ parts ) != 2 ) {
194
+ $ parts = explode (self ::COOKIE_DELIMITER , $ cookies [ 0 ][ 1 ], 3 );
195
+ if (count ($ parts ) != 3 ) {
190
196
return '' ;
191
197
}
198
+ list ($ expires , $ signature , $ token ) = $ parts ;
192
199
193
- list ( $ expires , $ hash ) = $ parts ;
200
+ // expired token
194
201
$ time = time ();
195
202
if (!ctype_digit ($ expires ) || $ expires < $ time ) {
196
203
return '' ;
197
204
}
198
- if (!hash_equals ($ this ->generateVerificationHash ($ tokenId , $ token , $ expires ), $ hash )) {
205
+
206
+ // invalid signature
207
+ $ nonce = substr ($ cookies [0 ][0 ], $ prefixLength );
208
+ if (!hash_equals ($ this ->generateSignature ($ tokenId , $ token , $ expires , $ nonce ), $ signature )) {
199
209
return '' ;
200
210
}
201
211
202
212
$ time += $ this ->ttl / 2 ;
203
213
if ($ expires < $ time ) {
204
- $ this ->refreshTokens [$ tokenId ] = $ token ;
214
+ $ this ->transientTokens [$ tokenId ] = $ token ;
205
215
}
206
216
207
- return $ this ->resolvedTokens [$ tokenId ] = $ token ;
217
+ return $ this ->extractedTokens [$ tokenId ] = $ token ;
208
218
}
209
219
210
220
/**
211
- * @param string $tokenId
212
- * @param string $token
221
+ * @param string $prefix
222
+ *
223
+ * @return array
213
224
*/
214
- protected function updateToken ( $ tokenId , $ token )
225
+ protected function findCookiesByPrefix ( $ prefix )
215
226
{
216
- if ($ token === $ this ->resolveToken ($ tokenId , true )) {
217
- unset($ this ->transientTokens [$ tokenId ]);
218
- } else {
219
- $ this ->transientTokens [$ tokenId ] = $ token ;
227
+ $ cookies = array ();
228
+ foreach ($ this ->cookies as $ cookie ) {
229
+ if (0 === strpos ($ cookie [0 ], $ prefix )) {
230
+ $ cookies [] = $ cookie ;
231
+ }
220
232
}
233
+
234
+ return $ cookies ;
221
235
}
222
236
223
237
/**
224
238
* @param string $tokenId
239
+ * @param string $nonce
225
240
*
226
- * @return string
241
+ * @return Cookie
227
242
*/
228
- protected function getTokenCookieValue ($ tokenId )
243
+ protected function createDeleteCookie ($ tokenId, $ nonce )
229
244
{
230
- $ name = $ this ->generateTokenCookieName ($ tokenId );
245
+ $ name = $ this ->generateCookieName ($ tokenId, $ nonce );
231
246
232
- return $ this -> cookies -> get ($ name , '' );
247
+ return new Cookie ($ name , '' , 0 , null , null , $ this -> secure , true );
233
248
}
234
249
235
250
/**
@@ -238,88 +253,82 @@ protected function getTokenCookieValue($tokenId)
238
253
*
239
254
* @return Cookie
240
255
*/
241
- protected function createTokenCookie ($ tokenId , $ token )
256
+ protected function createCookie ($ tokenId , $ token )
242
257
{
243
- $ name = $ this ->generateTokenCookieName ($ tokenId );
258
+ $ expires = time () + $ this ->ttl ;
259
+ $ nonce = self ::encodeBase64Url (random_bytes (6 ));
260
+ $ signature = $ this ->generateSignature ($ tokenId , $ token , $ expires , $ nonce );
244
261
245
- return new Cookie ($ name , $ token , 0 , null , null , $ this ->secure , false );
246
- }
262
+ $ this ->nonces [$ tokenId ][$ nonce ] = true ;
247
263
248
- /**
249
- * @param string $tokenId
250
- *
251
- * @return string
252
- */
253
- protected function generateTokenCookieName ($ tokenId )
254
- {
255
- $ encodedTokenId = rtrim (strtr (base64_encode ($ tokenId ), '+/ ' , '-_ ' ), '= ' );
264
+ $ name = $ this ->generateCookieName ($ tokenId , $ nonce );
265
+ $ value = $ expires.self ::COOKIE_DELIMITER .$ signature .self ::COOKIE_DELIMITER .$ token ;
256
266
257
- return sprintf ( ' _csrf/%s/%s ' , $ this -> secure ? ' secure ' : ' insecure ' , $ encodedTokenId );
267
+ return new Cookie ( $ name , $ value , 0 , null , null , $ this -> secure , true );
258
268
}
259
269
260
270
/**
261
271
* @param string $tokenId
272
+ * @param string $nonce
262
273
*
263
274
* @return string
264
275
*/
265
- protected function getVerificationCookieValue ($ tokenId )
276
+ protected function generateCookieName ($ tokenId, $ nonce )
266
277
{
267
- $ name = $ this ->generateVerificationCookieName ($ tokenId );
268
-
269
- return $ this ->cookies ->get ($ name , '' );
278
+ return sprintf (
279
+ '_csrf_%s_%s_%s ' ,
280
+ (int ) $ this ->secure ,
281
+ self ::encodeBase64Url ($ tokenId ),
282
+ $ nonce
283
+ );
270
284
}
271
285
272
286
/**
273
287
* @param string $tokenId
274
288
* @param string $token
275
- *
276
- * @return Cookie
277
- */
278
- protected function createVerificationCookie ($ tokenId , $ token )
279
- {
280
- $ name = $ this ->generateVerificationCookieName ($ tokenId );
281
- $ value = $ this ->generateVerificationCookieValue ($ tokenId , $ token );
282
-
283
- return new Cookie ($ name , $ value , 0 , null , null , $ this ->secure , true );
284
- }
285
-
286
- /**
287
- * @param string $tokenId
289
+ * @param int $expires
290
+ * @param string $nonce
288
291
*
289
292
* @return string
290
293
*/
291
- protected function generateVerificationCookieName ($ tokenId )
294
+ protected function generateSignature ($ tokenId, $ token , $ expires , $ nonce )
292
295
{
293
- return $ this ->generateTokenCookieName ( $ tokenId ). ' /verify ' ;
296
+ return hash_hmac ( ' sha256 ' , $ tokenId . $ token . $ expires . $ nonce . $ this ->secure , $ this -> secret ) ;
294
297
}
295
298
296
299
/**
297
- * @param string $tokenId
298
- * @param string $token
300
+ * @param string $header
299
301
*
300
- * @return string
302
+ * @return array
301
303
*/
302
- protected function generateVerificationCookieValue ( $ tokenId , $ token )
304
+ public static function parseCookieHeader ( $ header )
303
305
{
304
- if ('' === $ token ) {
305
- return '' ;
306
+ $ header = trim ((string ) $ header );
307
+ if ('' === $ header ) {
308
+ return array ();
306
309
}
307
310
308
- $ expires = time () + $ this ->ttl ;
309
- $ hash = $ this ->generateVerificationHash ($ tokenId , $ token , $ expires );
311
+ $ cookies = array ();
312
+ foreach (explode ('; ' , $ header ) as $ cookie ) {
313
+ if (false === strpos ($ cookie , '= ' )) {
314
+ continue ;
315
+ }
316
+
317
+ $ cookies [] = array_map (function ($ item ) {
318
+ return urldecode (trim ($ item , ' " ' ));
319
+ }, explode ('= ' , $ cookie , 2 ));
320
+ }
310
321
311
- return $ expires . self :: COOKIE_DELIMITER . $ hash ;
322
+ return $ cookies ;
312
323
}
313
324
314
325
/**
315
- * @param string $tokenId
316
- * @param string $token
317
- * @param int $expires
326
+ * @param string $data
318
327
*
319
328
* @return string
320
329
*/
321
- protected function generateVerificationHash ( $ tokenId , $ token , $ expires )
330
+ public static function encodeBase64Url ( $ data )
322
331
{
323
- return hash_hmac ( ' sha256 ' , $ tokenId . $ token . $ expires , $ this -> secret );
332
+ return rtrim ( strtr ( base64_encode ( $ data ), ' +/ ' , ' -_ ' ), ' = ' );
324
333
}
325
334
}
0 commit comments