Chris@0
|
1 <?php
|
Chris@0
|
2
|
Chris@0
|
3 namespace Drupal\Tests\rest\Functional;
|
Chris@0
|
4
|
Chris@0
|
5 use Drupal\Core\Url;
|
Chris@0
|
6 use GuzzleHttp\RequestOptions;
|
Chris@0
|
7 use Psr\Http\Message\ResponseInterface;
|
Chris@0
|
8
|
Chris@0
|
9 /**
|
Chris@0
|
10 * Trait for ResourceTestBase subclasses testing $auth=cookie.
|
Chris@0
|
11 *
|
Chris@0
|
12 * Characteristics:
|
Chris@0
|
13 * - After performing a valid "log in" request, the server responds with a 2xx
|
Chris@0
|
14 * status code and a 'Set-Cookie' response header. This cookie is what
|
Chris@0
|
15 * continues to identify the user in subsequent requests.
|
Chris@0
|
16 * - When accessing a URI that requires authentication without being
|
Chris@0
|
17 * authenticated, a standard 403 response must be sent.
|
Chris@0
|
18 * - Because of the reliance on cookies, and the fact that user agents send
|
Chris@0
|
19 * cookies with every request, this is vulnerable to CSRF attacks. To mitigate
|
Chris@0
|
20 * this, the response for the "log in" request contains a CSRF token that must
|
Chris@0
|
21 * be sent with every unsafe (POST/PATCH/DELETE) HTTP request.
|
Chris@0
|
22 */
|
Chris@0
|
23 trait CookieResourceTestTrait {
|
Chris@0
|
24
|
Chris@0
|
25 /**
|
Chris@0
|
26 * The session cookie.
|
Chris@0
|
27 *
|
Chris@0
|
28 * @see ::initAuthentication
|
Chris@0
|
29 *
|
Chris@0
|
30 * @var string
|
Chris@0
|
31 */
|
Chris@0
|
32 protected $sessionCookie;
|
Chris@0
|
33
|
Chris@0
|
34 /**
|
Chris@0
|
35 * The CSRF token.
|
Chris@0
|
36 *
|
Chris@0
|
37 * @see ::initAuthentication
|
Chris@0
|
38 *
|
Chris@0
|
39 * @var string
|
Chris@0
|
40 */
|
Chris@0
|
41 protected $csrfToken;
|
Chris@0
|
42
|
Chris@0
|
43 /**
|
Chris@0
|
44 * The logout token.
|
Chris@0
|
45 *
|
Chris@0
|
46 * @see ::initAuthentication
|
Chris@0
|
47 *
|
Chris@0
|
48 * @var string
|
Chris@0
|
49 */
|
Chris@0
|
50 protected $logoutToken;
|
Chris@0
|
51
|
Chris@0
|
52 /**
|
Chris@0
|
53 * {@inheritdoc}
|
Chris@0
|
54 */
|
Chris@0
|
55 protected function initAuthentication() {
|
Chris@0
|
56 $user_login_url = Url::fromRoute('user.login.http')
|
Chris@0
|
57 ->setRouteParameter('_format', static::$format);
|
Chris@0
|
58
|
Chris@0
|
59 $request_body = [
|
Chris@0
|
60 'name' => $this->account->name->value,
|
Chris@0
|
61 'pass' => $this->account->passRaw,
|
Chris@0
|
62 ];
|
Chris@0
|
63
|
Chris@14
|
64 $request_options[RequestOptions::BODY] = $this->serializer->encode($request_body, static::$format);
|
Chris@0
|
65 $request_options[RequestOptions::HEADERS] = [
|
Chris@0
|
66 'Content-Type' => static::$mimeType,
|
Chris@0
|
67 ];
|
Chris@0
|
68 $response = $this->request('POST', $user_login_url, $request_options);
|
Chris@0
|
69
|
Chris@0
|
70 // Parse and store the session cookie.
|
Chris@0
|
71 $this->sessionCookie = explode(';', $response->getHeader('Set-Cookie')[0], 2)[0];
|
Chris@0
|
72
|
Chris@0
|
73 // Parse and store the CSRF token and logout token.
|
Chris@0
|
74 $data = $this->serializer->decode((string) $response->getBody(), static::$format);
|
Chris@0
|
75 $this->csrfToken = $data['csrf_token'];
|
Chris@0
|
76 $this->logoutToken = $data['logout_token'];
|
Chris@0
|
77 }
|
Chris@0
|
78
|
Chris@0
|
79 /**
|
Chris@0
|
80 * {@inheritdoc}
|
Chris@0
|
81 */
|
Chris@0
|
82 protected function getAuthenticationRequestOptions($method) {
|
Chris@0
|
83 $request_options[RequestOptions::HEADERS]['Cookie'] = $this->sessionCookie;
|
Chris@0
|
84 // @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html
|
Chris@0
|
85 if (!in_array($method, ['HEAD', 'GET', 'OPTIONS', 'TRACE'])) {
|
Chris@0
|
86 $request_options[RequestOptions::HEADERS]['X-CSRF-Token'] = $this->csrfToken;
|
Chris@0
|
87 }
|
Chris@0
|
88 return $request_options;
|
Chris@0
|
89 }
|
Chris@0
|
90
|
Chris@0
|
91 /**
|
Chris@0
|
92 * {@inheritdoc}
|
Chris@0
|
93 */
|
Chris@14
|
94 protected function assertResponseWhenMissingAuthentication($method, ResponseInterface $response) {
|
Chris@0
|
95 // Requests needing cookie authentication but missing it results in a 403
|
Chris@0
|
96 // response. The cookie authentication mechanism sets no response message.
|
Chris@14
|
97 // Hence, effectively, this is just the 403 response that one gets as the
|
Chris@14
|
98 // anonymous user trying to access a certain REST resource.
|
Chris@14
|
99 // @see \Drupal\user\Authentication\Provider\Cookie
|
Chris@0
|
100 // @todo https://www.drupal.org/node/2847623
|
Chris@14
|
101 if ($method === 'GET') {
|
Chris@17
|
102 $expected_cookie_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability()
|
Chris@17
|
103 // @see \Drupal\Core\EventSubscriber\AnonymousUserResponseSubscriber::onRespond()
|
Chris@17
|
104 ->addCacheableDependency($this->getExpectedUnauthorizedEntityAccessCacheability(FALSE));
|
Chris@14
|
105 // - \Drupal\Core\EventSubscriber\AnonymousUserResponseSubscriber applies
|
Chris@14
|
106 // to cacheable anonymous responses: it updates their cacheability.
|
Chris@14
|
107 // - A 403 response to a GET request is cacheable.
|
Chris@14
|
108 // Therefore we must update our cacheability expectations accordingly.
|
Chris@14
|
109 if (in_array('user.permissions', $expected_cookie_403_cacheability->getCacheContexts(), TRUE)) {
|
Chris@14
|
110 $expected_cookie_403_cacheability->addCacheTags(['config:user.role.anonymous']);
|
Chris@14
|
111 }
|
Chris@14
|
112 // @todo Fix \Drupal\block\BlockAccessControlHandler::mergeCacheabilityFromConditions() in https://www.drupal.org/node/2867881
|
Chris@14
|
113 if (static::$entityTypeId === 'block') {
|
Chris@14
|
114 $expected_cookie_403_cacheability->setCacheTags(str_replace('user:2', 'user:0', $expected_cookie_403_cacheability->getCacheTags()));
|
Chris@14
|
115 }
|
Chris@17
|
116 $this->assertResourceErrorResponse(403, FALSE, $response, $expected_cookie_403_cacheability->getCacheTags(), $expected_cookie_403_cacheability->getCacheContexts(), 'MISS', FALSE);
|
Chris@14
|
117 }
|
Chris@14
|
118 else {
|
Chris@14
|
119 $this->assertResourceErrorResponse(403, FALSE, $response);
|
Chris@14
|
120 }
|
Chris@0
|
121 }
|
Chris@0
|
122
|
Chris@0
|
123 /**
|
Chris@0
|
124 * {@inheritdoc}
|
Chris@0
|
125 */
|
Chris@0
|
126 protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options) {
|
Chris@0
|
127 // X-CSRF-Token request header is unnecessary for safe and side effect-free
|
Chris@0
|
128 // HTTP methods. No need for additional assertions.
|
Chris@0
|
129 // @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html
|
Chris@0
|
130 if (in_array($method, ['HEAD', 'GET', 'OPTIONS', 'TRACE'])) {
|
Chris@0
|
131 return;
|
Chris@0
|
132 }
|
Chris@0
|
133
|
Chris@0
|
134 unset($request_options[RequestOptions::HEADERS]['X-CSRF-Token']);
|
Chris@0
|
135
|
Chris@0
|
136 // DX: 403 when missing X-CSRF-Token request header.
|
Chris@0
|
137 $response = $this->request($method, $url, $request_options);
|
Chris@0
|
138 $this->assertResourceErrorResponse(403, 'X-CSRF-Token request header is missing', $response);
|
Chris@0
|
139
|
Chris@0
|
140 $request_options[RequestOptions::HEADERS]['X-CSRF-Token'] = 'this-is-not-the-token-you-are-looking-for';
|
Chris@0
|
141
|
Chris@0
|
142 // DX: 403 when invalid X-CSRF-Token request header.
|
Chris@0
|
143 $response = $this->request($method, $url, $request_options);
|
Chris@0
|
144 $this->assertResourceErrorResponse(403, 'X-CSRF-Token request header is invalid', $response);
|
Chris@0
|
145
|
Chris@0
|
146 $request_options[RequestOptions::HEADERS]['X-CSRF-Token'] = $this->csrfToken;
|
Chris@0
|
147 }
|
Chris@0
|
148
|
Chris@0
|
149 }
|