Issues (902)

framework/captcha/CaptchaAction.php (8 issues)

1
<?php
2
/**
3
 * @link https://www.yiiframework.com/
4
 * @copyright Copyright (c) 2008 Yii Software LLC
5
 * @license https://www.yiiframework.com/license/
6
 */
7
8
namespace yii\captcha;
9
10
use Yii;
11
use yii\base\Action;
12
use yii\base\InvalidConfigException;
13
use yii\helpers\Url;
14
use yii\web\Response;
15
16
/**
17
 * CaptchaAction renders a CAPTCHA image.
18
 *
19
 * CaptchaAction is used together with [[Captcha]] and [[\yii\captcha\CaptchaValidator]]
20
 * to provide the [CAPTCHA](https://en.wikipedia.org/wiki/CAPTCHA) feature.
21
 *
22
 * By configuring the properties of CaptchaAction, you may customize the appearance of
23
 * the generated CAPTCHA images, such as the font color, the background color, etc.
24
 *
25
 * Note that CaptchaAction requires either GD2 extension or ImageMagick PHP extension.
26
 *
27
 * Using CAPTCHA involves the following steps:
28
 *
29
 * 1. Override [[\yii\web\Controller::actions()]] and register an action of class CaptchaAction with ID 'captcha'
30
 * 2. In the form model, declare an attribute to store user-entered verification code, and declare the attribute
31
 *    to be validated by the 'captcha' validator.
32
 * 3. In the controller view, insert a [[Captcha]] widget in the form.
33
 *
34
 * @property-read string $verifyCode The verification code.
35
 *
36
 * @author Qiang Xue <[email protected]>
37
 * @since 2.0
38
 */
39
class CaptchaAction extends Action
40
{
41
    /**
42
     * The name of the GET parameter indicating whether the CAPTCHA image should be regenerated.
43
     */
44
    const REFRESH_GET_VAR = 'refresh';
45
46
    /**
47
     * @var int how many times should the same CAPTCHA be displayed. Defaults to 3.
48
     * A value less than or equal to 0 means the test is unlimited (available since version 1.1.2).
49
     */
50
    public $testLimit = 3;
51
    /**
52
     * @var int the width of the generated CAPTCHA image. Defaults to 120.
53
     */
54
    public $width = 120;
55
    /**
56
     * @var int the height of the generated CAPTCHA image. Defaults to 50.
57
     */
58
    public $height = 50;
59
    /**
60
     * @var int padding around the text. Defaults to 2.
61
     */
62
    public $padding = 2;
63
    /**
64
     * @var int the background color. For example, 0x55FF00.
65
     * Defaults to 0xFFFFFF, meaning white color.
66
     */
67
    public $backColor = 0xFFFFFF;
68
    /**
69
     * @var int the font color. For example, 0x55FF00. Defaults to 0x2040A0 (blue color).
70
     */
71
    public $foreColor = 0x2040A0;
72
    /**
73
     * @var bool whether to use transparent background. Defaults to false.
74
     */
75
    public $transparent = false;
76
    /**
77
     * @var int the minimum length for randomly generated word. Defaults to 6.
78
     */
79
    public $minLength = 6;
80
    /**
81
     * @var int the maximum length for randomly generated word. Defaults to 7.
82
     */
83
    public $maxLength = 7;
84
    /**
85
     * @var int the offset between characters. Defaults to -2. You can adjust this property
86
     * in order to decrease or increase the readability of the captcha.
87
     */
88
    public $offset = -2;
89
    /**
90
     * @var string the TrueType font file. This can be either a file path or [path alias](guide:concept-aliases).
91
     */
92
    public $fontFile = '@yii/captcha/SpicyRice.ttf';
93
    /**
94
     * @var string|null the fixed verification code. When this property is set,
95
     * [[getVerifyCode()]] will always return the value of this property.
96
     * This is mainly used in automated tests where we want to be able to reproduce
97
     * the same verification code each time we run the tests.
98
     * If not set, it means the verification code will be randomly generated.
99
     */
100
    public $fixedVerifyCode;
101
    /**
102
     * @var string|null the rendering library to use. Currently supported only 'gd' and 'imagick'.
103
     * If not set, library will be determined automatically.
104
     * @since 2.0.7
105
     */
106
    public $imageLibrary;
107
108
109
    /**
110
     * Initializes the action.
111
     * @throws InvalidConfigException if the font file does not exist.
112
     */
113
    public function init()
114
    {
115
        $this->fontFile = Yii::getAlias($this->fontFile);
0 ignored issues
show
Documentation Bug introduced by
It seems like Yii::getAlias($this->fontFile) can also be of type false. However, the property $fontFile is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
116
        if (!is_file($this->fontFile)) {
0 ignored issues
show
It seems like $this->fontFile can also be of type false; however, parameter $filename of is_file() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

116
        if (!is_file(/** @scrutinizer ignore-type */ $this->fontFile)) {
Loading history...
117
            throw new InvalidConfigException("The font file does not exist: {$this->fontFile}");
118
        }
119
    }
120
121
    /**
122
     * Runs the action.
123
     */
124
    public function run()
125
    {
126
        if (Yii::$app->request->getQueryParam(self::REFRESH_GET_VAR) !== null) {
0 ignored issues
show
The method getQueryParam() does not exist on yii\console\Request. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

126
        if (Yii::$app->request->/** @scrutinizer ignore-call */ getQueryParam(self::REFRESH_GET_VAR) !== null) {
Loading history...
127
            // AJAX request for regenerating code
128
            $code = $this->getVerifyCode(true);
129
            Yii::$app->response->format = Response::FORMAT_JSON;
0 ignored issues
show
Bug Best Practice introduced by
The property format does not exist on yii\console\Response. Since you implemented __set, consider adding a @property annotation.
Loading history...
130
            return [
131
                'hash1' => $this->generateValidationHash($code),
132
                'hash2' => $this->generateValidationHash(strtolower($code)),
133
                // we add a random 'v' parameter so that FireFox can refresh the image
134
                // when src attribute of image tag is changed
135
                'url' => Url::to([$this->id, 'v' => uniqid('', true)]),
136
            ];
137
        }
138
139
        $this->setHttpHeaders();
140
        Yii::$app->response->format = Response::FORMAT_RAW;
141
142
        return $this->renderImage($this->getVerifyCode());
143
    }
144
145
    /**
146
     * Generates a hash code that can be used for client-side validation.
147
     * @param string $code the CAPTCHA code
148
     * @return string a hash code generated from the CAPTCHA code
149
     */
150
    public function generateValidationHash($code)
151
    {
152
        for ($h = 0, $i = strlen($code) - 1; $i >= 0; --$i) {
153
            $h += ord($code[$i]) << $i;
154
        }
155
156
        return $h;
157
    }
158
159
    /**
160
     * Gets the verification code.
161
     * @param bool $regenerate whether the verification code should be regenerated.
162
     * @return string the verification code.
163
     */
164
    public function getVerifyCode($regenerate = false)
165
    {
166
        if ($this->fixedVerifyCode !== null) {
167
            return $this->fixedVerifyCode;
168
        }
169
170
        $session = Yii::$app->getSession();
0 ignored issues
show
The method getSession() does not exist on yii\console\Application. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

170
        /** @scrutinizer ignore-call */ 
171
        $session = Yii::$app->getSession();
Loading history...
171
        $session->open();
172
        $name = $this->getSessionKey();
173
        if ($session[$name] === null || $regenerate) {
174
            $session[$name] = $this->generateVerifyCode();
175
            $session[$name . 'count'] = 1;
176
        }
177
178
        return $session[$name];
179
    }
180
181
    /**
182
     * Validates the input to see if it matches the generated code.
183
     * @param string $input user input
184
     * @param bool $caseSensitive whether the comparison should be case-sensitive
185
     * @return bool whether the input is valid
186
     */
187
    public function validate($input, $caseSensitive)
188
    {
189
        $code = $this->getVerifyCode();
190
        $valid = $caseSensitive ? ($input === $code) : strcasecmp($input, $code) === 0;
191
        $session = Yii::$app->getSession();
192
        $session->open();
193
        $name = $this->getSessionKey() . 'count';
194
        $session[$name] += 1;
195
        if ($valid || $session[$name] > $this->testLimit && $this->testLimit > 0) {
196
            $this->getVerifyCode(true);
197
        }
198
199
        return $valid;
200
    }
201
202
    /**
203
     * Generates a new verification code.
204
     * @return string the generated verification code
205
     */
206
    protected function generateVerifyCode()
207
    {
208
        if ($this->minLength > $this->maxLength) {
209
            $this->maxLength = $this->minLength;
210
        }
211
        if ($this->minLength < 3) {
212
            $this->minLength = 3;
213
        }
214
        if ($this->maxLength > 20) {
215
            $this->maxLength = 20;
216
        }
217
218
        $length = random_int($this->minLength, $this->maxLength);
219
220
        $letters = 'bcdfghjklmnpqrstvwxyz';
221
        $vowels = 'aeiou';
222
        $code = '';
223
        for ($i = 0; $i < $length; ++$i) {
224
            if ($i % 2 && random_int(0, 10) > 2 || !($i % 2) && random_int(0, 10) > 9) {
0 ignored issues
show
Consider adding parentheses for clarity. Current Interpretation: ($i % 2 && random_int(0,...& random_int(0, 10) > 9, Probably Intended Meaning: $i % 2 && (random_int(0,... random_int(0, 10) > 9)
Loading history...
225
                $code .= $vowels[random_int(0, 4)];
226
            } else {
227
                $code .= $letters[random_int(0, 20)];
228
            }
229
        }
230
231
        return $code;
232
    }
233
234
    /**
235
     * Returns the session variable name used to store verification code.
236
     * @return string the session variable name
237
     */
238
    protected function getSessionKey()
239
    {
240
        return '__captcha/' . $this->getUniqueId();
241
    }
242
243
    /**
244
     * Renders the CAPTCHA image.
245
     * @param string $code the verification code
246
     * @return string image contents
247
     * @throws InvalidConfigException if imageLibrary is not supported
248
     */
249
    protected function renderImage($code)
250
    {
251
        if (isset($this->imageLibrary)) {
252
            $imageLibrary = $this->imageLibrary;
253
        } else {
254
            $imageLibrary = Captcha::checkRequirements();
255
        }
256
        if ($imageLibrary === 'gd') {
257
            return $this->renderImageByGD($code);
258
        } elseif ($imageLibrary === 'imagick') {
259
            return $this->renderImageByImagick($code);
260
        }
261
262
        throw new InvalidConfigException("Defined library '{$imageLibrary}' is not supported");
263
    }
264
265
    /**
266
     * Renders the CAPTCHA image based on the code using GD library.
267
     * @param string $code the verification code
268
     * @return string image contents in PNG format.
269
     */
270
    protected function renderImageByGD($code)
271
    {
272
        $image = imagecreatetruecolor($this->width, $this->height);
273
274
        $backColor = imagecolorallocate(
275
            $image,
276
            (int) ($this->backColor % 0x1000000 / 0x10000),
277
            (int) ($this->backColor % 0x10000 / 0x100),
278
            $this->backColor % 0x100
279
        );
280
        imagefilledrectangle($image, 0, 0, $this->width - 1, $this->height - 1, $backColor);
281
        imagecolordeallocate($image, $backColor);
282
283
        if ($this->transparent) {
284
            imagecolortransparent($image, $backColor);
285
        }
286
287
        $foreColor = imagecolorallocate(
288
            $image,
289
            (int) ($this->foreColor % 0x1000000 / 0x10000),
290
            (int) ($this->foreColor % 0x10000 / 0x100),
291
            $this->foreColor % 0x100
292
        );
293
294
        $length = strlen($code);
295
        $box = imagettfbbox(30, 0, $this->fontFile, $code);
296
        $w = $box[4] - $box[0] + $this->offset * ($length - 1);
297
        $h = $box[1] - $box[5];
298
        $scale = min(($this->width - $this->padding * 2) / $w, ($this->height - $this->padding * 2) / $h);
299
        $x = 10;
300
        $y = round($this->height * 27 / 40);
301
        for ($i = 0; $i < $length; ++$i) {
302
            $fontSize = (int) (random_int(26, 32) * $scale * 0.8);
303
            $angle = random_int(-10, 10);
304
            $letter = $code[$i];
305
            $box = imagettftext($image, $fontSize, $angle, $x, $y, $foreColor, $this->fontFile, $letter);
0 ignored issues
show
$y of type double is incompatible with the type integer expected by parameter $y of imagettftext(). ( Ignorable by Annotation )

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

305
            $box = imagettftext($image, $fontSize, $angle, $x, /** @scrutinizer ignore-type */ $y, $foreColor, $this->fontFile, $letter);
Loading history...
306
            $x = $box[2] + $this->offset;
307
        }
308
309
        imagecolordeallocate($image, $foreColor);
310
311
        ob_start();
312
        imagepng($image);
313
        imagedestroy($image);
314
315
        return ob_get_clean();
316
    }
317
318
    /**
319
     * Renders the CAPTCHA image based on the code using ImageMagick library.
320
     * @param string $code the verification code
321
     * @return string image contents in PNG format.
322
     */
323
    protected function renderImageByImagick($code)
324
    {
325
        $backColor = $this->transparent ? new \ImagickPixel('transparent') : new \ImagickPixel('#' . str_pad(dechex($this->backColor), 6, 0, STR_PAD_LEFT));
326
        $foreColor = new \ImagickPixel('#' . str_pad(dechex($this->foreColor), 6, 0, STR_PAD_LEFT));
327
328
        $image = new \Imagick();
329
        $image->newImage($this->width, $this->height, $backColor);
330
331
        $draw = new \ImagickDraw();
332
        $draw->setFont($this->fontFile);
333
        $draw->setFontSize(30);
334
        $fontMetrics = $image->queryFontMetrics($draw, $code);
335
336
        $length = strlen($code);
337
        $w = (int) $fontMetrics['textWidth'] - 8 + $this->offset * ($length - 1);
338
        $h = (int) $fontMetrics['textHeight'] - 8;
339
        $scale = min(($this->width - $this->padding * 2) / $w, ($this->height - $this->padding * 2) / $h);
340
        $x = 10;
341
        $y = round($this->height * 27 / 40);
342
        for ($i = 0; $i < $length; ++$i) {
343
            $draw = new \ImagickDraw();
344
            $draw->setFont($this->fontFile);
345
            $draw->setFontSize((int) (random_int(26, 32) * $scale * 0.8));
346
            $draw->setFillColor($foreColor);
347
            $image->annotateImage($draw, $x, $y, random_int(-10, 10), $code[$i]);
348
            $fontMetrics = $image->queryFontMetrics($draw, $code[$i]);
349
            $x += (int) $fontMetrics['textWidth'] + $this->offset;
350
        }
351
352
        $image->setImageFormat('png');
353
        return $image->getImageBlob();
354
    }
355
356
    /**
357
     * Sets the HTTP headers needed by image response.
358
     */
359
    protected function setHttpHeaders()
360
    {
361
        Yii::$app->getResponse()->getHeaders()
0 ignored issues
show
The method getHeaders() does not exist on yii\console\Response. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

361
        Yii::$app->getResponse()->/** @scrutinizer ignore-call */ getHeaders()
Loading history...
362
            ->set('Pragma', 'public')
363
            ->set('Expires', '0')
364
            ->set('Cache-Control', 'must-revalidate, post-check=0, pre-check=0')
365
            ->set('Content-Transfer-Encoding', 'binary')
366
            ->set('Content-type', 'image/png');
367
    }
368
}
369