├── .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 |
105 | {{ captcha_html(captcha)|safe }} 106 | 107 |
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 | ![Example CAPTCHA Image](/captcha-example.PNG) 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 |
99 | 100 | {{ render_field_with_errors(your_form.captcha, class_="form-control")}} 101 |
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 | '
%s
' % 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 | --------------------------------------------------------------------------------