├── .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 |
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 |
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 |
--------------------------------------------------------------------------------