Issues (718)

src/Charcoal/Admin/AdminAction.php (5 issues)

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
false of type false is incompatible with the type string expected by parameter $cache_limiter of session_cache_limiter(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

93
            session_cache_limiter(/** @scrutinizer ignore-type */ false);
Loading history...
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
The condition $this->authRequired() === false is always false.
Loading history...
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 ignore-call  annotation

137
            $this->setData($request->/** @scrutinizer ignore-call */ getParams($keys));
Loading history...
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
Bug Best Practice introduced by
The expression return $recaptcha['private_key'] also could return the type Charcoal\Config\AbstractConfig|mixed which is incompatible with the documented return type null|string.
Loading history...
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 ignore-call  annotation

350
        /** @scrutinizer ignore-call */ 
351
        $token = $request->getParam('g-recaptcha-response', false);
Loading history...
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