├── .github
└── workflows
│ └── python-ci.yml
├── .gitignore
├── .python-version
├── .vscode
├── launch.json
└── settings.json
├── LICENSE.txt
├── MANIFEST.in
├── README.md
├── __init__.py
├── captcha-example.PNG
├── code_examples.md
├── debug_flask_server.py
├── flask_simple_captcha
├── __init__.py
├── captcha_generation.py
├── config.py
├── fonts
│ ├── RobotoMono-Bold.ttf
│ ├── RobotoMono-BoldItalic.ttf
│ ├── RobotoMono-SemiBold.ttf
│ └── RobotoMono-SemiBoldItalic.ttf
├── img.py
├── text.py
└── utils.py
├── pyproject.toml
├── requirements_dev.txt
├── setup.py
└── tests.py
/.github/workflows/python-ci.yml:
--------------------------------------------------------------------------------
1 | name: Python CI/CD
2 |
3 | permissions:
4 | contents: write
5 | pages: write
6 | id-token: write
7 |
8 | on:
9 | push:
10 | branches: [master]
11 | pull_request:
12 | branches: [master]
13 | workflow_dispatch:
14 |
15 | jobs:
16 | build:
17 | runs-on: ubuntu-latest
18 | strategy:
19 | matrix:
20 | python-version: [
21 | #'3.5.10-slim',
22 | #'3.6.13-slim',
23 | '3.7.17-slim',
24 | '3.8.18-slim',
25 | '3.9.19-slim',
26 | '3.10.14-slim',
27 | '3.11.9-slim',
28 | '3.12.3-slim',
29 | ]
30 | container:
31 | image: python:${{ matrix.python-version }}
32 | steps:
33 | - uses: actions/checkout@v2
34 | - name: Install dependencies
35 | run: |
36 | pip install --upgrade pip
37 | pip install -r requirements_dev.txt
38 | - name: Run tests and generate reports
39 | run: |
40 | pytest --cov=flask_simple_captcha --cov-report=xml --html=report.html \
41 | --cov-report=term-missing --cov-fail-under=70 -vv \
42 | -s tests.py
43 | - name: Upload reports as artifacts
44 | if: matrix.python-version == '3.11.9-slim'
45 | uses: actions/upload-artifact@v2
46 | with:
47 | name: Reports
48 | path: |
49 | report.html
50 | coverage.xml
51 | assets/
52 | deploy:
53 | needs: build
54 | runs-on: ubuntu-latest
55 | steps:
56 | - uses: actions/checkout@v2
57 | - name: Configure git
58 | run: |
59 | git config user.name 'GitHub Actions'
60 | git config user.email 'actions@github.com'
61 | - name: Checkout gh-pages
62 | run: |
63 | git fetch
64 | if git branch -a | grep -q 'gh-pages'; then
65 | git checkout gh-pages
66 | else
67 | git checkout --orphan gh-pages
68 | git add .
69 | git commit -m "Initial commit"
70 | git push -u origin gh-pages
71 | fi
72 |
73 | - name: Download artifacts
74 | uses: actions/download-artifact@v2
75 | with:
76 | name: Reports
77 | - name: Add and Push Reports
78 | run: |
79 | cp report.html index.html
80 | git status
81 | git add .
82 | git commit -m "Add report to pages"
83 | git push -f origin gh-pages
84 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | build/
2 | dist/
3 | flask_simple_captcha.egg-info/
4 | *.pyc
5 | __pycache__/
6 | venv/
7 | .coverage
8 | .coverage*
9 |
10 | report.html
11 | coverage.xml
12 | assets/
13 |
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
1 | 3.8
2 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": "Python: Run tests",
9 | "type": "python",
10 | "request": "launch",
11 | "module": "pytest",
12 | "args": [
13 | "tests.py",
14 | "-s",
15 | "-vv",
16 | "-x",
17 | "--cov=flask_simple_captcha/",
18 | "--cov-report",
19 | "term-missing"
20 | ],
21 | "console": "integratedTerminal",
22 | "justMyCode": true,
23 | "env": {
24 | "PYTHONPATH": "${workspaceFolder}"
25 | }
26 | },
27 | {
28 | "name": "Python: Flask",
29 | "type": "python",
30 | "request": "launch",
31 | "module": "debug_flask_server",
32 | "env": {
33 | "FLASK_APP": "debug_flask_server",
34 | "FLASK_DEBUG": "1",
35 | "PYTHONPATH": "${workspaceFolder}"
36 | },
37 | "args": ["run", "--no-debugger"],
38 | "jinja": true,
39 | "justMyCode": true
40 | }
41 | ]
42 | }
43 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "python.analysis.extraPaths": [
3 | "./venv/lib/python3.7/site-packages",
4 | "./venv/lib/python3.8/site-packages",
5 | "./venv/lib/python3.9/site-packages",
6 | "./venv/lib/python3.10/site-packages",
7 | "./venv/lib/python3.11/site-packages"
8 | ],
9 | "[python]": {
10 | "editor.defaultFormatter": "ms-python.black-formatter",
11 | "editor.formatOnSave": true
12 | },
13 | "isort.args": [
14 | "--profile",
15 | "black",
16 | "--use-parentheses",
17 | "--line-width",
18 | "79"
19 | ],
20 | "black-formatter.args": [
21 | "-l 79",
22 | "--skip-string-normalization",
23 | "--skip-magic-trailing-comma"
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
2 |
3 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
4 |
5 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
6 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include flask_simple_captcha/arial.ttf
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # flask-simple-captcha
2 |
3 | ### CURRENT VERSION: **v5.5.4**
4 |
5 | **v5.0.0+ added an encryption mechanism to the stored text in the jwts. Previous versions are insecure!**
6 |
7 | **v5.5.4 removed the upper limit on werkzeug/pillow dependency versions**
8 |
9 | `flask-simple-captcha` is a CAPTCHA generator class for generating and validating CAPTCHAs. It allows for easy integration into Flask applications.
10 |
11 | See the encryption / decryption breakdown below for more information on the verification mechanism.
12 |
13 | ## Features
14 |
15 | - Generates CAPTCHAs with customizable length and characters
16 | - Easy integration with Flask applications
17 | - Built-in image rendering and line drawing for added complexity
18 | - Base64 image encoding for easy embedding into HTML
19 | - Uses JWTs and Werkzeug password hashing for secure CAPTCHA verification
20 | - Successfully submitted CAPTCHAs are stored in-memory to prevent resubmission
21 | - Backwards compatible with 1.0 versions of this package
22 | - Avoids visually similar characters by default
23 | - Supports custom character set provided by user
24 | - Casing of submitted captcha is ignored by default
25 | - Minor random font variation in regards to size/family/etc
26 | - PNG/JPEG image format support
27 | - Customizable text|noise/background colors
28 |
29 | ## Prerequisites
30 |
31 | - Python 3.7 or higher
32 | - Werkzeug >=0.16.0, <3
33 | - Pillow >4, <10
34 |
35 | ## Installation
36 |
37 | Import this package directly into your Flask project and make sure to install all dependencies.
38 |
39 | ## How to Use
40 |
41 | ### Configuration
42 |
43 | ```python
44 | DEFAULT_CONFIG = {
45 | 'SECRET_CAPTCHA_KEY': 'LONGKEY', # use for JWT encoding/decoding
46 |
47 | # CAPTCHA GENERATION SETTINGS
48 | 'EXPIRE_SECONDS': 60 * 10, # takes precedence over EXPIRE_MINUTES
49 | 'CAPTCHA_IMG_FORMAT': 'JPEG', # 'PNG' or 'JPEG' (JPEG is 3X faster)
50 |
51 | # CAPTCHA TEXT SETTINGS
52 | 'CAPTCHA_LENGTH': 6, # Length of the generated CAPTCHA text
53 | 'CAPTCHA_DIGITS': False, # Should digits be added to the character pool?
54 | 'EXCLUDE_VISUALLY_SIMILAR': True, # Exclude visually similar characters
55 | 'BACKGROUND_COLOR': (0, 0, 0), # RGB(A?) background color (default black)
56 | 'TEXT_COLOR': (255, 255, 255), # RGB(A?) text color (default white)
57 |
58 | # Optional settings
59 | #'ONLY_UPPERCASE': True, # Only use uppercase characters
60 | #'CHARACTER_POOL': 'AaBb', # Use a custom character pool
61 | }
62 |
63 | ```
64 |
65 | ### Initialization
66 |
67 | Add this code snippet at the top of your application:
68 |
69 | ```python
70 | from flask_simple_captcha import CAPTCHA
71 | YOUR_CONFIG = {
72 | 'SECRET_CAPTCHA_KEY': 'LONG_KEY',
73 | 'CAPTCHA_LENGTH': 6,
74 | 'CAPTCHA_DIGITS': False,
75 | 'EXPIRE_SECONDS': 600,
76 | }
77 | SIMPLE_CAPTCHA = CAPTCHA(config=YOUR_CONFIG)
78 | app = SIMPLE_CAPTCHA.init_app(app)
79 | ```
80 |
81 | ### Protecting a Route
82 |
83 | To add CAPTCHA protection to a route, you can use the following code:
84 |
85 | ```python
86 | @app.route('/example', methods=['GET','POST'])
87 | def example():
88 | if request.method == 'GET':
89 | new_captcha_dict = SIMPLE_CAPTCHA.create()
90 | return render_template('your_template.html', captcha=new_captcha_dict)
91 | if request.method == 'POST':
92 | c_hash = request.form.get('captcha-hash')
93 | c_text = request.form.get('captcha-text')
94 | if SIMPLE_CAPTCHA.verify(c_text, c_hash):
95 | return 'success'
96 | else:
97 | return 'failed captcha'
98 | ```
99 |
100 | In your HTML template, you need to wrap the CAPTCHA inputs within a form element. The package will only generate the CAPTCHA inputs but not the surrounding form or the submit button.
101 |
102 | ```html
103 |
104 |
108 | ```
109 |
110 | ## Example Captcha Images
111 |
112 | Here is an example of what the generated CAPTCHA images look like, this is a screen shot from the `/images` route of the debug server.
113 |
114 | 
115 |
116 | [link to image url if the above does not load](https://github.com/cc-d/flask-simple-captcha/blob/master/captcha-example.PNG)
117 |
118 | ## Encryption and Decryption Breakdown
119 |
120 | Uses a combination of JWTs and Werkzeug's password hashing to encrypt and decrypt CAPTCHA text.
121 |
122 | ### Encryption
123 |
124 | 1. **Salting the Text**: The CAPTCHA text is salted by appending the secret key at the beginning.
125 | ```python
126 | salted_text = secret_key + text
127 | ```
128 | 2. **Hashing**: Werkzeug's `generate_password_hash` function is then used to hash the salted CAPTCHA text.
129 | ```python
130 | hashed_text = generate_password_hash(salted_text)
131 | ```
132 | 3. **Creating JWT Token**: A JWT token is generated using the hashed CAPTCHA text and an optional expiration time.
133 | ```python
134 | payload = {
135 | 'hashed_text': hashed_text,
136 | 'exp': datetime.utcnow() + timedelta(seconds=expire_seconds),
137 | }
138 | return jwt.encode(payload, secret_key, algorithm='HS256')
139 | ```
140 |
141 | ### Decryption
142 |
143 | 1. **Decode JWT Token**: The JWT token is decoded using the secret key. If the token is invalid or expired, the decryption process will fail.
144 | ```python
145 | decoded = jwt.decode(token, secret_key, algorithms=['HS256'])
146 | ```
147 | 2. **Extract Hashed Text**: The hashed CAPTCHA text is extracted from the decoded JWT payload.
148 | ```python
149 | hashed_text = decoded['hashed_text']
150 | ```
151 | 3. **Verifying the Hash**: Werkzeug's `check_password_hash` function is used to verify that the hashed CAPTCHA text matches the original salted CAPTCHA text.
152 | ```python
153 | salted_original_text = secret_key + original_text
154 | if check_password_hash(hashed_text, salted_original_text):
155 | return original_text
156 | ```
157 |
158 | # Development
159 |
160 | ### Setting Up Your Development Environment Without VS Code
161 |
162 | 1. **Create a Virtual Environment:**
163 |
164 | - Navigate to the project directory where you've cloned the repository and create a virtual environment named `venv` within the project directory:
165 |
166 | ```bash
167 | python -m venv venv/
168 | ```
169 |
170 | 2. **Activate the Virtual Environment:**
171 |
172 | - Activate the virtual environment to isolate the project dependencies:
173 | - On macOS/Linux:
174 | ```bash
175 | source venv/bin/activate
176 | ```
177 | - On Windows (using Command Prompt):
178 | ```cmd
179 | .\venv\Scripts\activate
180 | ```
181 | - On Windows (using PowerShell):
182 | ```powershell
183 | .\venv\Scripts\Activate.ps1
184 | ```
185 |
186 | 3. **Install Dependencies:**
187 |
188 | Install the required dependencies for development:
189 |
190 | ```bash
191 | pip install -r requirements_dev.txt
192 | ```
193 |
194 | Install the local flask-simple-captcha package:
195 |
196 | ```bash
197 | pip install .
198 | ```
199 |
200 | ## Running Tests
201 |
202 | #### ENSURE YOU HAVE A VENV NAMED `venv` IN THE PROJECT DIRECTORY AND THAT IT IS ACTIVATED AND BOTH THE DEPENDENCIES AND THE LOCAL FLASK-SIMPLE-CAPTCHA PACKAGE ARE INSTALLED IN THE VENV
203 |
204 | As of the time of me writing this README (2023-11-15), pytest reports 97% test coverage of the logic in the `flask_simple_captcha` package. Should be kept as close to 100% as possible.
205 |
206 | ### Run Tests Without VS Code
207 |
208 | - Run the tests using the following command (make sure your venv is activated and you are in the project directory)
209 | ```bash
210 | python -m pytest tests.py -s -vv --cov=flask_simple_captcha/ --cov-report term-missing
211 | ```
212 | - The command runs pytest with flags for verbose output, standard output capture, coverage report, and displaying missing lines in the coverage.
213 |
214 | ### Running Tests With VS Code
215 |
216 | Simply hit command + shift + p and type "Select And Start Debugging" and select `Python: Run tests`. You will want to make sure your venv is installed and activated.
217 |
218 | ### Example Test Output
219 |
220 | ```bash
221 | ... previous output omitted for brevity ...
222 |
223 | tests.py::TestCaptchaUtils::test_jwtencrypt PASSED
224 | tests.py::TestCaptchaUtils::test_no_hashed_text PASSED
225 |
226 |
227 | ---------- coverage: platform darwin, python 3.8.18-final-0 ----------
228 | Name Stmts Miss Cover Missing
229 | ----------------------------------
230 | flask_simple_captcha/__init__.py 3 0 100%
231 | flask_simple_captcha/captcha_generation.py 78 0 100%
232 | flask_simple_captcha/config.py 10 0 100%
233 | flask_simple_captcha/img.py 56 0 100%
234 | flask_simple_captcha/text.py 25 0 100%
235 | flask_simple_captcha/utils.py 51 0 100%
236 | ----------------------------
237 | TOTAL 223 0 100%
238 |
239 |
240 | ==================================== 41 passed in 5.53s
241 | ```
242 |
243 | ## Debug Server
244 |
245 | #### **Start the debug server without VS Code**
246 |
247 | 1. **Set Environment Variables:**
248 | - Before running the debug Flask server, set the required environment variables:
249 | - On macOS/Linux:
250 | ```bash
251 | export FLASK_APP=debug_flask_server
252 | export FLASK_DEBUG=1
253 | ```
254 | - On Windows (using Command Prompt):
255 | ```cmd
256 | set FLASK_APP=debug_flask_server
257 | set FLASK_DEBUG=1
258 | ```
259 | - On Windows (using PowerShell):
260 | ```powershell
261 | $env:FLASK_APP="debug_flask_server"
262 | $env:FLASK_DEBUG="1"
263 | ```
264 | 2. **Start the debug Flask server:**
265 | - Run the following command to start the debug Flask server:
266 | ```bash
267 | flask run --no-debugger
268 | ```
269 | - This will start the debug Flask server with the automatic reloader. See the navigation section below on how to access the debug server.
270 |
271 | #### **Start the debug server with VS Code**
272 |
273 | - Hit command + shift + p and type "Select And Start Debugging" and select `Python: Flask`
274 | - This will start the debug Flask server with debugging features enabled, including the interactive debugger and automatic reloader.
275 |
276 | ### Accessing the Debug Server
277 |
278 | Navigate to `localhost:5000` in your browser to view the debug server. You can also navigate to `localhost:5000/images` to view 50 CAPTCHA images at once, or `localhost:5000/images/<$NUMBER_OF_IMAGES>` to view an arbritrary amount of generated captchas.
279 |
280 | ## Code Examples
281 |
282 | For usage and integration examples, including how to use `flask-simple-captcha` with WTForms and Flask-Security, check out the [Code Examples](code_examples.md) document.
283 |
284 | ## Contributing
285 |
286 | Feel free to open a PR. The project has undergone a recent overhaul to improve the code quality.
287 |
288 | If you make changes in the logic, please follow the steps laid out in this document for testing and debugging. Make sure the coverage % stays >= 100% and that you verify manually at least once that things look okay by submitting a real CAPTCHA in the debug server.
289 |
290 | The `pyproject.toml` has the required configuration for `black` and `isort`. There is also a vscode settings file equivalent to the `pyproject.toml` file in `.vscode/settings.json`.
291 |
292 | ## License
293 |
294 | MIT
295 |
296 | Contact: ccarterdev@gmail.com
297 |
--------------------------------------------------------------------------------
/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cc-d/flask-simple-captcha/288142dc405874f3bb028162eeba9d3e72b59beb/__init__.py
--------------------------------------------------------------------------------
/captcha-example.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cc-d/flask-simple-captcha/288142dc405874f3bb028162eeba9d3e72b59beb/captcha-example.PNG
--------------------------------------------------------------------------------
/code_examples.md:
--------------------------------------------------------------------------------
1 | # Code Examples for Flask-Simple-Captcha
2 |
3 | This document provides practical code examples for integrating `flask-simple-captcha` into Flask applications.
4 |
5 | ## Integration with WTForms and Flask-Security
6 |
7 | This example, contributed by [@TobiasTKranz](https://www.github.com/TobiasTKranz), demonstrates integrating `flask-simple-captcha` with WTForms and Flask-Security in a Flask application.
8 |
9 | ### Code to access Simple Captcha easily inside your project
10 |
11 | In order to access the Simple Captcha Extension you might connect it to the flask extensions dict like that:
12 |
13 | ```python
14 | # Fake extension registration, to be able to access this extension's instance later on
15 | app.extensions.update({"flask-simple-captcha": SIMPLE_CAPTCHA})
16 | ```
17 |
18 | ### Code for Simple Captcha Field and Validation
19 |
20 | ```python
21 | import flask
22 | from flask import current_app, request
23 | from markupsafe import Markup
24 | from wtforms import Field, ValidationError
25 |
26 | SIMPLE_CAPTCHA_ERROR_CODES = {
27 | "missing-input-hash": "The secret parameter is missing.",
28 | "missing-input-response": "The response parameter is missing.",
29 | "invalid-captcha-sol": "The captcha was not entered correctly.",
30 | }
31 |
32 | class SimpleCaptchaWidget:
33 | def __call__(self, field, error=None, **kwargs):
34 | simple_captcha = flask.current_app.extensions.get("flask-simple-captcha")
35 | captcha_dict = simple_captcha.create()
36 |
37 | return Markup(simple_captcha.captcha_html(captcha_dict))
38 |
39 | class SimpleCaptcha:
40 | def __init__(self):
41 | self._simple_captcha = current_app.extensions.get("flask-simple-captcha")
42 |
43 | def __call__(self, form, field):
44 | if current_app.testing:
45 | return True
46 |
47 | request_data = request.json if request.is_json else request.form
48 | c_hash = request_data.get('captcha-hash')
49 | c_text = request_data.get('captcha-text')
50 |
51 | if not c_hash:
52 | raise ValidationError(
53 | SIMPLE_CAPTCHA_ERROR_CODES["missing-input-hash"]
54 | )
55 | elif not c_text:
56 | raise ValidationError(
57 | SIMPLE_CAPTCHA_ERROR_CODES["missing-input-response"]
58 | )
59 | elif not self._validate_simple_captcha(c_hash, c_text):
60 | raise ValidationError(
61 | SIMPLE_CAPTCHA_ERROR_CODES["invalid-captcha-sol"]
62 | )
63 |
64 | def _validate_simple_captcha(self, c_hash, c_text):
65 | return self._simple_captcha.verify(c_text, c_hash)
66 |
67 | class SimpleCaptchaField(Field):
68 | widget = SimpleCaptchaWidget()
69 |
70 | def __init__(self, label="", validators=None, **kwargs):
71 | validators = validators or [SimpleCaptcha()]
72 | super().__init__(label, validators, **kwargs)
73 | ```
74 |
75 | ### Implementing in a Custom Form
76 |
77 | Use the `SimpleCaptchaField` in your custom forms as follows:
78 |
79 | ```python
80 | from flask_security.forms import RegisterForm
81 | from wtforms import StringField, DataRequired
82 |
83 | class CustomRegisterForm(RegisterForm):
84 | captcha = SimpleCaptchaField("Captcha")
85 | first_name = StringField("First name", [DataRequired()])
86 | ```
87 |
88 | ### HTML Form Integration
89 |
90 | Include the CAPTCHA field in your HTML templates:
91 |
92 | ```html
93 |
102 | ```
103 |
--------------------------------------------------------------------------------
/debug_flask_server.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import os.path as op
3 | import multiprocessing as mp
4 | from flask import Flask, request, render_template_string
5 |
6 | from flask_simple_captcha import CAPTCHA, DEFAULT_CONFIG
7 |
8 | app = Flask(__name__)
9 | test_config = DEFAULT_CONFIG.copy()
10 |
11 | CAPTCHA = CAPTCHA(config=test_config)
12 | app = CAPTCHA.init_app(app)
13 |
14 | PROCS = mp.cpu_count()
15 |
16 |
17 | def _captcha(mp_list: list):
18 | mp_list.append(CAPTCHA.create())
19 |
20 |
21 | @app.route('/', methods=['GET', 'POST'])
22 | def submit_captcha():
23 | if request.method == 'GET':
24 | captcha_dict = CAPTCHA.create()
25 | capinput = CAPTCHA.captcha_html(captcha_dict)
26 | return render_template_string(
27 | '' % capinput
28 | )
29 | if request.method == 'POST':
30 | c_hash = request.form.get('captcha-hash')
31 | c_text = request.form.get('captcha-text')
32 |
33 | if CAPTCHA.verify(c_text, c_hash):
34 | return 'success'
35 | else:
36 | return 'failed captcha'
37 |
38 |
39 | @app.route('/images')
40 | @app.route('/images/')
41 | def bulk_captchas(captchas=None):
42 | captchas = captchas or 50
43 | with mp.Manager() as mgr:
44 | mp_list = mgr.list()
45 | with mp.Pool(PROCS) as pool:
46 | pool.map(_captcha, [mp_list] * captchas)
47 |
48 | captchas = list(mp_list)
49 |
50 | mimetype = 'image/png' if CAPTCHA.img_format == 'PNG' else 'image/jpeg'
51 | captchas = [
52 | '
' % (mimetype, c['img'])
54 | for c in captchas
55 | ]
56 |
57 | style = ''
58 | return style + '\n'.join(captchas)
59 |
60 |
61 | if __name__ == '__main__':
62 | app.run()
63 |
--------------------------------------------------------------------------------
/flask_simple_captcha/__init__.py:
--------------------------------------------------------------------------------
1 | import sys
2 | from os.path import dirname, abspath, join as pjoin
3 |
4 | from .captcha_generation import CAPTCHA, DEFAULT_CONFIG
5 |
--------------------------------------------------------------------------------
/flask_simple_captcha/captcha_generation.py:
--------------------------------------------------------------------------------
1 | import string
2 | from random import choice as rchoice
3 | from PIL import Image
4 | from typing import Tuple
5 | from uuid import uuid4
6 | from .config import DEFAULT_CONFIG
7 |
8 | from .utils import (
9 | jwtencrypt,
10 | jwtdecrypt,
11 | gen_captcha_text,
12 | CHARPOOL,
13 | exclude_similar_chars,
14 | )
15 |
16 | from .img import (
17 | convert_b64img as new_convert_b64img,
18 | draw_lines as new_draw_lines,
19 | create_text_img,
20 | )
21 | from .text import CAPTCHA_FONTS, get_font
22 |
23 |
24 | class CAPTCHA:
25 | """CAPTCHA class to generate and validate CAPTCHAs."""
26 |
27 | def __init__(self, config: dict):
28 | """Initialize CAPTCHA with default configuration."""
29 | self.config = {**DEFAULT_CONFIG, **config}
30 | self.verified_captchas = set()
31 | self.secret = self.config['SECRET_CAPTCHA_KEY']
32 |
33 | # jwt expiration time
34 | if 'EXPIRE_NORMALIZED' in config:
35 | self.expire_secs = config['EXPIRE_NORMALIZED']
36 | elif 'EXPIRE_SECONDS' in config:
37 | self.expire_secs = config['EXPIRE_SECONDS']
38 | elif 'EXPIRE_MINUTES' in config and 'EXPIRE_SECONDS' not in config:
39 | self.expire_secs = config['EXPIRE_MINUTES'] * 60
40 | else:
41 | self.expire_secs = DEFAULT_CONFIG['EXPIRE_SECONDS']
42 |
43 | # character pool
44 | if 'CHARACTER_POOL' in self.config:
45 | chars = self.config['CHARACTER_POOL']
46 | else:
47 | chars = ''.join(CHARPOOL)
48 |
49 | # uppercase characters
50 | if (
51 | 'ONLY_UPPERCASE' in self.config
52 | and self.config['ONLY_UPPERCASE'] is False
53 | ):
54 | chars = ''.join(set(c for c in chars))
55 | self.only_upper = False
56 | else:
57 | chars = ''.join(set(c.upper() for c in chars))
58 | self.only_upper = True
59 |
60 | # digits
61 | if self.config['CAPTCHA_DIGITS']:
62 | chars += string.digits
63 |
64 | # visually similar characters
65 | if self.config['EXCLUDE_VISUALLY_SIMILAR']:
66 | chars = exclude_similar_chars(chars)
67 |
68 | self.characters = tuple(set(chars))
69 |
70 | # img format
71 | self.img_format = self.config['CAPTCHA_IMG_FORMAT']
72 |
73 | # fonts
74 | self.fonts = CAPTCHA_FONTS
75 |
76 | # if USE_TEXT_FONTS is set in config, only use those fonts
77 | if 'USE_TEXT_FONTS' in self.config:
78 | self.fonts = []
79 | for fntname in self.config['USE_TEXT_FONTS']:
80 | fnt = get_font(fntname, CAPTCHA_FONTS)
81 | if fnt is not None:
82 | self.fonts.append(fnt)
83 |
84 | def get_background(self, text_size: Tuple[int, int]) -> Image:
85 | """preserved for backwards compatibility"""
86 | return Image.new(
87 | 'RGBA',
88 | (int(text_size[0]), int(text_size[1])),
89 | color=(0, 0, 0, 255),
90 | )
91 |
92 | def convert_b64img(self, *args, **kwargs) -> str:
93 | """preserved for backwards compatibility"""
94 | return new_convert_b64img(*args, **kwargs)
95 |
96 | def draw_lines(self, *args, **kwargs) -> Image:
97 | """preserved for backwards compatibility"""
98 | return new_draw_lines(*args, **kwargs)
99 |
100 | def create(self, length=None, digits=None) -> str:
101 | """Create a new CAPTCHA dict and add it to self.captchas"""
102 | # backwards compatibility
103 | length = self.config['CAPTCHA_LENGTH'] if length is None else length
104 | add_digits = (
105 | self.config['CAPTCHA_DIGITS'] if digits is None else digits
106 | )
107 |
108 | text = gen_captcha_text(
109 | length=length, add_digits=add_digits, charpool=self.characters
110 | )
111 | out_img = create_text_img(
112 | text,
113 | rchoice(self.fonts).path,
114 | back_color=self.config['BACKGROUND_COLOR'],
115 | text_color=self.config['TEXT_COLOR'],
116 | )
117 |
118 | return {
119 | 'img': self.convert_b64img(out_img, self.img_format),
120 | 'text': text,
121 | 'hash': jwtencrypt(
122 | text, self.secret, expire_seconds=self.expire_secs
123 | ),
124 | }
125 |
126 | def verify(self, c_text: str, c_hash: str) -> bool:
127 | """Verify CAPTCHA response. Return True if valid, False if invalid.
128 |
129 | Args:
130 | c_text (str): The CAPTCHA text to verify.
131 | c_hash (str): The jwt to verify (from the hidden input field)
132 |
133 | Returns:
134 | bool: True if valid, False if invalid.
135 | """
136 | # handle parameter reversed order
137 | if len(c_text.split('.')) == 3:
138 | # jwt was passed as 1st arg correct
139 | c_text, c_hash = c_hash, c_text
140 |
141 | if c_hash in self.verified_captchas:
142 | return False
143 |
144 | decoded_text = jwtdecrypt(
145 | c_hash, c_text, self.config['SECRET_CAPTCHA_KEY']
146 | )
147 |
148 | # token expired or invalid
149 | if decoded_text is None:
150 | return False
151 |
152 | if self.only_upper:
153 | decoded_text, c_text = decoded_text.upper(), c_text.upper()
154 |
155 | if decoded_text == c_text:
156 | self.verified_captchas.add(c_hash)
157 | return True
158 | return False
159 |
160 | def captcha_html(self, captcha: dict) -> str:
161 | """
162 | Generate HTML for the CAPTCHA image and input fields.
163 | Args:
164 | captcha (dict): captcha dict with hash/img keys
165 | Returns:
166 | str: HTML string containing the CAPTCHA image and input fields.
167 | """
168 | mimetype = 'image/png' if self.img_format == 'PNG' else 'image/jpeg'
169 | img = (
170 | '
' % (mimetype, captcha['img'])
172 | )
173 |
174 | inpu = (
175 | '\n'
178 | + '' % captcha['hash']
180 | )
181 |
182 | return '%s\n%s' % (img, inpu)
183 |
184 | def init_app(self, app):
185 | app.jinja_env.globals.update(captcha_html=self.captcha_html)
186 |
187 | return app
188 |
189 | def __repr__(self):
190 | return '' % self.config
191 |
--------------------------------------------------------------------------------
/flask_simple_captcha/config.py:
--------------------------------------------------------------------------------
1 | import string
2 | from glob import glob
3 | from typing import Optional, Union, Set, Tuple
4 |
5 | CHARPOOL = tuple(set(string.ascii_uppercase))
6 | EXCHARS = tuple(set('oOlI1'))
7 |
8 | DEFAULT_CONFIG = {
9 | 'SECRET_CAPTCHA_KEY': 'LONGKEY', # use for JWT encoding/decoding
10 | # CAPTCHA GENERATION SETTINGS
11 | 'EXPIRE_SECONDS': 60 * 10, # takes precedence over EXPIRE_MINUTES
12 | 'CAPTCHA_IMG_FORMAT': 'JPEG', # 'PNG' or 'JPEG' (JPEG is 3X faster)
13 | # CAPTCHA TEXT SETTINGS
14 | 'CAPTCHA_LENGTH': 6, # Length of the generated CAPTCHA text
15 | 'CAPTCHA_DIGITS': False, # Should digits be added to the character pool?
16 | 'EXCLUDE_VISUALLY_SIMILAR': True, # Exclude visually similar characters
17 | 'BACKGROUND_COLOR': (0, 0, 0), # RGB(A?) background color (default black)
18 | 'TEXT_COLOR': (255, 255, 255), # RGB(A?) text color (default white)
19 | # Optional/Backwards Compatability settings
20 | #'EXPIRE_MINUTES': 10, # backwards compatibility concerns supports this too
21 | #'EXCLUDE_VISUALLY_SIMILAR': True, # Optional
22 | #'ONLY_UPPERCASE': True, # Optional
23 | #'CHARACTER_POOL': 'AaBb', # Optional
24 | #'USE_TEXT_FONTS': ['RobotoMono-Bold'], # Only use these fonts in ./fonts
25 | }
26 |
27 | EXPIRE_NORMALIZED = DEFAULT_CONFIG['EXPIRE_SECONDS']
28 |
29 | # should add these to config and allow users to change them
30 | IMGHEIGHT = 60
31 | IMGWIDTH = 180
32 | FONTSIZE = 30
33 |
--------------------------------------------------------------------------------
/flask_simple_captcha/fonts/RobotoMono-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cc-d/flask-simple-captcha/288142dc405874f3bb028162eeba9d3e72b59beb/flask_simple_captcha/fonts/RobotoMono-Bold.ttf
--------------------------------------------------------------------------------
/flask_simple_captcha/fonts/RobotoMono-BoldItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cc-d/flask-simple-captcha/288142dc405874f3bb028162eeba9d3e72b59beb/flask_simple_captcha/fonts/RobotoMono-BoldItalic.ttf
--------------------------------------------------------------------------------
/flask_simple_captcha/fonts/RobotoMono-SemiBold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cc-d/flask-simple-captcha/288142dc405874f3bb028162eeba9d3e72b59beb/flask_simple_captcha/fonts/RobotoMono-SemiBold.ttf
--------------------------------------------------------------------------------
/flask_simple_captcha/fonts/RobotoMono-SemiBoldItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cc-d/flask-simple-captcha/288142dc405874f3bb028162eeba9d3e72b59beb/flask_simple_captcha/fonts/RobotoMono-SemiBoldItalic.ttf
--------------------------------------------------------------------------------
/flask_simple_captcha/img.py:
--------------------------------------------------------------------------------
1 | import os
2 | import random as ran
3 | from typing import Tuple, Optional, Union
4 | from PIL import Image, ImageDraw, ImageFont
5 | from io import BytesIO
6 | from base64 import b64encode
7 | from .utils import gen_captcha_text, jwtencrypt
8 | from .config import DEFAULT_CONFIG as _DEF, IMGHEIGHT, IMGWIDTH, FONTSIZE
9 | from .text import CaptchaFont
10 |
11 |
12 | def convert_b64img(
13 | captcha_img: Image, img_format: str = _DEF['CAPTCHA_IMG_FORMAT']
14 | ) -> str:
15 | """Convert PIL image to base64 string
16 | Args:
17 | captcha_img (Image): The PIL image to be converted
18 | img_format (str, optional): The image format to be used.
19 | Defaults to 'JPEG'
20 | Returns:
21 | str: The base64 encoded image string
22 | """
23 | byte_array = BytesIO()
24 | # JPEG is about ~3x faster
25 | if img_format == 'JPEG':
26 | captcha_img.save(byte_array, format=img_format, quality=85)
27 | else:
28 | captcha_img.save(byte_array, format=img_format)
29 |
30 | return b64encode(byte_array.getvalue()).decode()
31 |
32 |
33 | RGBAType = Union[Tuple[int, int, int, int], Tuple[int, int, int]]
34 |
35 |
36 | def draw_lines(
37 | im: Image,
38 | noise: int = 12,
39 | text_color: RGBAType = (255, 255, 255),
40 | **kwargs,
41 | ) -> Image:
42 | """Draws complex background noise on the image."""
43 | draw = kwargs.get('draw', None)
44 | if draw is None:
45 | draw = ImageDraw.Draw(im)
46 |
47 | w, h = im.size
48 |
49 | line_starts = [
50 | (ran.randint(0, w), ran.randint(0, h))
51 | for _ in range(int(noise * 0.66) * 2)
52 | ]
53 |
54 | # Draw lines
55 | for i in range(0, len(line_starts) - 1, 2):
56 | x0, y0 = line_starts[i]
57 | x1, y1 = line_starts[i + 1]
58 | draw.line((x0, y0, x1, y1), fill=text_color, width=2)
59 |
60 | ellipse_centers = [
61 | (ran.randint(0, w), ran.randint(0, h))
62 | for _ in range(int(noise * 0.33))
63 | ]
64 |
65 | # Draw ellipses
66 | for center_x, center_y in ellipse_centers:
67 | radius_x = ran.randint(4, w // 4)
68 | radius_y = ran.randint(4, h // 4)
69 | upper_left = (center_x - radius_x, center_y - radius_y)
70 | lower_right = (center_x + radius_x, center_y + radius_y)
71 | draw.ellipse((upper_left + lower_right), outline=text_color, width=2)
72 |
73 | return im
74 |
75 |
76 | def create_text_img(
77 | text: str,
78 | font_path: str,
79 | font_size: int = FONTSIZE,
80 | back_color: RGBAType = (0, 0, 0, 255),
81 | text_color: RGBAType = (255, 255, 255),
82 | ) -> Image:
83 | """Create a PIL image of the CAPTCHA text.
84 | Args:
85 | text (str): The CAPTCHA text to be drawn.
86 | font_path (str): The path to the font to be used.
87 | img_format (str): The image format to be used.
88 | Defaults to 'JPEG'
89 | back_color (RGBAType): The background color to be used.
90 | Defaults to (0, 0, 0, 255)
91 | text_color (RGBAType): The text color to be used.
92 | Defaults to (255, 255, 255)
93 | Returns:
94 | Image: The PIL image of the CAPTCHA text.
95 | """
96 | # roboto mono assumed to be 0.6x width of $size * char width
97 | char_w = round(font_size * 0.6)
98 | actual_txt_w = len(text) * char_w
99 | txt_w = font_size * len(text)
100 | txt_h = font_size
101 |
102 | fnt = ImageFont.truetype(font_path, font_size)
103 |
104 | # background should be slightly larger than text
105 | back_w, back_h = (round(actual_txt_w * 1.25), round(txt_h * 1.5))
106 |
107 | # each char is randomly placed in a segment of the background
108 | txt_seg_w = int(back_w / len(text)) # rounds down
109 |
110 | # gap between background h/w and text h/w
111 | seg_gap_h = int(back_h - txt_h) # rounds down
112 |
113 | # create background image
114 | back_img = Image.new('RGB', (back_w, back_h), color=back_color)
115 |
116 | # only initialize drawer once
117 | drawer = ImageDraw.Draw(back_img)
118 |
119 | char_chords = []
120 |
121 | for i, c in enumerate(text):
122 | startx = i * txt_seg_w
123 | endx = startx + (txt_seg_w - char_w)
124 | # roboto mono seems to appear slightly under the baseline
125 | starty = -5
126 | endy = seg_gap_h - 5
127 |
128 | ranx = ran.randint(startx, endx)
129 | rany = ran.randint(starty, endy)
130 |
131 | char_chords.append((ranx, rany))
132 |
133 | drawer.text((ranx, rany), c, font=fnt, fill=text_color)
134 |
135 | # 6 minimum
136 | back_img = draw_lines(
137 | back_img, noise=12, draw=drawer, text_color=text_color
138 | )
139 |
140 | back_img = back_img.resize((IMGWIDTH, IMGHEIGHT))
141 |
142 | return back_img
143 |
--------------------------------------------------------------------------------
/flask_simple_captcha/text.py:
--------------------------------------------------------------------------------
1 | import os
2 | import os.path as op
3 | import re
4 | from typing import Optional, List, Tuple, Union
5 | from glob import glob
6 |
7 |
8 | class CaptchaFont:
9 | def __init__(self, path: str):
10 | self.filename = op.basename(path)
11 | self.name = '.'.join(self.filename.split('.')[:-1])
12 | self.path = path
13 |
14 | def __repr__(self):
15 | return '' % self.name
16 |
17 |
18 | FONTS_DIR = op.join(op.dirname(op.abspath(__file__)), 'fonts')
19 | FONT_PATHS = glob(op.join(FONTS_DIR, '*.ttf'))
20 | FONT_NAMES = [op.basename(p) for p in FONT_PATHS]
21 |
22 | CAPTCHA_FONTS = [CaptchaFont(p) for p in FONT_PATHS]
23 |
24 |
25 | def get_font(
26 | name: str, font_pool: list = CAPTCHA_FONTS
27 | ) -> Optional[CaptchaFont]:
28 | """Get a CaptchaFont object by name or filename or path str
29 | Args:
30 | name (str): The name, filename, or path of the font
31 | font_pool (list, optional): list of CaptchaFont objects to search.
32 | Defaults to CAPTCHA_FONTS.
33 | Returns:
34 | CaptchaFont: The CaptchaFont object if found, else None
35 | """
36 | for font in font_pool:
37 | if font.name == name:
38 | return font
39 | elif font.filename == name:
40 | return font
41 | elif font.path == name:
42 | return font
43 | return None
44 |
--------------------------------------------------------------------------------
/flask_simple_captcha/utils.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import os
3 | import random
4 | import string
5 | import sys
6 | from datetime import datetime, timedelta
7 | from io import BytesIO
8 | from typing import Iterable, Optional, Set, Tuple, Union
9 |
10 | import jwt
11 | from PIL import Image
12 | from werkzeug.security import check_password_hash, generate_password_hash
13 |
14 | sys.path.append(os.path.dirname(os.path.abspath(__file__)))
15 | from .config import CHARPOOL, DEFAULT_CONFIG, EXCHARS, EXPIRE_NORMALIZED
16 |
17 |
18 | def hash_text(
19 | text: str, secret_key: str = DEFAULT_CONFIG['SECRET_CAPTCHA_KEY']
20 | ) -> str:
21 | """
22 | Encrypt the CAPTCHA text.
23 |
24 | Args:
25 | text (str): The CAPTCHA text to be encrypted.
26 | secret_key (str, optional): The secret key for encryption.
27 | Defaults to value in DEFAULT_CONFIG.
28 |
29 | Returns:
30 | str: The encrypted CAPTCHA text.
31 | """
32 | salted_text = secret_key + text
33 | return generate_password_hash(salted_text)
34 |
35 |
36 | def jwtencrypt(
37 | text: str,
38 | secret_key: str = DEFAULT_CONFIG['SECRET_CAPTCHA_KEY'],
39 | expire_seconds: int = EXPIRE_NORMALIZED,
40 | ) -> str:
41 | """
42 | Encode the CAPTCHA text into a JWT token.
43 |
44 | Args:
45 | text (str): The CAPTCHA text to be encoded.
46 | secret_key (str, optional): The secret key for JWT encoding.
47 | Defaults to value in DEFAULT_CONFIG.
48 | expire_seconds (int, optional): The expiration time for the token in seconds.
49 | Defaults to 600, 10 minutes.
50 |
51 | Returns:
52 | str: The encoded JWT token.
53 | """
54 | hashed_text = hash_text(text, secret_key)
55 | payload = {
56 | 'hashed_text': hashed_text,
57 | 'exp': datetime.utcnow() + timedelta(seconds=expire_seconds),
58 | }
59 | return jwt.encode(payload, secret_key, algorithm='HS256')
60 |
61 |
62 | def jwtdecrypt(
63 | token: str,
64 | original_text: str,
65 | secret_key: str = DEFAULT_CONFIG['SECRET_CAPTCHA_KEY'],
66 | ) -> Optional[str]:
67 | """
68 | Decode the CAPTCHA text from a JWT token.
69 |
70 | Args:
71 | token (str): The JWT token to decode.
72 | original_text (str): The original CAPTCHA text.
73 | secret_key (str, optional): The secret key for JWT decoding.
74 |
75 | Returns:
76 | Optional[str]: The decoded CAPTCHA text if valid, None if invalid.
77 | """
78 | try:
79 | decoded = jwt.decode(token, secret_key, algorithms=['HS256'])
80 | if 'hashed_text' not in decoded:
81 | return None
82 |
83 | hashed_text = decoded['hashed_text']
84 | salted_original_text = secret_key + original_text
85 |
86 | # Verify if the hashed text matches the original text
87 | if check_password_hash(hashed_text, salted_original_text):
88 | return original_text
89 | else:
90 | return None
91 |
92 | except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
93 | return None
94 |
95 |
96 | def exclude_similar_chars(chars: Union[str, set, list, tuple]) -> str:
97 | """Excludes characters that are potentially visually confusing from
98 | the character pool (provided as charstr).
99 |
100 | Args:
101 | charstr (Union[str, set, list, tuple]): The character pool to exclude characters from.
102 |
103 | Returns:
104 | Union[str, set, list, tuple]: The character pool with
105 | visually confusing characters excluded.
106 | """
107 | if isinstance(chars, str):
108 | return ''.join({c for c in chars if c not in EXCHARS})
109 | elif isinstance(chars, set):
110 | return chars - set(EXCHARS)
111 | elif isinstance(chars, list):
112 | return [c for c in chars if c not in EXCHARS]
113 | else:
114 | return tuple(c for c in chars if c not in EXCHARS)
115 |
116 |
117 | def gen_captcha_text(
118 | length: int = 6,
119 | add_digits: bool = False,
120 | exclude_similar: bool = True,
121 | charpool: Optional[Iterable] = None,
122 | only_uppercase: Optional[bool] = None,
123 | ) -> str:
124 | """Generate a random CAPTCHA text.
125 | Args:
126 | length (int): The length of the CAPTCHA text.
127 | Defaults to 6
128 | add_digits (bool): add digits to the character pool?
129 | Defaults to False
130 | exclude_similar (bool): exclude visually similar characters?
131 | from the character pool. Defaults to True.
132 | charpool (Iterable, optional): The character pool to
133 | generate the CAPTCHA text from. If this is provided, it will
134 | override the default character pool as well as add_digits.
135 | Defaults to None.
136 | only_uppercase (bool, optional): Only return uppercase characters. If
137 | a custom character pool is passed, only the uppercase characters will
138 | be used from that pool.
139 | Defaults to True.
140 | Returns:
141 | str: The generated CAPTCHA text.
142 | """
143 | if charpool is not None:
144 | if only_uppercase is True:
145 | charpool = tuple({c.upper() for c in charpool})
146 | else:
147 | charpool = tuple(charpool)
148 | else:
149 | charpool = CHARPOOL
150 |
151 | if exclude_similar:
152 | charpool = exclude_similar_chars(charpool)
153 |
154 | if add_digits:
155 | charpool += tuple(set(string.digits))
156 |
157 | return ''.join((random.choice(charpool) for _ in range(length)))
158 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.isort]
2 | profile = "black"
3 | use_parentheses = true
4 | line_length = 79
5 |
6 | [tool.black]
7 | line_length = 79
8 | skip_string_normalization = true
9 | skip_magic_trailing_comma = true
10 | preview = true
11 | target-version = ['py37']
--------------------------------------------------------------------------------
/requirements_dev.txt:
--------------------------------------------------------------------------------
1 | Werkzeug>=0.16.0
2 | Pillow>4
3 | pyjwt<3,>=2
4 | Flask
5 | black
6 | isort
7 | pytest
8 | pytest-cov
9 | pytest-xdist
10 | pytest-html
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | from setuptools import setup, find_packages
3 |
4 | with open('README.md', 'r') as r:
5 | README = r.read()
6 |
7 | setup(
8 | name='flask-simple-captcha',
9 | version='5.5.5',
10 | description=(
11 | 'Extremely simple, "Good Enough" captcha implemention for flask forms.'
12 | ' No server side session library required.'
13 | ),
14 | long_description=README,
15 | long_description_content_type='text/markdown',
16 | url='https://github.com/cc-d/flask-simple-captcha',
17 | author='Cary Carter',
18 | author_email='ccarterdev@gmail.com',
19 | license='MIT',
20 | classifiers=[
21 | 'License :: OSI Approved :: MIT License',
22 | 'Programming Language :: Python :: 3',
23 | 'Programming Language :: Python :: 3.7',
24 | ],
25 | packages=find_packages(exclude=('tests',)),
26 | include_package_data=True,
27 | package_data={'flask_simple_captcha': ['fonts/*']},
28 | install_requires=['Werkzeug>=0.16.0', 'Pillow>4', 'pyjwt<3,>=2'],
29 | )
30 |
--------------------------------------------------------------------------------
/tests.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | import time
3 | import jwt
4 | import string
5 | from io import BytesIO
6 | from base64 import b64encode
7 | from datetime import datetime, timedelta
8 | from unittest.mock import patch, Mock, MagicMock, ANY
9 |
10 | from flask import Flask
11 |
12 | from PIL import Image
13 | from werkzeug.security import generate_password_hash, check_password_hash
14 |
15 | from flask_simple_captcha import CAPTCHA
16 |
17 | from flask_simple_captcha.config import DEFAULT_CONFIG, EXPIRE_NORMALIZED
18 | from flask_simple_captcha.utils import (
19 | jwtencrypt,
20 | jwtdecrypt,
21 | gen_captcha_text,
22 | exclude_similar_chars,
23 | CHARPOOL,
24 | hash_text,
25 | )
26 | from flask_simple_captcha.img import (
27 | convert_b64img,
28 | draw_lines,
29 | create_text_img,
30 | )
31 | from flask_simple_captcha.text import CaptchaFont, get_font, CAPTCHA_FONTS
32 |
33 | _TESTTEXT = 'TestText'
34 | _TESTKEY = 'TestKey'
35 |
36 | app = Flask(__name__)
37 |
38 |
39 | class TestConfig(unittest.TestCase):
40 | def test_expire_seconds_priority(self):
41 | config_with_both = {'EXPIRE_SECONDS': 600, 'EXPIRE_MINUTES': 10}
42 | expected_expire_normalized = (
43 | 600 # Expecting EXPIRE_SECONDS to take priority
44 | )
45 |
46 | if 'EXPIRE_SECONDS' in config_with_both:
47 | expire_normalized = config_with_both['EXPIRE_SECONDS']
48 | elif 'EXPIRE_MINUTES' in config_with_both:
49 | expire_normalized = config_with_both['EXPIRE_MINUTES'] * 60
50 | else:
51 | expire_normalized = EXPIRE_NORMALIZED # Or set a default value
52 |
53 | self.assertEqual(expire_normalized, expected_expire_normalized)
54 |
55 | def test_only_expire_minutes(self):
56 | config_with_minutes = {'EXPIRE_MINUTES': 10}
57 | expected_expire_normalized = 600 # 10 minutes in seconds
58 |
59 | if 'EXPIRE_SECONDS' in config_with_minutes:
60 | expire_normalized = config_with_minutes['EXPIRE_SECONDS']
61 | elif 'EXPIRE_MINUTES' in config_with_minutes:
62 | expire_normalized = config_with_minutes['EXPIRE_MINUTES'] * 60
63 | else:
64 | expire_normalized = EXPIRE_NORMALIZED # Or set a default value
65 |
66 | self.assertEqual(expire_normalized, expected_expire_normalized)
67 |
68 | def test_no_expire_values(self):
69 | config_without_expire = {}
70 | expected_expire_normalized = (
71 | EXPIRE_NORMALIZED # Assuming no default value is set
72 | )
73 |
74 | if 'EXPIRE_SECONDS' in config_without_expire:
75 | expire_normalized = config_without_expire['EXPIRE_SECONDS']
76 | elif 'EXPIRE_MINUTES' in config_without_expire:
77 | expire_normalized = config_without_expire['EXPIRE_MINUTES'] * 60
78 | else:
79 | expire_normalized = EXPIRE_NORMALIZED # Or set a default value
80 |
81 | self.assertEqual(expire_normalized, expected_expire_normalized)
82 |
83 | def test_expire_minutes_without_expire_seconds(self):
84 | config = {'EXPIRE_MINUTES': 10}
85 | expected_expire_normalized = 600 # 10 minutes in seconds
86 |
87 | captcha = CAPTCHA(config)
88 | self.assertEqual(captcha.expire_secs, expected_expire_normalized)
89 |
90 | def test_config_with_expire_minutes_only(self):
91 | config_with_expire_minutes = {
92 | 'SECRET_CAPTCHA_KEY': 'TestKey',
93 | 'CAPTCHA_LENGTH': 6,
94 | 'EXPIRE_MINUTES': 5,
95 | }
96 | captcha_instance = CAPTCHA(config_with_expire_minutes)
97 | self.assertEqual(captcha_instance.expire_secs, 300)
98 |
99 |
100 | class TestImg(unittest.TestCase):
101 | def test_convert_b64img(self):
102 | img = Image.new('RGB', (60, 30), color=(73, 109, 137))
103 | b64image = convert_b64img(img)
104 | self.assertIsInstance(b64image, str)
105 |
106 | @patch('flask_simple_captcha.img.Image', autospec=True)
107 | def test_save_png(self, mock_image):
108 | img = Image.new('RGB', (1, 2), color=(1, 2, 3))
109 | with patch.object(img, 'save') as mock_save:
110 | convert_b64img(img, 'PNG')
111 | mock_save.assert_called_once_with(ANY, format='PNG')
112 |
113 | def test_nodrawer_lines(self):
114 | im = Image.new('RGB', (100, 100))
115 | with patch('flask_simple_captcha.img.ImageDraw') as mock_draw:
116 | draw_lines(im)
117 | mock_draw.Draw.assert_called_once_with(im)
118 |
119 |
120 | class TestCAPTCHA(unittest.TestCase):
121 | def setUp(self):
122 | self.config = DEFAULT_CONFIG
123 | self.config['EXPIRE_NORMALIZED'] = 60
124 | self.captcha = CAPTCHA(self.config)
125 |
126 | def test_create(self):
127 | result = self.captcha.create()
128 | self.assertIn('img', result)
129 | self.assertIn('text', result)
130 | self.assertIn('hash', result)
131 |
132 | def test_verify_valid(self):
133 | result = self.captcha.create()
134 | text = result['text']
135 | c_hash = result['hash']
136 | self.assertTrue(self.captcha.verify(text, c_hash))
137 |
138 | def test_verify_invalid(self):
139 | result = self.captcha.create()
140 | text = 'invalid_text'
141 | c_hash = result['hash']
142 | self.assertFalse(self.captcha.verify(text, c_hash))
143 |
144 | def test_verify_duplicate(self):
145 | result = self.captcha.create()
146 | text = result['text']
147 | c_hash = result['hash']
148 | self.assertTrue(self.captcha.verify(text, c_hash))
149 | self.assertFalse(self.captcha.verify(text, c_hash))
150 |
151 | def test_captcha_html(self):
152 | captcha = {'img': 'example_img', 'hash': 'example_hash'}
153 | html = self.captcha.captcha_html(captcha)
154 | self.assertIn('example_img', html)
155 | self.assertIn('example_hash', html)
156 |
157 | def test_get_background(self):
158 | text_size = (50, 50)
159 | bg = self.captcha.get_background(text_size)
160 | self.assertEqual(bg.size, (text_size[0], text_size[1]))
161 |
162 | def test_repr(self):
163 | repr_str = repr(self.captcha)
164 | self.assertIsInstance(repr_str, str)
165 |
166 | def test_init_app(self):
167 | with patch("flask.Flask") as MockFlask:
168 | mock_app = MockFlask()
169 | ret_app = self.captcha.init_app(mock_app)
170 | mock_app.jinja_env.globals.update.assert_called()
171 | self.assertEqual(ret_app, mock_app)
172 |
173 | def test_reversed_args(self):
174 | result = self.captcha.create()
175 | c_text = result['text']
176 | c_hash = result['hash']
177 |
178 | self.assertTrue(self.captcha.verify(c_hash, c_text))
179 |
180 | def test_jwt_expiration(self):
181 | # Creating a captcha with a very short expiration time
182 | self.config['EXPIRE_NORMALIZED'] = 0
183 | self.captcha = CAPTCHA(self.config)
184 |
185 | result = self.captcha.create()
186 | text = result['text']
187 | c_hash = result['hash']
188 |
189 | self.assertFalse(self.captcha.verify(result['text'], result['hash']))
190 |
191 | self.config['EXPIRE_NORMALIZED'] = 60
192 | self.captcha = CAPTCHA(self.config)
193 | result = self.captcha.create()
194 | self.assertTrue(self.captcha.verify(result['text'], result['hash']))
195 |
196 | def test_arg_order(self):
197 | result = self.captcha.create()
198 | text = result['text']
199 | c_hash = result['hash']
200 |
201 | self.assertTrue(self.captcha.verify(c_hash, text))
202 |
203 | result = self.captcha.create()
204 | text = result['text']
205 | c_hash = result['hash']
206 | self.assertTrue(self.captcha.verify(text, c_hash))
207 |
208 | def test_backwards_defaults(self):
209 | """ensures backward compatibility with v1.0 default assumptions"""
210 | for i in range(5):
211 | result = self.captcha.create()
212 | for c in result['text']:
213 | self.assertIn(c, string.ascii_uppercase)
214 |
215 | def test_expire_seconds(self):
216 | """ensures expire_seconds is set correctly"""
217 | del self.config['EXPIRE_NORMALIZED']
218 | self.config['EXPIRE_SECONDS'] = 100
219 | self.captcha = CAPTCHA(self.config)
220 | self.assertEqual(self.captcha.expire_secs, 100)
221 |
222 | @patch(
223 | 'flask_simple_captcha.captcha_generation.DEFAULT_CONFIG',
224 | {'EXPIRE_SECONDS': 99},
225 | )
226 | def test_default_expire_seconds(self):
227 | conf = DEFAULT_CONFIG.copy()
228 | del conf['EXPIRE_NORMALIZED']
229 | del conf['EXPIRE_SECONDS']
230 |
231 | captcha = CAPTCHA(conf)
232 | self.assertEqual(captcha.expire_secs, 99)
233 |
234 | @patch('flask_simple_captcha.captcha_generation.CHARPOOL', 'Z')
235 | def test_charpool(self):
236 | conf = DEFAULT_CONFIG.copy()
237 |
238 | cap = CAPTCHA(conf)
239 | self.assertEqual(cap.characters, ('Z',))
240 |
241 | def test_charpool_in_config(self):
242 | conf = DEFAULT_CONFIG.copy()
243 | conf['CHARACTER_POOL'] = 'X'
244 |
245 | cap = CAPTCHA(conf)
246 | self.assertEqual(cap.characters, ('X',))
247 |
248 | def test_only_upper_false(self):
249 | conf = DEFAULT_CONFIG.copy()
250 | testchars = 'AaBb'
251 | conf['ONLY_UPPERCASE'] = False
252 | conf['CHARACTER_POOL'] = testchars
253 |
254 | cap = CAPTCHA(conf)
255 | for c in cap.characters:
256 | self.assertIn(c, testchars)
257 |
258 | def test_captcha_digits(self):
259 | conf = DEFAULT_CONFIG.copy()
260 | conf['CAPTCHA_DIGITS'] = True
261 |
262 | cap = CAPTCHA(conf)
263 | for c in ['2', '3', '4', '5', '6', '7', '8', '9']:
264 | self.assertIn(c, cap.characters)
265 |
266 | @patch('flask_simple_captcha.captcha_generation.jwtdecrypt')
267 | def test_decrypt_text_nomatch(self, mock_jwtdecrypt):
268 | mock_jwtdecrypt.return_value = 'afsddsgfewfewggwegfw'
269 | conf = DEFAULT_CONFIG.copy()
270 | cap = CAPTCHA(conf)
271 | cap.create()
272 | self.assertFalse(cap.verify('test', 'test'))
273 |
274 |
275 | class TestCaptchaUtils(unittest.TestCase):
276 | def test_jwtencrypt(self):
277 | token = jwtencrypt(_TESTTEXT, _TESTKEY, expire_seconds=100)
278 | decoded = jwt.decode(token, _TESTKEY, algorithms=['HS256'])
279 |
280 | self.assertAlmostEqual(int(decoded['exp']), int(time.time() + 100))
281 | decrypted_text = jwtdecrypt(token, _TESTTEXT, _TESTKEY)
282 | self.assertEqual(decrypted_text, _TESTTEXT)
283 |
284 | def test_jwtdecrypt_valid_token(self):
285 | token = jwtencrypt(_TESTTEXT)
286 | decoded_text = jwtdecrypt(token, _TESTTEXT)
287 | self.assertEqual(decoded_text, _TESTTEXT)
288 |
289 | def test_jwtdecrypt_invalid_token(self):
290 | expired_token = jwtencrypt(_TESTTEXT, expire_seconds=-100)
291 | self.assertIsNone(jwtdecrypt(expired_token, _TESTTEXT))
292 |
293 | invalid_token = "invalid.token.here"
294 | self.assertIsNone(jwtdecrypt(invalid_token, _TESTTEXT))
295 |
296 | def test_exclude_similar_chars(self):
297 | self.assertEqual(exclude_similar_chars("oOlI1A"), "A")
298 |
299 | def test_gen_captcha_text(self):
300 | deftext = gen_captcha_text()
301 |
302 | for c in deftext:
303 | self.assertIn(c, CHARPOOL)
304 | self.assertTrue(c.upper() == c)
305 | self.assertTrue(c in string.ascii_uppercase)
306 |
307 | # LENGTH / Pool / Add Digits / Only Uppercase
308 | AaDigits_text = gen_captcha_text(
309 | length=500, charpool="Aa", add_digits=True, only_uppercase=True
310 | )
311 |
312 | aachars = tuple(set('A' + string.digits))
313 |
314 | self.assertTrue(AaDigits_text.isalnum())
315 | self.assertTrue(AaDigits_text.isupper())
316 |
317 | for c in AaDigits_text:
318 | self.assertIn(c, aachars)
319 |
320 | self.assertTrue(len(AaDigits_text) == 500)
321 |
322 | # only uppercase behaviour
323 | oa_text = gen_captcha_text(
324 | length=100, charpool='Aa', only_uppercase=False
325 | )
326 | for c in oa_text:
327 | self.assertIn(c, 'Aa')
328 |
329 | oa_text = gen_captcha_text(length=100, charpool='Aa')
330 | for c in oa_text:
331 | self.assertIn(c, 'Aa')
332 |
333 | def test_hashed_text(self):
334 | hashed_text = hash_text(_TESTTEXT, _TESTKEY)
335 | self.assertTrue(check_password_hash(hashed_text, _TESTKEY + _TESTTEXT))
336 |
337 | @patch('flask_simple_captcha.utils.jwt.decode')
338 | def test_no_hashed_text(self, mock_jwtdecode):
339 | mock_jwtdecode.return_value = {'not': 'in'}
340 | hashed_text = jwtdecrypt(_TESTTEXT, _TESTKEY)
341 | self.assertIsNone(hashed_text)
342 |
343 | @patch('flask_simple_captcha.utils.EXCHARS', 'abcd')
344 | def test_exclude_similar_chars(self):
345 | # pass fake args
346 | testset = {'a', 'b', 'c', 'd', 'e'}
347 | exchars = exclude_similar_chars(testset)
348 | self.assertEqual(exchars, {'e'})
349 |
350 | testlist = ['a', 'b', 'c', 'd', 'e']
351 | exchars = exclude_similar_chars(testlist)
352 | self.assertEqual(exchars, ['e'])
353 |
354 |
355 | class TestText(unittest.TestCase):
356 | def setUp(self):
357 | self.fonts = CAPTCHA_FONTS
358 |
359 | def test_repr_format(self):
360 | _font = self.fonts[0]
361 | _name = _font.name
362 |
363 | font = CaptchaFont(self.fonts[0].path)
364 | self.assertEqual(repr(font), '' % _name)
365 |
366 | def test_get_font(self):
367 | font = get_font('RobotoMono-Bold', self.fonts)
368 | self.assertIsInstance(font, CaptchaFont)
369 |
370 | def test_get_font_by_filename(self):
371 | font = get_font('RobotoMono-Bold.ttf', self.fonts)
372 | self.assertIsInstance(font, CaptchaFont)
373 |
374 | def test_get_font_by_path(self):
375 | font = get_font(self.fonts[0].path, self.fonts)
376 | self.assertIsInstance(font, CaptchaFont)
377 |
378 | def test_get_font_not_found(self):
379 | font = get_font('notfourqeqerfqwnd', self.fonts)
380 | self.assertIsNone(font)
381 |
382 | def test_use_text_fonts(self):
383 | conf = DEFAULT_CONFIG.copy()
384 | conf['USE_TEXT_FONTS'] = ['RobotoMono-Bold']
385 | cap = CAPTCHA(conf)
386 | self.assertEqual(len(cap.fonts), 1)
387 | self.assertEqual(cap.fonts[0].name, 'RobotoMono-Bold')
388 |
389 |
390 | class TestBackwardsCompatibleMethods(unittest.TestCase):
391 | def setUp(self):
392 | self.config = DEFAULT_CONFIG
393 | self.config['EXPIRE_NORMALIZED'] = 60
394 | self.captcha = CAPTCHA(self.config)
395 |
396 | @patch('flask_simple_captcha.captcha_generation.new_draw_lines')
397 | def test_draw_lines(self, mock_drawlines):
398 | self.captcha.draw_lines(Image.new('RGB', (100, 100)))
399 | mock_drawlines.assert_called_once_with(Image.new('RGB', (100, 100)))
400 |
401 |
402 | if __name__ == '__main__':
403 | unittest.main()
404 |
--------------------------------------------------------------------------------