1 | <?php |
||||||
2 | |||||||
3 | namespace Charcoal\Admin; |
||||||
4 | |||||||
5 | use RuntimeException; |
||||||
6 | |||||||
7 | // From PSR-7 |
||||||
8 | use Psr\Http\Message\RequestInterface; |
||||||
9 | use Psr\Http\Message\ResponseInterface; |
||||||
10 | |||||||
11 | // From Pimple |
||||||
12 | use Pimple\Container; |
||||||
13 | |||||||
14 | // From 'charcoal-factory' |
||||||
15 | use Charcoal\Factory\FactoryInterface; |
||||||
16 | |||||||
17 | // From 'charcoal-user' |
||||||
18 | use Charcoal\User\AuthAwareInterface; |
||||||
19 | use Charcoal\User\AuthAwareTrait; |
||||||
20 | |||||||
21 | // From 'charcoal-translator' |
||||||
22 | use Charcoal\Translator\Translation; |
||||||
23 | use Charcoal\Translator\TranslatorAwareTrait; |
||||||
24 | |||||||
25 | // From 'charcoal-app' |
||||||
26 | use Charcoal\App\Action\AbstractAction; |
||||||
27 | |||||||
28 | // From 'charcoal-admin' |
||||||
29 | use Charcoal\Admin\Ui\FeedbackContainerTrait; |
||||||
30 | use Charcoal\Admin\Support\AdminTrait; |
||||||
31 | use Charcoal\Admin\Support\BaseUrlTrait; |
||||||
32 | use Charcoal\Admin\Support\SecurityTrait; |
||||||
33 | |||||||
34 | /** |
||||||
35 | * The base class for the `admin` Actions. |
||||||
36 | */ |
||||||
37 | abstract class AdminAction extends AbstractAction implements |
||||||
38 | AuthAwareInterface |
||||||
39 | { |
||||||
40 | use AdminTrait; |
||||||
41 | use AuthAwareTrait; |
||||||
42 | use BaseUrlTrait; |
||||||
43 | use FeedbackContainerTrait; |
||||||
44 | use SecurityTrait; |
||||||
45 | use TranslatorAwareTrait; |
||||||
46 | |||||||
47 | const GOOGLE_RECAPTCHA_SERVER_URL = 'https://www.google.com/recaptcha/api/siteverify'; |
||||||
48 | |||||||
49 | /** |
||||||
50 | * The name of the project. |
||||||
51 | * |
||||||
52 | * @var \Charcoal\Translator\Translation|string|null |
||||||
53 | */ |
||||||
54 | private $siteName; |
||||||
55 | |||||||
56 | /** |
||||||
57 | * Store the user response token from the last validation by Google reCAPTCHA API. |
||||||
58 | * |
||||||
59 | * @var string|null |
||||||
60 | */ |
||||||
61 | private $recaptchaLastToken; |
||||||
62 | |||||||
63 | /** |
||||||
64 | * Store the result from the last validation by Google reCAPTCHA API. |
||||||
65 | * |
||||||
66 | * @var array|null |
||||||
67 | */ |
||||||
68 | private $recaptchaLastResult; |
||||||
69 | |||||||
70 | /** |
||||||
71 | * Store the model factory. |
||||||
72 | * |
||||||
73 | * @var FactoryInterface $modelFactory |
||||||
74 | */ |
||||||
75 | private $modelFactory; |
||||||
76 | |||||||
77 | /** |
||||||
78 | * Action's init method is called automatically from `charcoal-app`'s Action Route. |
||||||
79 | * |
||||||
80 | * For admin actions, initializations is: |
||||||
81 | * |
||||||
82 | * - to start a session, if necessary |
||||||
83 | * - to authenticate |
||||||
84 | * - to initialize the action data with the PSR Request object |
||||||
85 | * |
||||||
86 | * @param RequestInterface $request The request to initialize. |
||||||
87 | * @return boolean |
||||||
88 | * @see \Charcoal\App\Route\TemplateRoute::__invoke() |
||||||
89 | */ |
||||||
90 | public function init(RequestInterface $request) |
||||||
91 | { |
||||||
92 | if (!session_id()) { |
||||||
93 | session_cache_limiter(false); |
||||||
0 ignored issues
–
show
Bug
introduced
by
![]() |
|||||||
94 | session_start(); |
||||||
95 | } |
||||||
96 | |||||||
97 | $this->setDataFromRequest($request); |
||||||
98 | $this->authRedirect($request); |
||||||
99 | |||||||
100 | return parent::init($request); |
||||||
101 | } |
||||||
102 | |||||||
103 | /** |
||||||
104 | * Determine if the current user is authenticated, if not redirect them to the login page. |
||||||
105 | * |
||||||
106 | * @todo Move auth-check and redirection to a middleware or dedicated admin route. |
||||||
107 | * @param RequestInterface $request The request to initialize. |
||||||
108 | * @return void |
||||||
109 | */ |
||||||
110 | protected function authRedirect(RequestInterface $request) |
||||||
111 | { |
||||||
112 | unset($request); |
||||||
113 | // Test if authentication is required. |
||||||
114 | if ($this->authRequired() === false) { |
||||||
0 ignored issues
–
show
|
|||||||
115 | return; |
||||||
116 | } |
||||||
117 | |||||||
118 | // Test if user is authorized to access this controller |
||||||
119 | if ($this->isAuthorized() === true) { |
||||||
120 | return; |
||||||
121 | } |
||||||
122 | |||||||
123 | header('HTTP/1.0 403 Forbidden'); |
||||||
124 | exit; |
||||||
125 | } |
||||||
126 | |||||||
127 | /** |
||||||
128 | * Sets the action data from a PSR Request object. |
||||||
129 | * |
||||||
130 | * @param RequestInterface $request A PSR-7 compatible Request instance. |
||||||
131 | * @return self |
||||||
132 | */ |
||||||
133 | protected function setDataFromRequest(RequestInterface $request) |
||||||
134 | { |
||||||
135 | $keys = $this->validDataFromRequest(); |
||||||
136 | if (!empty($keys)) { |
||||||
137 | $this->setData($request->getParams($keys)); |
||||||
0 ignored issues
–
show
The method
getParams() does not exist on Psr\Http\Message\RequestInterface . It seems like you code against a sub-type of Psr\Http\Message\RequestInterface such as Slim\Http\Request .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
138 | } |
||||||
139 | |||||||
140 | return $this; |
||||||
141 | } |
||||||
142 | |||||||
143 | /** |
||||||
144 | * Retrieve the list of parameters to extract from the HTTP request. |
||||||
145 | * |
||||||
146 | * @return string[] |
||||||
147 | */ |
||||||
148 | protected function validDataFromRequest() |
||||||
149 | { |
||||||
150 | return [ |
||||||
151 | // HTTP Handling |
||||||
152 | 'next_url', |
||||||
153 | ]; |
||||||
154 | } |
||||||
155 | |||||||
156 | /** |
||||||
157 | * Retrieve the name of the project. |
||||||
158 | * |
||||||
159 | * @return Translation|string|null |
||||||
160 | */ |
||||||
161 | public function siteName() |
||||||
162 | { |
||||||
163 | return $this->siteName; |
||||||
164 | } |
||||||
165 | |||||||
166 | /** |
||||||
167 | * Default response stub. |
||||||
168 | * |
||||||
169 | * @return array |
||||||
170 | */ |
||||||
171 | public function results() |
||||||
172 | { |
||||||
173 | $results = [ |
||||||
174 | 'success' => $this->success(), |
||||||
175 | 'next_url' => $this->redirectUrl(), |
||||||
176 | 'feedbacks' => $this->feedbacks() |
||||||
177 | ]; |
||||||
178 | return $results; |
||||||
179 | } |
||||||
180 | |||||||
181 | /** |
||||||
182 | * Set common dependencies used in all admin actions. |
||||||
183 | * |
||||||
184 | * @param Container $container DI Container. |
||||||
185 | * @return void |
||||||
186 | */ |
||||||
187 | protected function setDependencies(Container $container) |
||||||
188 | { |
||||||
189 | parent::setDependencies($container); |
||||||
190 | |||||||
191 | // Satisfies TranslatorAwareTrait dependencies |
||||||
192 | $this->setTranslator($container['translator']); |
||||||
193 | |||||||
194 | // Satisfies AuthAwareInterface + SecurityTrait dependencies |
||||||
195 | $this->setAuthenticator($container['admin/authenticator']); |
||||||
196 | $this->setAuthorizer($container['admin/authorizer']); |
||||||
197 | |||||||
198 | // Satisfies AdminTrait dependencies |
||||||
199 | $this->setDebug($container['config']); |
||||||
200 | $this->setAppConfig($container['config']); |
||||||
201 | $this->setAdminConfig($container['admin/config']); |
||||||
202 | |||||||
203 | // Satisfies BaseUrlTrait dependencies |
||||||
204 | $this->setBaseUrl($container['base-url']); |
||||||
205 | $this->setAdminUrl($container['admin/base-url']); |
||||||
206 | |||||||
207 | |||||||
208 | // Satisfies AdminAction dependencies |
||||||
209 | $this->setSiteName($container['config']['project_name']); |
||||||
210 | $this->setModelFactory($container['model/factory']); |
||||||
211 | } |
||||||
212 | |||||||
213 | /** |
||||||
214 | * @param FactoryInterface $factory The factory used to create models. |
||||||
215 | * @return void |
||||||
216 | */ |
||||||
217 | protected function setModelFactory(FactoryInterface $factory) |
||||||
218 | { |
||||||
219 | $this->modelFactory = $factory; |
||||||
220 | } |
||||||
221 | |||||||
222 | /** |
||||||
223 | * @return FactoryInterface The model factory. |
||||||
224 | */ |
||||||
225 | protected function modelFactory() |
||||||
226 | { |
||||||
227 | return $this->modelFactory; |
||||||
228 | } |
||||||
229 | |||||||
230 | /** |
||||||
231 | * Set the name of the project. |
||||||
232 | * |
||||||
233 | * @param string $name Name of the project. |
||||||
234 | * @return AdminAction Chainable |
||||||
235 | */ |
||||||
236 | protected function setSiteName($name) |
||||||
237 | { |
||||||
238 | $this->siteName = $this->translator()->translation($name); |
||||||
239 | |||||||
240 | return $this; |
||||||
241 | } |
||||||
242 | |||||||
243 | /** |
||||||
244 | * Determine if a CAPTCHA test is available. |
||||||
245 | * |
||||||
246 | * For example, the "Login", "Lost Password", and "Reset Password" templates |
||||||
247 | * can render the CAPTCHA test. |
||||||
248 | * |
||||||
249 | * @see AdminTemplate::recaptchaEnabled() Duplicate |
||||||
250 | * @return boolean |
||||||
251 | */ |
||||||
252 | public function recaptchaEnabled() |
||||||
253 | { |
||||||
254 | $recaptcha = $this->apiConfig('google.recaptcha'); |
||||||
255 | |||||||
256 | if (empty($recaptcha) || (isset($recaptcha['active']) && $recaptcha['active'] === false)) { |
||||||
257 | return false; |
||||||
258 | } |
||||||
259 | |||||||
260 | return (!empty($recaptcha['public_key']) || !empty($recaptcha['key'])) && |
||||||
261 | (!empty($recaptcha['private_key']) || !empty($recaptcha['secret'])); |
||||||
262 | } |
||||||
263 | |||||||
264 | /** |
||||||
265 | * Retrieve the Google reCAPTCHA secret key. |
||||||
266 | * |
||||||
267 | * @throws RuntimeException If Google reCAPTCHA is required but not configured. |
||||||
268 | * @return string|null |
||||||
269 | */ |
||||||
270 | public function recaptchaSecretKey() |
||||||
271 | { |
||||||
272 | $recaptcha = $this->apiConfig('google.recaptcha'); |
||||||
273 | |||||||
274 | if (!empty($recaptcha['private_key'])) { |
||||||
275 | return $recaptcha['private_key']; |
||||||
0 ignored issues
–
show
|
|||||||
276 | } elseif (!empty($recaptcha['secret'])) { |
||||||
277 | return $recaptcha['secret']; |
||||||
278 | } |
||||||
279 | |||||||
280 | return null; |
||||||
281 | } |
||||||
282 | |||||||
283 | /** |
||||||
284 | * Retrieve the result from the last validation by Google reCAPTCHA API. |
||||||
285 | * |
||||||
286 | * @return array|null |
||||||
287 | */ |
||||||
288 | protected function getLastCaptchaValidation() |
||||||
289 | { |
||||||
290 | return $this->recaptchaLastResult; |
||||||
291 | } |
||||||
292 | |||||||
293 | /** |
||||||
294 | * Validate a Google reCAPTCHA user response. |
||||||
295 | * |
||||||
296 | * @todo {@link https://github.com/mcaskill/charcoal-recaptcha Implement CAPTCHA validation as a service}. |
||||||
297 | * @link https://developers.google.com/recaptcha/docs/verify |
||||||
298 | * @param string $token A user response token provided by reCAPTCHA. |
||||||
299 | * @throws RuntimeException If Google reCAPTCHA is not configured. |
||||||
300 | * @return boolean Returns TRUE if the user response is valid, FALSE if it is invalid. |
||||||
301 | */ |
||||||
302 | protected function validateCaptcha($token) |
||||||
303 | { |
||||||
304 | if (empty($token)) { |
||||||
305 | throw new RuntimeException('Google reCAPTCHA response parameter is invalid or malformed.'); |
||||||
306 | } |
||||||
307 | |||||||
308 | $secret = $this->recaptchaSecretKey(); |
||||||
309 | if (empty($secret)) { |
||||||
310 | throw new RuntimeException('Google reCAPTCHA [apis.google.recaptcha.private_key] is not configured.'); |
||||||
311 | } |
||||||
312 | |||||||
313 | $data = [ |
||||||
314 | 'secret' => $secret, |
||||||
315 | 'response' => $token, |
||||||
316 | ]; |
||||||
317 | |||||||
318 | if (isset($_SERVER['REMOTE_ADDR'])) { |
||||||
319 | $data['remoteip'] = $_SERVER['REMOTE_ADDR']; |
||||||
320 | } |
||||||
321 | |||||||
322 | $query = http_build_query($data); |
||||||
323 | $url = static::GOOGLE_RECAPTCHA_SERVER_URL.'?'.$query; |
||||||
324 | |||||||
325 | $this->logger->debug(sprintf('Verifying reCAPTCHA user response: %s', $url)); |
||||||
326 | |||||||
327 | /** |
||||||
328 | * @todo Use Guzzle |
||||||
329 | */ |
||||||
330 | $result = file_get_contents($url); |
||||||
331 | $result = (array)json_decode($result, true); |
||||||
332 | |||||||
333 | $this->recaptchaLastToken = $token; |
||||||
334 | $this->recaptchaLastResult = $result; |
||||||
335 | |||||||
336 | return isset($result['success']) && (bool)$result['success']; |
||||||
337 | } |
||||||
338 | |||||||
339 | /** |
||||||
340 | * Validate a Google reCAPTCHA user response from a PSR Request object. |
||||||
341 | * |
||||||
342 | * @param RequestInterface $request A PSR-7 compatible Request instance. |
||||||
343 | * @param ResponseInterface|null $response A PSR-7 compatible Response instance. |
||||||
344 | * If $response is provided and challenge fails, then it is replaced |
||||||
345 | * with a new Response object that represents a client error. |
||||||
346 | * @return boolean Returns TRUE if the user response is valid, FALSE if it is invalid. |
||||||
347 | */ |
||||||
348 | protected function validateCaptchaFromRequest(RequestInterface $request, ResponseInterface &$response = null) |
||||||
349 | { |
||||||
350 | $token = $request->getParam('g-recaptcha-response', false); |
||||||
0 ignored issues
–
show
The method
getParam() does not exist on Psr\Http\Message\RequestInterface . It seems like you code against a sub-type of Psr\Http\Message\RequestInterface such as Slim\Http\Request .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
351 | if (empty($token)) { |
||||||
352 | if ($response !== null) { |
||||||
353 | $this->addFeedback('error', $this->translator()->translate('Missing CAPTCHA response.')); |
||||||
354 | $this->setSuccess(false); |
||||||
355 | |||||||
356 | $response = $response->withStatus(400); |
||||||
357 | } |
||||||
358 | |||||||
359 | return false; |
||||||
360 | } |
||||||
361 | |||||||
362 | $result = $this->validateCaptcha($token); |
||||||
363 | if ($result === false && $response !== null) { |
||||||
364 | $this->addFeedback('error', $this->translator()->translate('Invalid or malformed CAPTCHA response.')); |
||||||
365 | $this->setSuccess(false); |
||||||
366 | |||||||
367 | $response = $response->withStatus(400); |
||||||
368 | } |
||||||
369 | |||||||
370 | return $result; |
||||||
371 | } |
||||||
372 | } |
||||||
373 |