├── .coveragerc ├── .flaskenv ├── .gitignore ├── .gitlab-ci.yml ├── .isort.cfg ├── Dockerfile ├── LICENSE ├── README.md ├── activate.sh ├── captcha_api ├── __init__.py ├── app.py ├── captcha.cfg.example ├── captcha_generator.py ├── celery_worker.py ├── db.py ├── log_utils.py ├── migrations │ ├── README │ ├── alembic.ini │ ├── env.py │ ├── script.py.mako │ └── versions │ │ └── ddca5caebdd6_add_captcha_table.py ├── models.py ├── rest.py ├── speech.py ├── static │ ├── captcha.js │ └── demo.html └── tasks.py ├── requirements.in ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── tools.py └── unit │ ├── __init__.py │ └── test_captcha_api.py └── wsgi.py /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | branch = True 4 | omit = ./tests*, 5 | ./.venv/* 6 | 7 | [report] 8 | # Regexes for lines to exclude from consideration 9 | exclude_lines = 10 | # Have to re-enable the standard pragma 11 | pragma: no cover 12 | 13 | # Don't complain about missing debug-only code: 14 | def __repr__ 15 | if self\.debug 16 | 17 | # Don't complain if tests don't hit defensive assertion code: 18 | raise AssertionError 19 | raise NotImplementedError 20 | 21 | # Don't complain if non-runnable code isn't run: 22 | if 0: 23 | if __name__ == .__main__.: 24 | 25 | ignore_errors = True 26 | 27 | [html] 28 | directory = coverage_html_report -------------------------------------------------------------------------------- /.flaskenv: -------------------------------------------------------------------------------- 1 | CAPTCHA_API_CONFIG=captcha.cfg 2 | FLASK_APP=captcha_api.app 3 | FLASK_DEBUG=1 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Adapter configurations for testing 7 | test_adapter_config*.py 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # celery beat schedule file 98 | celerybeat-schedule 99 | 100 | # SageMath parsed files 101 | *.sage.py 102 | 103 | # Environments 104 | .env 105 | .venv 106 | env/ 107 | venv/ 108 | ENV/ 109 | env.bak/ 110 | venv.bak/ 111 | 112 | # Spyder project settings 113 | .spyderproject 114 | .spyproject 115 | 116 | # Rope project settings 117 | .ropeproject 118 | 119 | # mkdocs documentation 120 | /site 121 | 122 | # mypy 123 | .mypy_cache/ 124 | .dmypy.json 125 | dmypy.json 126 | 127 | # Pyre type checker 128 | .pyre/ 129 | 130 | #Ours 131 | .vscode 132 | .idea 133 | config 134 | openshift 135 | coverage_html_report 136 | 137 | # Test database 138 | test.db 139 | 140 | # Config files 141 | captcha.cfg -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: python:3.11-slim 2 | variables: 3 | NAMESPACE_PROD: captcha 4 | APP_NAME: captcha-api 5 | PROD_TAG: latest 6 | OPENSHIFT_SERVER_PROD: https://api.paas.okd.cern.ch 7 | 8 | stages: 9 | - lint 10 | - test 11 | - build_docker 12 | - deploy 13 | 14 | .docker_build_template: &docker_definition 15 | stage: build_docker 16 | image: 17 | # We recommend using the CERN version of the Kaniko image: gitlab-registry.cern.ch/ci-tools/docker-image-builder 18 | name: gitlab-registry.cern.ch/ci-tools/docker-image-builder 19 | entrypoint: [""] 20 | script: 21 | # Prepare Kaniko configuration file 22 | - echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json 23 | # Build and push the image from the Dockerfile at the root of the project. 24 | # To push to a specific docker tag, amend the --destination parameter, e.g. --destination $CI_REGISTRY_IMAGE:$CI_BUILD_REF_NAME 25 | # See https://docs.gitlab.com/ee/ci/variables/predefined_variables.html#variables-reference for available variables 26 | - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination ${TO} 27 | 28 | .deploy_template: &deploy_definition 29 | stage: deploy 30 | image: gitlab-registry.cern.ch/paas-tools/openshift-client:latest 31 | script: 32 | # Adding || true to disable the error message when the image already exists 33 | - oc import-image ${APP_NAME} --from="${CI_REGISTRY_IMAGE}:${TAG}" --confirm --token=${OS_TOKEN} --server=${OPENSHIFT_SERVER} -n ${NAMESPACE} || true 34 | - oc tag "${CI_REGISTRY_IMAGE}:${TAG}" "${APP_NAME}:latest" --token=${OS_TOKEN} --server=${OPENSHIFT_SERVER} -n ${NAMESPACE} 35 | 36 | ### Linting 37 | flake8: 38 | image: python:3.11-slim 39 | stage: lint 40 | before_script: 41 | - apt-get update && apt-get install -y -qq gcc 42 | - pip install flake8 43 | script: 44 | - python -m flake8 *.py captcha_api tests 45 | allow_failure: true 46 | 47 | 48 | ### Testing 49 | test: 50 | stage: test 51 | before_script: 52 | - export PIP_CONFIG_FILE=$(pwd)/pip.conf 53 | - apt-get update && apt-get install -y -qq libfreetype6 fontconfig-config espeak ffmpeg libtiff5-dev libopenjp2-7-dev zlib1g-dev python3-tk gcc libfreetype6-dev 54 | - pip install -e '.[dev]' 55 | script: 56 | - coverage run -m pytest 57 | - coverage html 58 | - coverage xml 59 | - coverage report 60 | artifacts: 61 | reports: 62 | coverage_report: 63 | coverage_format: cobertura 64 | path: coverage.xml 65 | paths: 66 | - coverage_html_report 67 | expire_in: 1 week 68 | 69 | ### Docker build definitions 70 | 71 | build_docker_prod: 72 | <<: *docker_definition 73 | variables: 74 | TO: ${CI_REGISTRY_IMAGE}:${PROD_TAG} 75 | only: 76 | - master # the branch you want to publish 77 | 78 | ### Deployment definitions 79 | deploy_prod: 80 | <<: *deploy_definition 81 | variables: 82 | ENVIRONMENT: prod 83 | OS_TOKEN: ${OPENSHIFT_DEPLOY_TOKEN} 84 | OPENSHIFT_SERVER: ${OPENSHIFT_SERVER_PROD} 85 | NAMESPACE: ${NAMESPACE_PROD} 86 | TAG: ${PROD_TAG} 87 | environment: 88 | name: prod 89 | url: https://captcha.web.cern.ch 90 | only: 91 | - master 92 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [isort] 2 | multi_line_output=3 3 | include_trailing_comma=True 4 | force_grid_wrap=0 5 | use_parentheses=True 6 | line_length=100 7 | lines_after_imports=2 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/library/python:3.11-slim 2 | 3 | RUN apt-get update && apt-get install -y -qq libfreetype6 fontconfig-config espeak ffmpeg libtiff5-dev libopenjp2-7-dev zlib1g-dev python3-tk gcc libfreetype6-dev 4 | 5 | WORKDIR /app 6 | COPY . . 7 | ENV PIP_CONFIG_FILE /app/pip.conf 8 | 9 | RUN pip install --no-cache-dir -r requirements.txt 10 | 11 | EXPOSE 8080 12 | CMD [ "gunicorn", "--bind", "0.0.0.0:8080", "wsgi:app" ] 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Captcha API 2 | 3 | This project contains a simple Captcha API that returns an encoded image containing 4 | letters a-Z and numbers 1-9 via a GET endpoint. The POST endpoint can be used to validate the 5 | captcha. 6 | 7 | A Dockerfile is provided for containerization. 8 | 9 | ## Running with Docker 10 | 11 | Build the Docker image 12 | 13 | ``` 14 | docker build . -t captcha-api 15 | ``` 16 | 17 | Run the Docker image 18 | 19 | ``` 20 | docker run -d --name captcha -p 8080:8080 -e CAPTCHA_API_CONFIG=captcha.cfg captcha-api 21 | ``` 22 | 23 | Navigate to `http://localhost:8080/swagger-ui` 24 | 25 | ## Running locally 26 | 27 | This guide is written for users of Debian-based systems, the package names might differ on other operating systems such as CentOS. 28 | 29 | For usage on Windows, please use the WSL2 distribution in order to build and run the application. 30 | 31 | ### Installing the required libraries 32 | 33 | Unfortunately we need some binary dependencies, since PIL and the audio CAPTCHA generation require them. Run the following command: 34 | 35 | ```bash 36 | sudo apt-get install libtiff5-dev libopenjp2-7-dev zlib1g-dev \ 37 | libfreetype6-dev liblcms2-dev libwebp-dev tcl8.6-dev tk8.6-dev python3-tk \ 38 | libharfbuzz-dev libfribidi-dev libxcb1-dev ffmpeg espeak python3-dev 39 | ``` 40 | 41 | ### Installing and running the application 42 | 43 | Run 44 | 45 | ```bash 46 | pip install -e '.[dev]' 47 | ``` 48 | 49 | in order to install all the required dependencies for development. 50 | 51 | To start the server locally, you need to create a `captcha.cfg` file. Just `cp captcha_api/captcha.cfg.example captcha_api/captcha.cfg` to get started. 52 | 53 | Running the server is done by running: 54 | 55 | ``` 56 | flask run 57 | ``` 58 | 59 | or 60 | 61 | ``` 62 | gunicorn --bind 0.0.0.0:5000 wsgi:app 63 | ``` 64 | 65 | ## Audio CAPTCHA 66 | 67 | For accessibility reasons, one might one to listen to the CAPTCHA message. In order to do that, you can point to the following endpoint: 68 | 69 | ``` 70 | /api/v1.0/captcha/audio/$CAPTCHA_ID 71 | ``` 72 | 73 | The file returned is in the `mp3` format and can be easily loaded into an HTML form as such: 74 | 75 | ```html 76 |
77 | 80 |
81 | ``` 82 | 83 | 84 | ## Running migrations 85 | 86 | Make sure you installed the dependencies using `pip install -e .`. 87 | 88 | Afterwards, run `flask db upgrade` to bring your DB to the latest level. By default it will use a `test.db` SQLite file in the `captcha_api` folder. 89 | 90 | 91 | ## Embedded captcha.js 92 | 93 | The Captcha API includes a static Javascript file to help with simple web form integrations. To use or test this script: 94 | 95 | 1. Configure the `captchaApiUrl` variable in `captcha_api/static/captcha.js`. 96 | 2. Run the Captcha API. 97 | 3. Include the script in your page, and the HTML element `
` in your form (see the example in `captcha_api/static/demo.html`). 98 | 4. When processing your form, validate `captchaAnswer` and `captchaId` with the `POST` endpoint of the Captcha API. 99 | 100 | Example for the production Captcha API: 101 | 102 | ```html 103 | 104 | 105 | 106 | 107 |
108 |
109 | 110 |
111 | 112 | 113 | ``` 114 | -------------------------------------------------------------------------------- /activate.sh: -------------------------------------------------------------------------------- 1 | if [ -s ./.venv ] ; then 2 | . ./.venv/bin/activate ; 3 | else 4 | echo 'No venv defined, creating...' ; 5 | venvprompt=${PWD##*/} 6 | python -m venv --prompt $venvprompt .venv ; 7 | . ./.venv/bin/activate ; 8 | fi; 9 | 10 | -------------------------------------------------------------------------------- /captcha_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CERN/captcha-api/ad53e2c9662a8473572bffec1369d6158a053d2c/captcha_api/__init__.py -------------------------------------------------------------------------------- /captcha_api/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from celery import Celery 4 | from flask import Blueprint, Flask, redirect 5 | from flask_cors import CORS 6 | from werkzeug.middleware.proxy_fix import ProxyFix 7 | 8 | from .db import db, migrate 9 | from .log_utils import configure_logging 10 | from .rest import api 11 | 12 | index_bp = Blueprint("index", __name__) 13 | 14 | celery = Celery() 15 | 16 | 17 | @index_bp.route("/") 18 | def index(): 19 | return redirect("/swagger-ui") 20 | 21 | 22 | def _read_env_config(app: Flask): 23 | try: 24 | app.config.from_envvar("CAPTCHA_API_CONFIG") 25 | except Exception as e: 26 | app.logger.error(e) 27 | 28 | 29 | def _setup_api(app: Flask): 30 | api.version = app.config["API_VERSION"] 31 | api.prefix = f"/api/{api.version}" 32 | api.init_app(app) 33 | 34 | 35 | def _setup_celery(app): 36 | """Sets up Celery as a background task runner for the application.""" 37 | if app.config.get("USE_CELERY", False): 38 | celery.conf.broker_url = app.config["CELERY_BROKER_URL"] 39 | celery.conf.result_backend = app.config["CELERY_RESULT_BACKEND"] 40 | celery.conf.update(app.config) 41 | 42 | class ContextTask(celery.Task): 43 | def __call__(self, *args, **kwargs): 44 | with app.app_context(): 45 | return self.run(*args, **kwargs) 46 | 47 | celery.Task = ContextTask 48 | else: 49 | app.logger.warning("Celery is disabled!") 50 | 51 | 52 | def _setup_db(app): 53 | app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False 54 | db.init_app(app) 55 | migrate.init_app(app, directory=os.path.join(app.root_path, "migrations")) 56 | 57 | 58 | def _configure_app(app, from_env=True): 59 | app.config.from_pyfile("captcha.cfg.example") 60 | if from_env: 61 | _read_env_config(app) 62 | 63 | 64 | def create_app(config_override=None, use_env_config=True) -> Flask: 65 | app = Flask(__name__) 66 | app.url_map.strict_slashes = False 67 | app.logger = configure_logging() 68 | 69 | if config_override: 70 | app.config.update(config_override) 71 | _configure_app(app, use_env_config) 72 | 73 | app.wsgi_app = ProxyFix(app.wsgi_app) 74 | CORS(app) 75 | 76 | _setup_db(app) 77 | _setup_api(app) 78 | 79 | # Create a Celery connection 80 | _setup_celery(app) 81 | 82 | # Blueprints 83 | app.register_blueprint(index_bp) 84 | 85 | return app 86 | -------------------------------------------------------------------------------- /captcha_api/captcha.cfg.example: -------------------------------------------------------------------------------- 1 | API_VERSION = "v1.0" 2 | 3 | # Local database 4 | SQLALCHEMY_DATABASE_URI = "sqlite:///test.db" 5 | DEFAULT_CAPTCHA_FONT = "DejaVuSerif.ttf" 6 | 7 | # Set to True for Celery background tasks functionality 8 | USE_CELERY = False 9 | 10 | CELERY_BROKER_URL = "redis://localhost:6379" 11 | CELERY_RESULT_BACKEND = "redis://localhost:6379" 12 | -------------------------------------------------------------------------------- /captcha_api/captcha_generator.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from random import randint 3 | from typing import Tuple 4 | 5 | from PIL import Image, ImageDraw, ImageFont, ImageOps 6 | 7 | 8 | def _get_random_color(): 9 | # random color rgb 10 | return randint(120, 200), randint(120, 200), randint(120, 200) 11 | 12 | 13 | def _get_random_code(): 14 | # random characters 15 | codes = [ 16 | [chr(i) for i in range(49, 58)], 17 | [chr(i) for i in range(65, 91)], 18 | [chr(i) for i in range(97, 123)], 19 | ] 20 | codes = codes[randint(0, 2)] 21 | return codes[randint(0, len(codes) - 1)] 22 | 23 | 24 | def _generate_rotated_char(c, font): 25 | txt = Image.new("L", font.getsize(c)) 26 | blank_image = ImageDraw.Draw(txt) 27 | blank_image.text((0, 0), c, font=font, fill=255) 28 | rotated_text = txt.rotate(randint(-50, 50), expand=1) 29 | return rotated_text 30 | 31 | 32 | class CaptchaGenerator: 33 | """ 34 | Generates captcha images based on the parameters 35 | """ 36 | 37 | def __init__(self, fontname="DejaVuSerif.ttf", width=250, height=60): 38 | self.width = width 39 | self.height = height 40 | self.font = ImageFont.truetype(fontname, size=36) 41 | 42 | def generate_captcha(self, length=6) -> Tuple[BytesIO, str]: 43 | """ 44 | Generate a captcha image 45 | :return: A tuple consisting of the image bytes and the text 46 | """ 47 | img = Image.new("RGB", (self.width, self.height), (250, 250, 250)) 48 | draw = ImageDraw.Draw(img) 49 | # captcha text 50 | text = "" 51 | for i in range(length): 52 | char = _get_random_code() 53 | text += char 54 | 55 | rotated = _generate_rotated_char(char, self.font) 56 | colorized = ImageOps.colorize(rotated, (0, 0, 0), _get_random_color()) 57 | img.paste( 58 | colorized, 59 | (int(self.width * 0.13 * (i + 1)), int(self.height * 0.2)), 60 | rotated, 61 | ) 62 | # add interference line 63 | for i in range(15): 64 | x_1 = randint(0, self.width) 65 | y_1 = randint(0, self.height) 66 | x_2 = randint(0, self.width) 67 | y_2 = randint(0, self.height) 68 | draw.line((x_1, y_1, x_2, y_2), fill=_get_random_color()) 69 | # add interference point 70 | for i in range(16): 71 | draw.point( 72 | (randint(0, self.width), randint(0, self.height)), 73 | fill=_get_random_color(), 74 | ) 75 | # save the picture 76 | img_byte_array = BytesIO() 77 | img.save(img_byte_array, format="jpeg") 78 | return img_byte_array, text 79 | -------------------------------------------------------------------------------- /captcha_api/celery_worker.py: -------------------------------------------------------------------------------- 1 | from celery.schedules import crontab 2 | 3 | from .app import celery, create_app 4 | from .tasks import delete_old_captchas 5 | 6 | 7 | app = create_app() 8 | 9 | 10 | @celery.on_after_configure.connect 11 | def setup_periodic_tasks(sender, **kwargs): 12 | # Executes every hour the delete old captchas task 13 | sender.add_periodic_task( 14 | crontab(minute=0, hour="*/1"), 15 | delete_old_captchas.s(), 16 | ) 17 | -------------------------------------------------------------------------------- /captcha_api/db.py: -------------------------------------------------------------------------------- 1 | from flask_migrate import Migrate 2 | from flask_sqlalchemy import SQLAlchemy 3 | 4 | 5 | db = SQLAlchemy() 6 | 7 | migrate = Migrate(db=db) 8 | -------------------------------------------------------------------------------- /captcha_api/log_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | 5 | def configure_logging(): 6 | """Logging setup""" 7 | logger = logging.getLogger(__name__) 8 | logger.setLevel(logging.DEBUG) 9 | formatter = logging.Formatter( 10 | "%(asctime)s %(levelname)s %(filename)s:%(lineno)d - %(message)s" 11 | ) 12 | 13 | # adds console handler to logger instance the first time this code is called 14 | # avoids adding extra handlers to the instance, which causes duplicate logs msgs 15 | if not len(logger.handlers): 16 | console = logging.StreamHandler(sys.stdout) 17 | console.setFormatter(formatter) 18 | logger.addHandler(console) 19 | 20 | # Requests logs some stuff at INFO that we don't want 21 | # unless we have DEBUG 22 | requests_log = logging.getLogger("requests") 23 | requests_log.setLevel(logging.ERROR) 24 | return logger 25 | -------------------------------------------------------------------------------- /captcha_api/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /captcha_api/migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /captcha_api/migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import logging 4 | from logging.config import fileConfig 5 | 6 | from alembic import context 7 | 8 | # add your model's MetaData object here 9 | # for 'autogenerate' support 10 | # from myapp import mymodel 11 | # target_metadata = mymodel.Base.metadata 12 | from flask import current_app 13 | from sqlalchemy import engine_from_config, pool 14 | 15 | 16 | # this is the Alembic Config object, which provides 17 | # access to the values within the .ini file in use. 18 | config = context.config 19 | 20 | # Interpret the config file for Python logging. 21 | # This line sets up loggers basically. 22 | fileConfig(config.config_file_name) 23 | logger = logging.getLogger("alembic.env") 24 | 25 | 26 | config.set_main_option( 27 | "sqlalchemy.url", 28 | str(current_app.extensions["migrate"].db.engine.url).replace("%", "%%"), 29 | ) 30 | target_metadata = current_app.extensions["migrate"].db.metadata 31 | 32 | # other values from the config, defined by the needs of env.py, 33 | # can be acquired: 34 | # my_important_option = config.get_main_option("my_important_option") 35 | # ... etc. 36 | 37 | 38 | def run_migrations_offline(): 39 | """Run migrations in 'offline' mode. 40 | 41 | This configures the context with just a URL 42 | and not an Engine, though an Engine is acceptable 43 | here as well. By skipping the Engine creation 44 | we don't even need a DBAPI to be available. 45 | 46 | Calls to context.execute() here emit the given string to the 47 | script output. 48 | 49 | """ 50 | url = config.get_main_option("sqlalchemy.url") 51 | context.configure(url=url, target_metadata=target_metadata, literal_binds=True) 52 | 53 | with context.begin_transaction(): 54 | context.run_migrations() 55 | 56 | 57 | def run_migrations_online(): 58 | """Run migrations in 'online' mode. 59 | 60 | In this scenario we need to create an Engine 61 | and associate a connection with the context. 62 | 63 | """ 64 | 65 | # this callback is used to prevent an auto-migration from being generated 66 | # when there are no changes to the schema 67 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 68 | def process_revision_directives(context, revision, directives): 69 | if getattr(config.cmd_opts, "autogenerate", False): 70 | script = directives[0] 71 | if script.upgrade_ops.is_empty(): 72 | directives[:] = [] 73 | logger.info("No changes in schema detected.") 74 | 75 | connectable = engine_from_config( 76 | config.get_section(config.config_ini_section), 77 | prefix="sqlalchemy.", 78 | poolclass=pool.NullPool, 79 | ) 80 | 81 | with connectable.connect() as connection: 82 | context.configure( 83 | connection=connection, 84 | target_metadata=target_metadata, 85 | process_revision_directives=process_revision_directives, 86 | **current_app.extensions["migrate"].configure_args 87 | ) 88 | 89 | with context.begin_transaction(): 90 | context.run_migrations() 91 | 92 | 93 | if context.is_offline_mode(): 94 | run_migrations_offline() 95 | else: 96 | run_migrations_online() 97 | -------------------------------------------------------------------------------- /captcha_api/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /captcha_api/migrations/versions/ddca5caebdd6_add_captcha_table.py: -------------------------------------------------------------------------------- 1 | """Add captcha table 2 | 3 | Revision ID: ddca5caebdd6 4 | Revises: 5 | Create Date: 2020-09-18 17:47:11.382882 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "ddca5caebdd6" 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.create_table( 21 | "captcha", 22 | sa.Column("id", sa.String(length=36), nullable=False), 23 | sa.Column("answer", sa.String(length=120), nullable=False), 24 | sa.Column("creation_time", sa.DateTime(), nullable=False), 25 | sa.PrimaryKeyConstraint("id"), 26 | ) 27 | 28 | 29 | def downgrade(): 30 | op.drop_table("captcha") 31 | -------------------------------------------------------------------------------- /captcha_api/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from .db import db 4 | 5 | 6 | class Captcha(db.Model): 7 | id = db.Column(db.String(36), primary_key=True) 8 | answer = db.Column(db.String(120), nullable=False) 9 | creation_time = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) 10 | 11 | def __repr__(self): 12 | return "" % self.id 13 | -------------------------------------------------------------------------------- /captcha_api/rest.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode 2 | from datetime import datetime, timedelta 3 | from uuid import uuid4 4 | 5 | from flask import request, send_file 6 | from flask_restx import Api, Resource, fields 7 | 8 | from .captcha_generator import CaptchaGenerator 9 | from .db import db 10 | from .models import Captcha 11 | from .speech import text_to_speech 12 | 13 | 14 | api = Api( 15 | title="CAPTCHA API", 16 | description="A simple API for handling CAPTCHA", 17 | security={"oauth2": ["api"]}, 18 | doc="/swagger-ui", 19 | ) 20 | 21 | 22 | captcha_ns = api.namespace( 23 | "captcha", description="Utilities for validating and generating CAPTCHA" 24 | ) 25 | 26 | 27 | captcha_model = captcha_ns.model( 28 | "CaptchaAnswer", {"answer": fields.String, "id": fields.String} 29 | ) 30 | 31 | 32 | def get_request_data(request): 33 | """ 34 | Gets the data from the request 35 | """ 36 | # https://stackoverflow.com/questions/10434599/how-to-get-data-received-in-flask-request/25268170 37 | data = request.form.to_dict() if request.form else request.get_json() 38 | if not data: 39 | return {} 40 | return data 41 | 42 | 43 | @captcha_ns.route("/") 44 | class CaptchaResource(Resource): 45 | """ 46 | Handling captchas 47 | """ 48 | 49 | def __init__(self, api=None, *args, **kwargs): 50 | super().__init__(api=api, *args, **kwargs) 51 | self.generator = CaptchaGenerator( 52 | fontname=api.app.config["DEFAULT_CAPTCHA_FONT"] 53 | ) 54 | 55 | def get(self): 56 | """ 57 | Generate a new captcha text 58 | """ 59 | img_array, answer = self.generator.generate_captcha() 60 | captcha_id = str(uuid4()) 61 | new_captcha = Captcha(id=captcha_id, answer=answer) 62 | db.session.add(new_captcha) 63 | db.session.commit() 64 | return { 65 | "id": captcha_id, 66 | "img": "data:image/jpeg;base64," + b64encode(img_array.getvalue()).decode(), 67 | } 68 | 69 | @captcha_ns.doc(body=captcha_model) 70 | def post(self): 71 | """ 72 | Solve a captcha and match it with the database thing 73 | """ 74 | data = get_request_data(request) 75 | 76 | existing = Captcha.query.filter_by(id=data["id"]).first() 77 | if not existing: 78 | return {"message": "Not found"}, 404 79 | 80 | time_difference = datetime.utcnow() - existing.creation_time 81 | if time_difference > timedelta(minutes=1): 82 | db.session.delete(existing) 83 | db.session.commit() 84 | return {"message": "You did not answer fast enough!"}, 400 85 | 86 | if data["answer"].casefold() != existing.answer.casefold(): 87 | db.session.delete(existing) 88 | db.session.commit() 89 | return {"message": "Invalid answer"}, 400 90 | 91 | db.session.delete(existing) 92 | db.session.commit() 93 | return {"message": "Valid"} 94 | 95 | 96 | @captcha_ns.route("/audio/") 97 | class CaptchaAudioResource(Resource): 98 | """ 99 | Sending audio recordings for captchas 100 | """ 101 | 102 | def get(self, captcha_id): 103 | """ 104 | Generate a new captcha text for the given captcha 105 | """ 106 | existing_captcha = Captcha.query.get_or_404(captcha_id) 107 | split_answer = ", ".join(existing_captcha.answer) 108 | mp3_file = text_to_speech(split_answer) 109 | 110 | return send_file( 111 | mp3_file, 112 | as_attachment=True, 113 | max_age=-1, 114 | download_name="captcha.mp3", 115 | mimetype="audio/mpeg", 116 | ) 117 | -------------------------------------------------------------------------------- /captcha_api/speech.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | from io import BytesIO 4 | from tempfile import mkstemp 5 | 6 | import pyttsx3 7 | 8 | 9 | def text_to_speech(text): 10 | """Converts a piece of text to an mp3 document.""" 11 | 12 | engine = pyttsx3.init() 13 | engine.setProperty("rate", 60) 14 | _, filename = mkstemp(suffix="-captcha.mp3") 15 | engine.save_to_file(text, filename) 16 | engine.runAndWait() 17 | engine.stop() 18 | # Required because of a weird FS error 19 | time.sleep(0.3) 20 | 21 | with open(filename, "rb") as fh: 22 | buf = BytesIO(fh.read()) 23 | buf.seek(0) 24 | # Cleanup 25 | if os.path.exists(filename): 26 | os.remove(filename) 27 | return buf 28 | -------------------------------------------------------------------------------- /captcha_api/static/captcha.js: -------------------------------------------------------------------------------- 1 | const captchaApiUrl = "https://captcha.web.cern.ch/api/v1.0"; 2 | 3 | class CaptchaApiClient { 4 | baseUrl = captchaApiUrl; 5 | 6 | _formatUrl(relativeUrl) { 7 | return `${this.baseUrl}/${relativeUrl}`; 8 | } 9 | 10 | async _request(relativeUrl, options = {}) { 11 | if (options.method !== "GET") { 12 | options.headers = { "Content-Type": "application/json" }; 13 | } 14 | return await (await fetch(this._formatUrl(relativeUrl), options)).json(); 15 | } 16 | 17 | getCaptcha() { 18 | return this._request("captcha"); 19 | } 20 | 21 | getCaptchaAudioUrl(captchaId) { 22 | return this._formatUrl(`captcha/audio/${captchaId}`); 23 | } 24 | } 25 | 26 | const Captcha = () => { 27 | 28 | const client = new CaptchaApiClient(); 29 | let captchaResponse; 30 | let showAudio; 31 | 32 | const reload = async () => { 33 | captchaResponse = await client.getCaptcha(); 34 | showAudio = false; 35 | document.getElementById("cern-captcha").innerHTML = template(captchaResponse.id, captchaResponse.img); 36 | document.getElementById("reload").addEventListener("click", reload); 37 | document.getElementById("show-audio").addEventListener("click", toggleAudio) 38 | } 39 | 40 | const toggleAudio = () => { 41 | showAudio = !showAudio; 42 | if (showAudio) { 43 | document.getElementById("audio").innerHTML = audioTemplate(client.getCaptchaAudioUrl(captchaResponse.id)); 44 | } else { 45 | document.getElementById("audio").innerHTML = ""; 46 | } 47 | document.getElementById("show-audio").innerText = `${showAudio ? "Hide " : "Show "} audio`; 48 | } 49 | 50 | const template = (id, img) => ` 51 | 84 | ` 85 | 86 | const audioTemplate = (audioUrl) => ` 87 | 90 | ` 91 | 92 | reload(); 93 | } 94 | 95 | document.addEventListener("load", Captcha()); 96 | -------------------------------------------------------------------------------- /captcha_api/static/demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |
7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /captcha_api/tasks.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from .app import celery 4 | from .db import db 5 | from .models import Captcha 6 | 7 | 8 | @celery.task 9 | def delete_old_captchas(): 10 | one_hour_ago = datetime.utcnow() - timedelta(hours=1) 11 | old_captchas = Captcha.query.filter(Captcha.creation_time <= one_hour_ago) 12 | old_captchas.delete() 13 | db.session.commit() 14 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | Flask==2.1.0 2 | gunicorn==20.1.0 3 | requests==2.23.* 4 | flask-restx==1.1.0 5 | flask_cors==3.0.* 6 | python-dotenv==0.12.0 7 | Flask-SQLAlchemy==2.5.1 8 | SQLAlchemy==1.4.37 9 | pillow==7.2.0 10 | celery==5.3.1 11 | redis==3.5.3 12 | pyttsx3==2.90 13 | flask-migrate==2.5.3 14 | itsdangerous==2.0.1 15 | certifi 16 | werkzeug==2.1.2 17 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # pip-compile 6 | # 7 | alembic==1.8.1 8 | # via flask-migrate 9 | amqp==5.1.1 10 | # via kombu 11 | aniso8601==9.0.1 12 | # via flask-restx 13 | attrs==22.1.0 14 | # via jsonschema 15 | billiard==4.1.0 16 | # via celery 17 | celery==5.3.1 18 | # via -r requirements.in 19 | certifi==2022.9.24 20 | # via 21 | # -r requirements.in 22 | # requests 23 | chardet==3.0.4 24 | # via requests 25 | click==8.1.3 26 | # via 27 | # celery 28 | # click-didyoumean 29 | # click-plugins 30 | # click-repl 31 | # flask 32 | click-didyoumean==0.3.0 33 | # via celery 34 | click-plugins==1.1.1 35 | # via celery 36 | click-repl==0.3.0 37 | # via celery 38 | flask==2.1.0 39 | # via 40 | # -r requirements.in 41 | # flask-cors 42 | # flask-migrate 43 | # flask-restx 44 | # flask-sqlalchemy 45 | flask-cors==3.0.10 46 | # via -r requirements.in 47 | flask-migrate==2.5.3 48 | # via -r requirements.in 49 | flask-restx==1.1.0 50 | # via -r requirements.in 51 | flask-sqlalchemy==2.5.1 52 | # via 53 | # -r requirements.in 54 | # flask-migrate 55 | greenlet==1.1.3.post0 56 | # via sqlalchemy 57 | gunicorn==20.1.0 58 | # via -r requirements.in 59 | idna==2.10 60 | # via requests 61 | itsdangerous==2.0.1 62 | # via 63 | # -r requirements.in 64 | # flask 65 | jinja2==3.1.2 66 | # via flask 67 | jsonschema==4.16.0 68 | # via flask-restx 69 | kombu==5.3.1 70 | # via celery 71 | mako==1.2.3 72 | # via alembic 73 | markupsafe==2.1.1 74 | # via 75 | # jinja2 76 | # mako 77 | pillow==7.2.0 78 | # via -r requirements.in 79 | prompt-toolkit==3.0.39 80 | # via click-repl 81 | pyrsistent==0.18.1 82 | # via jsonschema 83 | python-dateutil==2.8.2 84 | # via celery 85 | python-dotenv==0.12.0 86 | # via -r requirements.in 87 | pyttsx3==2.90 88 | # via -r requirements.in 89 | pytz==2022.5 90 | # via flask-restx 91 | redis==3.5.3 92 | # via -r requirements.in 93 | requests==2.23.0 94 | # via -r requirements.in 95 | six==1.16.0 96 | # via 97 | # flask-cors 98 | # python-dateutil 99 | sqlalchemy==1.4.37 100 | # via 101 | # -r requirements.in 102 | # alembic 103 | # flask-sqlalchemy 104 | tzdata==2023.3 105 | # via celery 106 | urllib3==1.25.11 107 | # via requests 108 | vine==5.0.0 109 | # via 110 | # amqp 111 | # celery 112 | # kombu 113 | wcwidth==0.2.6 114 | # via prompt-toolkit 115 | werkzeug==2.1.2 116 | # via 117 | # -r requirements.in 118 | # flask 119 | # flask-restx 120 | 121 | # The following packages are considered to be unsafe in a requirements file: 122 | # setuptools 123 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude=__pycache__ 3 | max-line-length=110 4 | 5 | inline-quotes = single 6 | multiline-quotes = single 7 | docstring-quotes = double 8 | avoid-escape = true -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from os import path 4 | 5 | from setuptools import setup, find_packages 6 | 7 | 8 | def load_requirements(): 9 | try: 10 | with open("requirements.txt", "r") as f: 11 | return f.readlines() 12 | except Exception: 13 | print("Exception getting requirements.txt file, returning []") 14 | return [] 15 | 16 | 17 | here = path.abspath(path.dirname(__file__)) 18 | 19 | setup( 20 | name="captcha-api", 21 | version="0.2.0", 22 | description="CERN CAPTCHA service", 23 | url="https://github.com/CERN/captcha-api", 24 | author="MALT IAM team", 25 | author_email="authzsvc-admins@cern.ch", 26 | classifiers=[ 27 | "Development Status :: 4 - Beta", 28 | "Intended Audience :: Developers", 29 | "Topic :: Software Development :: Libraries :: Python Modules", 30 | "Programming Language :: Python :: 3.9", 31 | "Programming Language :: Python :: 3.10", 32 | ], 33 | install_requires=load_requirements(), 34 | keywords=["captcha", "service", "Flask", "SQLAlchemy", "CLI"], 35 | packages=find_packages(exclude=("tests*", "*tests", "tests")), 36 | extras_require={ 37 | "dev": ["pytest", "flake8", "coverage", "black==22.3.0", "isort"] 38 | }, 39 | include_package_data=True, 40 | ) 41 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CERN/captcha-api/ad53e2c9662a8473572bffec1369d6158a053d2c/tests/__init__.py -------------------------------------------------------------------------------- /tests/tools.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from abc import ABCMeta 3 | from unittest.mock import patch 4 | 5 | import flask_migrate 6 | from flask import Flask 7 | from flask.testing import FlaskClient 8 | 9 | from captcha_api.app import create_app 10 | 11 | 12 | API_ROOT = "/api/v1.0" 13 | 14 | 15 | class WebTestBase(unittest.TestCase, metaclass=ABCMeta): 16 | """ 17 | Base Class for web app tests 18 | """ 19 | 20 | def __init__(self, name): 21 | super().__init__(name) 22 | self.app: Flask = None 23 | self.app_client: FlaskClient = None 24 | self.user_info_mock = None 25 | self.jwt_mock = None 26 | 27 | def _create_app(self, overrides): 28 | self.app = create_app(config_override=overrides, use_env_config=False) 29 | self.app.testing = True 30 | self.app_client = self.app.test_client() 31 | self.ctx = self.app.app_context() 32 | self.ctx.push() 33 | flask_migrate.upgrade(revision="head") 34 | 35 | def setUp(self): 36 | self.addCleanup(patch.stopall) 37 | self._create_app({"SQLALCHEMY_DATABASE_URI": "sqlite://"}) 38 | 39 | def tearDown(self): 40 | flask_migrate.downgrade() 41 | self.ctx.pop() 42 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CERN/captcha-api/ad53e2c9662a8473572bffec1369d6158a053d2c/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_captcha_api.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | from uuid import UUID, uuid4 4 | 5 | from captcha_api.db import db 6 | from captcha_api.models import Captcha 7 | 8 | from ..tools import API_ROOT, WebTestBase 9 | 10 | 11 | class TestCaptchaApi(WebTestBase): 12 | 13 | CAPTCHA_ROOT = f"{API_ROOT}/captcha" 14 | 15 | def test_create_captcha(self): 16 | resp = self.app_client.get(self.CAPTCHA_ROOT) 17 | 18 | self.assertIsNotNone(UUID(resp.json["id"])) 19 | self.assertIsNotNone(resp.json["img"]) 20 | self.assertEqual(200, resp.status_code) 21 | 22 | with self.app.app_context(): 23 | db_captcha = Captcha.query.get(resp.json["id"]) 24 | self.assertIsNotNone(db_captcha) 25 | self.assertIsNotNone(db_captcha.answer) 26 | self.assertIsNotNone(db_captcha.creation_time) 27 | 28 | def test_answer_missing_captcha(self): 29 | resp = self.app_client.get(self.CAPTCHA_ROOT) 30 | 31 | self.assertIsNotNone(UUID(resp.json["id"])) 32 | self.assertEqual(200, resp.status_code) 33 | 34 | answer_res = self.app_client.post( 35 | self.CAPTCHA_ROOT, 36 | data=json.dumps({"id": str(uuid4()), "answer": "42"}), 37 | content_type="application/json", 38 | ) 39 | self.assertEqual(404, answer_res.status_code) 40 | 41 | def test_create_captcha_and_answer_wrong(self): 42 | resp = self.app_client.get(self.CAPTCHA_ROOT) 43 | 44 | self.assertIsNotNone(UUID(resp.json["id"])) 45 | self.assertEqual(200, resp.status_code) 46 | 47 | answer_res = self.app_client.post( 48 | self.CAPTCHA_ROOT, 49 | data=json.dumps({"id": resp.json["id"], "answer": "42"}), 50 | content_type="application/json", 51 | ) 52 | self.assertEqual(400, answer_res.status_code) 53 | self.assertTrue("invalid answer" in answer_res.json["message"].casefold()) 54 | 55 | def test_create_captcha_and_answer_uppercase_works_and_removes_it(self): 56 | resp = self.app_client.get(self.CAPTCHA_ROOT) 57 | 58 | self.assertIsNotNone(UUID(resp.json["id"])) 59 | self.assertEqual(200, resp.status_code) 60 | 61 | with self.app.app_context(): 62 | db_captcha = Captcha.query.get(resp.json["id"]) 63 | answer_res = self.app_client.post( 64 | self.CAPTCHA_ROOT, 65 | data=json.dumps( 66 | {"id": resp.json["id"], "answer": db_captcha.answer.upper()} 67 | ), 68 | content_type="application/json", 69 | ) 70 | self.assertEqual(200, answer_res.status_code) 71 | 72 | def test_create_captcha_and_answer_right_works_and_removes_it(self): 73 | resp = self.app_client.get(self.CAPTCHA_ROOT) 74 | 75 | self.assertIsNotNone(UUID(resp.json["id"])) 76 | self.assertEqual(200, resp.status_code) 77 | 78 | with self.app.app_context(): 79 | db_captcha = Captcha.query.get(resp.json["id"]) 80 | answer_res = self.app_client.post( 81 | self.CAPTCHA_ROOT, 82 | data=json.dumps({"id": resp.json["id"], "answer": db_captcha.answer}), 83 | content_type="application/json", 84 | ) 85 | self.assertEqual(200, answer_res.status_code) 86 | 87 | answer_res = self.app_client.post( 88 | self.CAPTCHA_ROOT, 89 | data=json.dumps({"id": resp.json["id"], "answer": db_captcha.answer}), 90 | content_type="application/json", 91 | ) 92 | self.assertEqual(404, answer_res.status_code) 93 | 94 | def test_create_captcha_and_answer_too_late_does_not_work(self): 95 | resp = self.app_client.get(self.CAPTCHA_ROOT) 96 | 97 | self.assertIsNotNone(UUID(resp.json["id"])) 98 | self.assertEqual(200, resp.status_code) 99 | 100 | with self.app.app_context(): 101 | db_captcha = Captcha.query.get(resp.json["id"]) 102 | db_captcha.creation_time = datetime.datetime.utcnow() - datetime.timedelta( 103 | minutes=2 104 | ) 105 | db.session.commit() 106 | 107 | answer_res = self.app_client.post( 108 | self.CAPTCHA_ROOT, 109 | data=json.dumps({"id": resp.json["id"], "answer": db_captcha.answer}), 110 | content_type="application/json", 111 | ) 112 | self.assertEqual(400, answer_res.status_code) 113 | self.assertTrue( 114 | "did not answer fast enough" in answer_res.json["message"].casefold() 115 | ) 116 | 117 | def test_get_captcha_audio(self): 118 | resp = self.app_client.get(self.CAPTCHA_ROOT) 119 | 120 | captcha_id = resp.json["id"] 121 | self.assertIsNotNone(UUID(resp.json["id"])) 122 | self.assertEqual(200, resp.status_code) 123 | 124 | resp = self.app_client.get(f"{self.CAPTCHA_ROOT}/audio/{captcha_id}") 125 | self.assertEqual(200, resp.status_code) 126 | self.assertTrue(len(resp.data) > 0) 127 | self.assertTrue("audio/mpeg", resp.mimetype) 128 | -------------------------------------------------------------------------------- /wsgi.py: -------------------------------------------------------------------------------- 1 | from captcha_api.app import create_app 2 | 3 | app = create_app() 4 | --------------------------------------------------------------------------------