├── tests ├── __init__.py ├── test_basic_verify_password.py ├── test_basic_hashed_password.py ├── test_digest_custom_realm.py ├── test_digest_custom_realm_without_opaque.py ├── test_digest_custom_realm_auth.py ├── test_basic_get_password.py ├── test_basic_custom_realm.py ├── test_digest_ha1_password.py ├── test_multi.py ├── test_token.py ├── test_digest_get_password_without_session.py └── test_digest_get_password.py ├── .github └── FUNDING.yml ├── MANIFEST.in ├── docs ├── _static │ ├── logo.png │ └── index.html ├── _themes │ ├── flask │ │ ├── theme.conf │ │ ├── relations.html │ │ ├── layout.html │ │ └── static │ │ │ └── flasky.css_t │ ├── flask_small │ │ ├── theme.conf │ │ ├── layout.html │ │ └── static │ │ │ └── flasky.css_t │ ├── README │ ├── LICENSE │ └── flask_theme_support.py ├── Makefile ├── make.bat ├── conf.py └── index.rst ├── AUTHORS ├── .travis.yml ├── .gitignore ├── examples ├── digest_auth.py ├── basic_auth.py ├── token_auth.py └── multi_auth.py ├── tox.ini ├── LICENSE ├── bin ├── release └── mkchangelog.py ├── setup.py ├── README.md ├── sanic_httpauth_compat.py ├── sanic_httpauth.py └── CHANGES.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: miguelgrinberg 2 | custom: https://paypal.me/miguelgrinberg 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE 2 | recursive-include tests *.py 3 | recursive-include docs * 4 | -------------------------------------------------------------------------------- /docs/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MihaiBalint/Sanic-HTTPAuth/HEAD/docs/_static/logo.png -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Miguel Grinberg 2 | Henrique Carvalho Alves 3 | Svitlana Kost 4 | -------------------------------------------------------------------------------- /docs/_themes/flask/theme.conf: -------------------------------------------------------------------------------- 1 | [theme] 2 | inherit = basic 3 | stylesheet = flasky.css 4 | pygments_style = flask_theme_support.FlaskyStyle 5 | 6 | [options] 7 | index_logo = '' 8 | index_logo_height = 120px 9 | touch_icon = 10 | -------------------------------------------------------------------------------- /docs/_themes/flask_small/theme.conf: -------------------------------------------------------------------------------- 1 | [theme] 2 | inherit = basic 3 | stylesheet = flasky.css 4 | nosidebar = true 5 | pygments_style = flask_theme_support.FlaskyStyle 6 | 7 | [options] 8 | index_logo = '' 9 | index_logo_height = 120px 10 | github_fork = '' 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | matrix: 4 | include: 5 | - python: 3.7 6 | env: TOXENV=flake8 7 | - python: 3.6 8 | env: TOXENV=py36 9 | - python: 3.7 10 | env: TOXENV=py37 11 | - python: 3.8 12 | env: TOXENV=py38 13 | - python: 3.7 14 | env: TOXENV=docs 15 | install: 16 | - pip install tox 17 | script: 18 | - tox 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | var 14 | sdist 15 | develop-eggs 16 | .installed.cfg 17 | lib 18 | lib64 19 | 20 | # Installer logs 21 | pip-log.txt 22 | 23 | # Unit test / coverage reports 24 | .coverage 25 | .tox 26 | nosetests.xml 27 | 28 | # Translations 29 | *.mo 30 | 31 | # Mr Developer 32 | .mr.developer.cfg 33 | .project 34 | .pydevproject 35 | 36 | docs/ 37 | -------------------------------------------------------------------------------- /docs/_static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Flask-HTTPAuth documentation 5 | 6 | 7 | 8 | 9 | The Flask-HTTPAuth documentation is available at Read the Docs. 10 | If your browser does not automatically redirect you, please click here. 11 | 12 | -------------------------------------------------------------------------------- /examples/digest_auth.py: -------------------------------------------------------------------------------- 1 | from sanic import Sanic, response 2 | from sanic_httpauth import HTTPDigestAuth 3 | from sanic_session import Session 4 | 5 | app = Sanic(__name__) 6 | app.config["SECRET_KEY"] = "secret key here" 7 | auth = HTTPDigestAuth() 8 | Session(app) 9 | 10 | users = {"john": "hello", "susan": "bye"} 11 | 12 | 13 | @auth.get_password 14 | def get_pw(username): 15 | if username in users: 16 | return users.get(username) 17 | return None 18 | 19 | 20 | @app.route("/") 21 | @auth.login_required 22 | def index(request): 23 | return response.text(f"Hello, {auth.username(request)}") 24 | 25 | 26 | if __name__ == "__main__": 27 | app.run() 28 | -------------------------------------------------------------------------------- /docs/_themes/flask/relations.html: -------------------------------------------------------------------------------- 1 |

Related Topics

2 | 20 | -------------------------------------------------------------------------------- /docs/_themes/flask_small/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "basic/layout.html" %} 2 | {% block header %} 3 | {{ super() }} 4 | {% if pagename == 'index' %} 5 |
6 | {% endif %} 7 | {% endblock %} 8 | {% block footer %} 9 | {% if pagename == 'index' %} 10 |
11 | {% endif %} 12 | {% endblock %} 13 | {# do not display relbars #} 14 | {% block relbar1 %}{% endblock %} 15 | {% block relbar2 %} 16 | {% if theme_github_fork %} 17 | Fork me on GitHub 19 | {% endif %} 20 | {% endblock %} 21 | {% block sidebar1 %}{% endblock %} 22 | {% block sidebar2 %}{% endblock %} 23 | -------------------------------------------------------------------------------- /docs/_themes/flask/layout.html: -------------------------------------------------------------------------------- 1 | {%- extends "basic/layout.html" %} 2 | {%- block extrahead %} 3 | {{ super() }} 4 | {% if theme_touch_icon %} 5 | 6 | {% endif %} 7 | 8 | {% endblock %} 9 | {%- block relbar2 %}{% endblock %} 10 | {% block header %} 11 | {{ super() }} 12 | {% if pagename == 'index' %} 13 |
14 | {% endif %} 15 | {% endblock %} 16 | {%- block footer %} 17 | 21 | {% if pagename == 'index' %} 22 |
23 | {% endif %} 24 | {%- endblock %} 25 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=flake8,py36,py37,py38,docs,coverage 3 | skip_missing_interpreters=True 4 | 5 | [testenv] 6 | commands= 7 | coverage run --branch --include=sanic_httpauth.py setup.py test 8 | coverage report --show-missing 9 | coverage erase 10 | deps= 11 | coverage 12 | 13 | [testenv:flake8] 14 | basepython=python3.8 15 | deps= 16 | flake8 17 | commands= 18 | python3.8 -m flake8 --exclude=".*" --ignore=E402,W504 sanic_httpauth.py tests examples 19 | 20 | [testenv:py36] 21 | basepython=python3.6 22 | commands= 23 | pytest tests/ 24 | 25 | [testenv:py37] 26 | basepython=python3.7 27 | commands= 28 | pytest tests/ 29 | 30 | [testenv:py38] 31 | basepython=python3.8 32 | commands= 33 | pytest tests/ 34 | 35 | [testenv:docs] 36 | basepython=python3.8 37 | changedir=docs 38 | deps= 39 | sphinx 40 | whitelist_externals= 41 | make 42 | commands= 43 | make html 44 | 45 | [testenv:coverage] 46 | basepython=python3.8 47 | deps= 48 | sanic_session 49 | commands= 50 | coverage run --branch --source=sanic_httpauth.py sanic_httpauth_compat.py tests 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Miguel Grinberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /docs/_themes/README: -------------------------------------------------------------------------------- 1 | Flask Sphinx Styles 2 | =================== 3 | 4 | This repository contains sphinx styles for Flask and Flask related 5 | projects. To use this style in your Sphinx documentation, follow 6 | this guide: 7 | 8 | 1. put this folder as _themes into your docs folder. Alternatively 9 | you can also use git submodules to check out the contents there. 10 | 2. add this to your conf.py: 11 | 12 | sys.path.append(os.path.abspath('_themes')) 13 | html_theme_path = ['_themes'] 14 | html_theme = 'flask' 15 | 16 | The following themes exist: 17 | 18 | - 'flask' - the standard flask documentation theme for large 19 | projects 20 | - 'flask_small' - small one-page theme. Intended to be used by 21 | very small addon libraries for flask. 22 | 23 | The following options exist for the flask_small theme: 24 | 25 | [options] 26 | index_logo = '' filename of a picture in _static 27 | to be used as replacement for the 28 | h1 in the index.rst file. 29 | index_logo_height = 120px height of the index logo 30 | github_fork = '' repository name on github for the 31 | "fork me" badge 32 | -------------------------------------------------------------------------------- /examples/basic_auth.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Basic authentication example 3 | 4 | This example demonstrates how to protect Sanic endpoints with basic 5 | authentication, using passwords (hashed but with an unsecure app-global salt). 6 | 7 | After running this example, visit http://localhost:5000 in your browser. To 8 | gain access, you can use (username=john, password=hello) or 9 | (username=susan, password=bye). 10 | """ 11 | import hashlib 12 | from sanic import Sanic, response 13 | from sanic_httpauth import HTTPBasicAuth 14 | 15 | app = Sanic(__name__) 16 | auth = HTTPBasicAuth() 17 | 18 | 19 | def hash_password(salt, password): 20 | salted = password + salt 21 | return hashlib.sha512(salted.encode("utf8")).hexdigest() 22 | 23 | 24 | app_salt = "APP_SECRET - don't do this in production" 25 | users = { 26 | "john": hash_password(app_salt, "hello"), 27 | "susan": hash_password(app_salt, "bye"), 28 | } 29 | 30 | 31 | @auth.verify_password 32 | def verify_password(username, password): 33 | if username in users: 34 | return users.get(username) == hash_password(app_salt, password) 35 | return False 36 | 37 | 38 | @app.route("/") 39 | @auth.login_required 40 | def index(request): 41 | return response.text(f"Hello, {auth.username(request)}!") 42 | 43 | 44 | if __name__ == "__main__": 45 | app.run(debug=True, host="0.0.0.0") 46 | -------------------------------------------------------------------------------- /bin/release: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | 3 | VERSION="$1" 4 | VERSION_FILE=flask_httpauth.py 5 | 6 | if [[ "$VERSION" == "" ]]; then 7 | echo "Usage: $0 " 8 | fi 9 | 10 | # update change log 11 | head -n 2 CHANGES.md > _CHANGES.md 12 | echo "**Release $VERSION** - $(date +%F)" >> _CHANGES.md 13 | echo "" >> _CHANGES.md 14 | pip install gitpython 15 | python bin/mkchangelog.py >> _CHANGES.md 16 | echo "" >> _CHANGES.md 17 | len=$(wc -l < CHANGES.md) 18 | tail -n $(expr $len - 2) CHANGES.md >> _CHANGES.md 19 | vim _CHANGES.md 20 | set +e 21 | grep -q ABORT _CHANGES.md 22 | if [[ "$?" == "0" ]]; then 23 | rm _CHANGES.md 24 | echo "Aborted." 25 | exit 1 26 | fi 27 | set -e 28 | mv _CHANGES.md CHANGES.md 29 | 30 | sed -i "" "s/^__version__ = '.*'$/__version__ = '$VERSION'/" $VERSION_FILE 31 | rm -rf dist 32 | pip install --upgrade pip wheel twine 33 | python setup.py sdist bdist_wheel --universal 34 | 35 | git add $VERSION_FILE CHANGES.md 36 | git commit -m "Release $VERSION" 37 | git tag -f v$VERSION 38 | git push --tags origin master 39 | 40 | read -p "Press any key to submit to PyPI or Ctrl-C to abort..." -n1 -s 41 | twine upload dist/* 42 | 43 | NEW_VERSION="${VERSION%.*}.$((${VERSION##*.}+1))dev" 44 | sed -i "" "s/^__version__ = '.*'$/__version__ = '$NEW_VERSION'/" $VERSION_FILE 45 | git add $VERSION_FILE 46 | git commit -m "Version $NEW_VERSION" 47 | git push origin master 48 | echo "Development is now open on version $NEW_VERSION!" 49 | -------------------------------------------------------------------------------- /examples/token_auth.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Token authentication example 3 | 4 | This example demonstrates how to protect Sanic endpoints with token 5 | authentication, using tokens. 6 | 7 | When this application starts, a token is generated for each of the two users. 8 | To gain access, you can use a command line HTTP client such as curl, passing 9 | one of the tokens: 10 | 11 | curl -X GET -H "Authorization: Bearer " http://localhost:8000/ 12 | 13 | The response should include the username, which is obtained from the token. 14 | """ 15 | from sanic import Sanic, response 16 | from sanic_httpauth import HTTPTokenAuth 17 | from itsdangerous import TimedJSONWebSignatureSerializer as Serializer 18 | 19 | 20 | app = Sanic(__name__) 21 | app.config["SECRET_KEY"] = "top secret!" 22 | token_serializer = Serializer(app.config["SECRET_KEY"], expires_in=3600) 23 | 24 | auth = HTTPTokenAuth("Bearer") 25 | 26 | users = ["john", "susan"] 27 | for user in users: 28 | token = token_serializer.dumps({"username": user}).decode("utf-8") 29 | print("*** token for {}: {}\n".format(user, token)) 30 | 31 | 32 | @auth.verify_token 33 | def verify_token(token): 34 | try: 35 | return "username" in token_serializer.loads(token) 36 | except: # noqa: E722 37 | return False 38 | 39 | 40 | @app.route("/") 41 | @auth.login_required 42 | def index(request): 43 | data = token_serializer.loads(auth.token(request)) 44 | username = data["username"] 45 | return response.text(f"Hello, {username}!") 46 | 47 | 48 | if __name__ == "__main__": 49 | app.run() 50 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sanic-HTTPAuth 3 | -------------- 4 | 5 | Basic and Digest HTTP authentication for Sanic routes. 6 | """ 7 | import re 8 | from setuptools import setup 9 | 10 | with open("sanic_httpauth.py", "r") as f: 11 | version = re.search( 12 | r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE 13 | ).group(1) 14 | 15 | with open("README.md", encoding="utf-8") as f: 16 | long_description = f.read() 17 | 18 | setup( 19 | name="Sanic-HTTPAuth", 20 | version=version, 21 | url="http://github.com/MihaiBalint/sanic-httpauth/", 22 | license="MIT", 23 | author="Mihai Balint", 24 | author_email="balint.mihai@gmail.com", 25 | description="Basic, Digest and Bearer token authentication for Sanic routes", 26 | long_description=long_description, 27 | long_description_content_type="text/markdown", 28 | py_modules=["sanic_httpauth", "sanic_httpauth_compat"], 29 | zip_safe=False, 30 | include_package_data=True, 31 | platforms="any", 32 | install_requires=["sanic"], 33 | extras_require={ 34 | "session": ["sanic_session"], 35 | "test": ["sanic_session", "sanic-cors", "ipdb"], 36 | }, 37 | test_suite="tests", 38 | classifiers=[ 39 | "Environment :: Web Environment", 40 | "Intended Audience :: Developers", 41 | "License :: OSI Approved :: MIT License", 42 | "Operating System :: OS Independent", 43 | "Programming Language :: Python", 44 | "Programming Language :: Python :: 3", 45 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 46 | "Topic :: Software Development :: Libraries :: Python Modules", 47 | ], 48 | ) 49 | -------------------------------------------------------------------------------- /docs/_themes/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 by Armin Ronacher. 2 | 3 | Some rights reserved. 4 | 5 | Redistribution and use in source and binary forms of the theme, with or 6 | without modification, are permitted provided that the following conditions 7 | are met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above 13 | copyright notice, this list of conditions and the following 14 | disclaimer in the documentation and/or other materials provided 15 | with the distribution. 16 | 17 | * The names of the contributors may not be used to endorse or 18 | promote products derived from this software without specific 19 | prior written permission. 20 | 21 | We kindly ask you to only use these themes in an unmodified manner just 22 | for Flask and Flask-related products, not for unrelated projects. If you 23 | like the visual style and want to use it for your own projects, please 24 | consider making some larger changes to the themes (such as changing 25 | font faces, sizes, colors or margins). 26 | 27 | THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 28 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 29 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 30 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 31 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 32 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 33 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 34 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 35 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 36 | ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE 37 | POSSIBILITY OF SUCH DAMAGE. 38 | -------------------------------------------------------------------------------- /bin/mkchangelog.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | import sys 4 | import git 5 | 6 | URL = 'https://github.com/miguelgrinberg/Flask-HTTPAuth' 7 | merges = {} 8 | 9 | 10 | def format_message(commit): 11 | if commit.message.startswith('Version '): 12 | return '' 13 | if '#nolog' in commit.message: 14 | return '' 15 | if commit.message.startswith('Merge pull request'): 16 | pr = commit.message.split('#')[1].split(' ')[0] 17 | message = ' '.join([line for line in [line.strip() for line in commit.message.split('\n')[1:]] if line]) 18 | merges[message] = pr 19 | return '' 20 | if commit.message.startswith('Release '): 21 | return '\n**{message}** - {date}\n'.format( 22 | message=commit.message.strip(), 23 | date=datetime.datetime.fromtimestamp(commit.committed_date).strftime('%Y-%m-%d')) 24 | message = ' '.join([line for line in [line.strip() for line in commit.message.split('\n')] if line]) 25 | if message in merges: 26 | message += ' #' + merges[message] 27 | message = re.sub('\\(.*(#[0-9]+)\\)', '\\1', message) 28 | message = re.sub('Fixes (#[0-9]+)', '\\1', message) 29 | message = re.sub('fixes (#[0-9]+)', '\\1', message) 30 | message = re.sub('#([0-9]+)', '[#\\1]({url}/issues/\\1)'.format(url=URL), message) 31 | message += ' ([commit]({url}/commit/{sha}))'.format(url=URL, sha=str(commit)) 32 | if commit.author.name != 'Miguel Grinberg': 33 | message += ' (thanks **{name}**!)'.format(name=commit.author.name) 34 | return '- ' + message 35 | 36 | 37 | def main(all=False): 38 | repo = git.Repo() 39 | 40 | for commit in repo.iter_commits(): 41 | if not all and commit.message.startswith('Release '): 42 | break 43 | message = format_message(commit) 44 | if message: 45 | print(message) 46 | 47 | 48 | if __name__ == '__main__': 49 | main(all=len(sys.argv) > 1 and sys.argv[1] == 'all') 50 | -------------------------------------------------------------------------------- /examples/multi_auth.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Multiple authentication example 3 | 4 | This example demonstrates how to combine two authentication methods using the 5 | "MultiAuth" class. 6 | 7 | The root URL for this application can be accessed via basic auth, providing 8 | username and password, or via token auth, providing a bearer JWS token. 9 | """ 10 | import hashlib 11 | from sanic import Sanic, response 12 | from sanic_httpauth import HTTPBasicAuth, HTTPTokenAuth, MultiAuth 13 | from itsdangerous import TimedJSONWebSignatureSerializer as JWS 14 | 15 | 16 | app = Sanic(__name__) 17 | app.config["SECRET_KEY"] = "top secret!" 18 | jws = JWS(app.config["SECRET_KEY"], expires_in=3600) 19 | 20 | basic_auth = HTTPBasicAuth() 21 | token_auth = HTTPTokenAuth("Bearer") 22 | multi_auth = MultiAuth(basic_auth, token_auth) 23 | 24 | 25 | def hash_password(salt, password): 26 | salted = password + salt 27 | return hashlib.sha512(salted.encode("utf8")).hexdigest() 28 | 29 | 30 | app_salt = "APP_SECRET - don't do this in production" 31 | users = { 32 | "john": hash_password(app_salt, "hello"), 33 | "susan": hash_password(app_salt, "bye"), 34 | } 35 | 36 | for user in users.keys(): 37 | token = jws.dumps({"username": user}) 38 | print("*** token for {}: {}\n".format(user, token)) 39 | 40 | 41 | @basic_auth.verify_password 42 | def verify_password(username, password): 43 | if username in users: 44 | return users.get(username) == hash_password(app_salt, password) 45 | return False 46 | 47 | 48 | @token_auth.verify_token 49 | def verify_token(token): 50 | try: 51 | return "username" in jws.loads(token) 52 | except: # noqa: E722 53 | return False 54 | 55 | 56 | @app.route("/") 57 | @multi_auth.login_required 58 | def index(request): 59 | username = basic_auth.username(request) 60 | if not username: 61 | data = jws.loads(token_auth.token(request)) 62 | username = data["username"] 63 | 64 | return response.text(f"Hello, {username}!") 65 | 66 | 67 | if __name__ == "__main__": 68 | app.run() 69 | -------------------------------------------------------------------------------- /tests/test_basic_verify_password.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import unittest 3 | 4 | from sanic import Sanic 5 | from sanic.response import text 6 | from sanic_httpauth import HTTPBasicAuth 7 | 8 | 9 | class HTTPAuthTestCase(unittest.TestCase): 10 | def setUp(self): 11 | app = Sanic(__name__) 12 | app.config["SECRET_KEY"] = "my secret" 13 | 14 | basic_verify_auth = HTTPBasicAuth() 15 | 16 | @basic_verify_auth.verify_password 17 | def basic_verify_auth_verify_password(username, password): 18 | if username == "john": 19 | return password == "hello" 20 | elif username == "susan": 21 | return password == "bye" 22 | elif username == "": 23 | return True 24 | return False 25 | 26 | @basic_verify_auth.error_handler 27 | def error_handler(request): 28 | return text("error", status=403) # use a custom error status 29 | 30 | @app.route("/") 31 | def index(request): 32 | return text("index") 33 | 34 | @app.route("/basic-verify") 35 | @basic_verify_auth.login_required 36 | def basic_verify_auth_route(request): 37 | anon = basic_verify_auth.username(request) == "" 38 | return text( 39 | f"basic_verify_auth:{basic_verify_auth.username(request)} " 40 | f"anon:{anon}" 41 | ) 42 | 43 | self.app = app 44 | self.basic_verify_auth = basic_verify_auth 45 | self.client = app.test_client 46 | 47 | def test_verify_auth_login_valid(self): 48 | creds = base64.b64encode(b"susan:bye").decode("utf-8") 49 | req, response = self.client.get( 50 | "/basic-verify", headers={"Authorization": "Basic " + creds} 51 | ) 52 | self.assertEqual(response.content, 53 | b"basic_verify_auth:susan anon:False") 54 | 55 | def test_verify_auth_login_empty(self): 56 | req, response = self.client.get("/basic-verify") 57 | self.assertEqual(response.content, b"basic_verify_auth: anon:True") 58 | 59 | def test_verify_auth_login_invalid(self): 60 | creds = base64.b64encode(b"john:bye").decode("utf-8") 61 | req, response = self.client.get( 62 | "/basic-verify", headers={"Authorization": "Basic " + creds} 63 | ) 64 | self.assertEqual(response.status_code, 403) 65 | self.assertTrue("WWW-Authenticate" in response.headers) 66 | -------------------------------------------------------------------------------- /tests/test_basic_hashed_password.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import unittest 3 | from hashlib import md5 as basic_md5 4 | 5 | from sanic import Sanic 6 | from sanic.response import text 7 | from sanic_httpauth import HTTPBasicAuth 8 | 9 | 10 | def md5(s): 11 | if isinstance(s, str): 12 | s = s.encode("utf-8") 13 | return basic_md5(s) 14 | 15 | 16 | class HTTPAuthTestCase(unittest.TestCase): 17 | def setUp(self): 18 | app = Sanic(__name__) 19 | app.config["SECRET_KEY"] = "my secret" 20 | 21 | basic_custom_auth = HTTPBasicAuth() 22 | 23 | @basic_custom_auth.get_password 24 | def get_basic_custom_auth_get_password(username): 25 | if username == "john": 26 | return md5("hello").hexdigest() 27 | elif username == "susan": 28 | return md5("bye").hexdigest() 29 | else: 30 | return None 31 | 32 | @basic_custom_auth.hash_password 33 | def basic_custom_auth_hash_password(password): 34 | return md5(password).hexdigest() 35 | 36 | @app.route("/") 37 | def index(request): 38 | return text("index") 39 | 40 | @app.route("/basic-custom") 41 | @basic_custom_auth.login_required 42 | def basic_custom_auth_route(request): 43 | return text( 44 | f"basic_custom_auth:{basic_custom_auth.username(request)}") 45 | 46 | self.app = app 47 | self.basic_custom_auth = basic_custom_auth 48 | self.client = app.test_client 49 | 50 | def test_basic_auth_login_valid_with_hash1(self): 51 | creds = base64.b64encode(b"john:hello").decode("utf-8") 52 | req, response = self.client.get( 53 | "/basic-custom", headers={"Authorization": "Basic " + creds} 54 | ) 55 | self.assertEqual(response.content.decode("utf-8"), 56 | "basic_custom_auth:john") 57 | 58 | def test_basic_custom_auth_login_valid(self): 59 | creds = base64.b64encode(b"john:hello").decode("utf-8") 60 | req, response = self.client.get( 61 | "/basic-custom", headers={"Authorization": "Basic " + creds} 62 | ) 63 | self.assertEqual(response.content, b"basic_custom_auth:john") 64 | 65 | def test_basic_custom_auth_login_invalid(self): 66 | creds = base64.b64encode(b"john:bye").decode("utf-8") 67 | req, response = self.client.get( 68 | "/basic-custom", headers={"Authorization": "Basic " + creds} 69 | ) 70 | self.assertEqual(response.status_code, 401) 71 | self.assertTrue("WWW-Authenticate" in response.headers) 72 | -------------------------------------------------------------------------------- /tests/test_digest_custom_realm.py: -------------------------------------------------------------------------------- 1 | import re 2 | import unittest 3 | 4 | from sanic import Sanic 5 | from sanic.response import text 6 | from sanic_httpauth import HTTPDigestAuth 7 | from sanic_session import Session 8 | 9 | 10 | class HTTPAuthTestCase(unittest.TestCase): 11 | def setUp(self): 12 | app = Sanic(__name__) 13 | app.config["SECRET_KEY"] = "my secret" 14 | Session(app) 15 | 16 | digest_auth_my_realm = HTTPDigestAuth(realm="My Realm", qop="") 17 | 18 | @digest_auth_my_realm.get_password 19 | def get_digest_password_3(username): 20 | if username == "susan": 21 | return "hello" 22 | elif username == "john": 23 | return "bye" 24 | else: 25 | return None 26 | 27 | @app.route("/") 28 | def index(request): 29 | return text("index") 30 | 31 | @app.route("/digest-with-realm") 32 | @digest_auth_my_realm.login_required 33 | def digest_auth_my_realm_route(request): 34 | return text(f"digest_auth_my_realm:" 35 | f"{digest_auth_my_realm.username(request)}") 36 | 37 | self.app = app 38 | self.client = app.test_client 39 | 40 | def test_digest_auth_prompt_with_custom_realm(self): 41 | req, response = self.client.get("/digest-with-realm") 42 | self.assertEqual(response.status_code, 401) 43 | self.assertTrue("WWW-Authenticate" in response.headers) 44 | self.assertTrue( 45 | re.match( 46 | r'^Digest realm="My Realm", ' 47 | r'nonce="[0-9a-f]+", qop="", opaque="[0-9a-f]+"$', 48 | response.headers["WWW-Authenticate"], 49 | ) 50 | ) 51 | 52 | def test_digest_auth_login_invalid(self): 53 | req, response = self.client.get( 54 | "/digest-with-realm", 55 | headers={ 56 | "Authorization": 'Digest username="susan",' 57 | 'realm="My Realm",' 58 | 'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",' 59 | 'uri="/digest-with-realm",' 60 | 'response="ca306c361a9055b968810067a37fb8cb",' 61 | 'opaque="5ccc069c403ebaf9f0171e9517f40e41"' 62 | }, 63 | ) 64 | self.assertEqual(response.status_code, 401) 65 | self.assertTrue("WWW-Authenticate" in response.headers) 66 | self.assertTrue( 67 | re.match( 68 | r'^Digest realm="My Realm", ' 69 | r'nonce="[0-9a-f]+", qop="", opaque="[0-9a-f]+"$', 70 | response.headers["WWW-Authenticate"], 71 | ) 72 | ) 73 | -------------------------------------------------------------------------------- /tests/test_digest_custom_realm_without_opaque.py: -------------------------------------------------------------------------------- 1 | import re 2 | import unittest 3 | 4 | from sanic import Sanic 5 | from sanic.response import text 6 | from sanic_httpauth import HTTPDigestAuth 7 | from sanic_session import Session 8 | 9 | 10 | class HTTPAuthTestCase(unittest.TestCase): 11 | def setUp(self): 12 | app = Sanic(__name__) 13 | app.config["SECRET_KEY"] = "my secret" 14 | Session(app) 15 | 16 | digest_auth_my_realm = HTTPDigestAuth( 17 | realm="My Realm", qop="auth", use_opaque=False) 18 | 19 | @digest_auth_my_realm.get_password 20 | def get_digest_password_3(username): 21 | if username == "susan": 22 | return "hello" 23 | elif username == "john": 24 | return "bye" 25 | else: 26 | return None 27 | 28 | @app.route("/") 29 | def index(request): 30 | return text("index") 31 | 32 | @app.route("/digest-with-realm") 33 | @digest_auth_my_realm.login_required 34 | def digest_auth_my_realm_route(request): 35 | return text(f"digest_auth_my_realm:" 36 | f"{digest_auth_my_realm.username(request)}") 37 | 38 | self.app = app 39 | self.client = app.test_client 40 | 41 | def test_digest_auth_prompt_with_custom_realm(self): 42 | req, response = self.client.get("/digest-with-realm") 43 | self.assertEqual(response.status_code, 401) 44 | self.assertTrue("WWW-Authenticate" in response.headers) 45 | self.assertTrue( 46 | re.match( 47 | r'^Digest realm="My Realm", ' 48 | r'nonce="[0-9a-f]+", qop="auth"', 49 | response.headers["WWW-Authenticate"], 50 | ) 51 | ) 52 | 53 | def test_digest_auth_login_invalid(self): 54 | req, response = self.client.get( 55 | "/digest-with-realm", 56 | headers={ 57 | "Authorization": 'Digest username="susan",' 58 | 'realm="My Realm",' 59 | 'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",' 60 | 'uri="/digest-with-realm",' 61 | 'response="ca306c361a9055b968810067a37fb8cb",' 62 | 'opaque="5ccc069c403ebaf9f0171e9517f40e41"' 63 | }, 64 | ) 65 | self.assertEqual(response.status_code, 401) 66 | self.assertTrue("WWW-Authenticate" in response.headers) 67 | self.assertTrue( 68 | re.match( 69 | r'^Digest realm="My Realm", ' 70 | r'nonce="[0-9a-f]+", qop="auth"', 71 | response.headers["WWW-Authenticate"], 72 | ) 73 | ) 74 | -------------------------------------------------------------------------------- /tests/test_digest_custom_realm_auth.py: -------------------------------------------------------------------------------- 1 | import re 2 | import unittest 3 | 4 | from sanic import Sanic 5 | from sanic.response import text 6 | from sanic_httpauth import HTTPDigestAuth 7 | from sanic_session import Session 8 | 9 | 10 | class HTTPAuthTestCase(unittest.TestCase): 11 | def setUp(self): 12 | app = Sanic(__name__) 13 | app.config["SECRET_KEY"] = "my secret" 14 | Session(app) 15 | 16 | digest_auth_my_realm = HTTPDigestAuth(realm="My Realm", qop="auth") 17 | 18 | @digest_auth_my_realm.get_password 19 | def get_digest_password_3(username): 20 | if username == "susan": 21 | return "hello" 22 | elif username == "john": 23 | return "bye" 24 | else: 25 | return None 26 | 27 | @app.route("/") 28 | def index(request): 29 | return text("index") 30 | 31 | @app.route("/digest-with-realm") 32 | @digest_auth_my_realm.login_required 33 | def digest_auth_my_realm_route(request): 34 | return text(f"digest_auth_my_realm:" 35 | f"{digest_auth_my_realm.username(request)}") 36 | 37 | self.app = app 38 | self.client = app.test_client 39 | 40 | def test_digest_auth_prompt_with_custom_realm(self): 41 | req, response = self.client.get("/digest-with-realm") 42 | self.assertEqual(response.status_code, 401) 43 | self.assertTrue("WWW-Authenticate" in response.headers) 44 | self.assertTrue( 45 | re.match( 46 | r'^Digest realm="My Realm", ' 47 | r'nonce="[0-9a-f]+", qop="auth", opaque="[0-9a-f]+"$', 48 | response.headers["WWW-Authenticate"], 49 | ) 50 | ) 51 | 52 | def test_digest_auth_login_invalid(self): 53 | req, response = self.client.get( 54 | "/digest-with-realm", 55 | headers={ 56 | "Authorization": 'Digest username="susan",' 57 | 'realm="My Realm",' 58 | 'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",' 59 | 'uri="/digest-with-realm",' 60 | 'response="ca306c361a9055b968810067a37fb8cb",' 61 | 'opaque="5ccc069c403ebaf9f0171e9517f40e41"' 62 | 'qop="auth", nc=00000001, cnonce="5fd0a782"' 63 | }, 64 | ) 65 | self.assertEqual(response.status_code, 401) 66 | self.assertTrue("WWW-Authenticate" in response.headers) 67 | self.assertTrue( 68 | re.match( 69 | r'^Digest realm="My Realm", ' 70 | r'nonce="[0-9a-f]+", qop="auth", opaque="[0-9a-f]+"$', 71 | response.headers["WWW-Authenticate"], 72 | ) 73 | ) 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Sanic-HTTPAuth 2 | ============== 3 | 4 | [![Build Status](https://travis-ci.org/miguelgrinberg/Flask-HTTPAuth.png?branch=master)](https://travis-ci.org/miguelgrinberg/Flask-HTTPAuth) 5 | 6 | This a fork of [Flask-HTTPAuth](https://github.com/miguelgrinberg/Flask-HTTPAuth) for Sanic. It is a simple extension that provides Basic and Digest HTTP authentication for Sanic routes. 7 | 8 | Still a work in progress, contributions are welcome. 9 | 10 | Installation 11 | ------------ 12 | The easiest way to install this is through pip. 13 | ``` 14 | pip install Sanic-HTTPAuth 15 | ``` 16 | 17 | Basic authentication example 18 | ---------------------------- 19 | 20 | ```python 21 | import hashlib 22 | from sanic import Sanic, response 23 | from sanic_httpauth import HTTPBasicAuth 24 | 25 | app = Sanic(__name__) 26 | auth = HTTPBasicAuth() 27 | 28 | 29 | def hash_password(salt, password): 30 | salted = password + salt 31 | return hashlib.sha512(salted.encode("utf8")).hexdigest() 32 | 33 | 34 | app_salt = "APP_SECRET - don't do this in production" 35 | users = { 36 | "john": hash_password(app_salt, "hello"), 37 | "susan": hash_password(app_salt, "bye"), 38 | } 39 | 40 | 41 | @auth.verify_password 42 | def verify_password(username, password): 43 | if username in users: 44 | return users.get(username) == hash_password(app_salt, password) 45 | return False 46 | 47 | 48 | @app.route("/") 49 | @auth.login_required 50 | def index(request): 51 | return response.text(f"Hello, {auth.username(request)}!") 52 | 53 | 54 | if __name__ == "__main__": 55 | app.run() 56 | ``` 57 | 58 | Note: See the [Flask-HTTPAuth documentation](http://pythonhosted.org/Flask-HTTPAuth) for more complex examples that involve password hashing and custom verification callbacks. 59 | 60 | Digest authentication example 61 | ----------------------------- 62 | 63 | ```python 64 | from sanic import Sanic, response 65 | from sanic_httpauth import HTTPDigestAuth 66 | from sanic_session import Session 67 | 68 | app = Sanic(__name__) 69 | app.config["SECRET_KEY"] = "secret key here" 70 | auth = HTTPDigestAuth() 71 | Session(app) 72 | 73 | users = {"john": "hello", "susan": "bye"} 74 | 75 | 76 | @auth.get_password 77 | def get_pw(username): 78 | if username in users: 79 | return users.get(username) 80 | return None 81 | 82 | 83 | @app.route("/") 84 | @auth.login_required 85 | def index(request): 86 | return response.text(f"Hello, {auth.username(request)}!") 87 | 88 | 89 | if __name__ == "__main__": 90 | app.run() 91 | ``` 92 | 93 | Resources 94 | --------- 95 | 96 | - [Flask-HTTPAuth Documentation](http://flask-httpauth.readthedocs.io/en/latest/) 97 | - [PyPI](https://pypi.org/project/Sanic-HTTPAuth) 98 | - [Change log](https://github.com/MihaiBalint/Sanic-HTTPAuth/blob/master/CHANGES.md) 99 | -------------------------------------------------------------------------------- /tests/test_basic_get_password.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import unittest 3 | 4 | from sanic import Sanic 5 | from sanic.response import text 6 | from sanic_cors import CORS 7 | from sanic_httpauth import HTTPBasicAuth 8 | 9 | 10 | class HTTPAuthTestCase(unittest.TestCase): 11 | def setUp(self): 12 | app = Sanic(__name__) 13 | app.config["SECRET_KEY"] = "my secret" 14 | app.config["CORS_AUTOMATIC_OPTIONS"] = True 15 | 16 | CORS(app) 17 | basic_auth = HTTPBasicAuth() 18 | 19 | @basic_auth.get_password 20 | def get_basic_password(username): 21 | if username == "john": 22 | return "hello" 23 | elif username == "susan": 24 | return "bye" 25 | else: 26 | return None 27 | 28 | @app.route("/") 29 | def index(request): 30 | return text("index") 31 | 32 | @app.route("/basic") 33 | @basic_auth.login_required 34 | def basic_auth_route(request): 35 | return text(f"basic_auth:{basic_auth.username(request)}") 36 | 37 | self.app = app 38 | self.basic_auth = basic_auth 39 | self.client = app.test_client 40 | 41 | def test_no_auth(self): 42 | req, response = self.client.get("/") 43 | self.assertEqual(response.content.decode("utf-8"), "index") 44 | 45 | def test_basic_auth_prompt(self): 46 | req, response = self.client.get("/basic") 47 | self.assertEqual(response.status_code, 401) 48 | self.assertTrue("WWW-Authenticate" in response.headers) 49 | self.assertEqual( 50 | response.headers["WWW-Authenticate"], 51 | 'Basic realm="Authentication Required"', 52 | ) 53 | 54 | def test_basic_auth_ignore_options(self): 55 | req, response = self.client.options("/basic") 56 | self.assertEqual(response.status_code, 200) 57 | self.assertTrue("WWW-Authenticate" not in response.headers) 58 | 59 | def test_basic_auth_login_valid(self): 60 | creds = base64.b64encode(b"john:hello").decode("utf-8") 61 | req, response = self.client.get( 62 | "/basic", headers={"Authorization": "Basic " + creds} 63 | ) 64 | self.assertEqual(response.content.decode("utf-8"), "basic_auth:john") 65 | 66 | def test_basic_auth_login_invalid(self): 67 | creds = base64.b64encode(b"john:bye").decode("utf-8") 68 | req, response = self.client.get( 69 | "/basic", headers={"Authorization": "Basic " + creds} 70 | ) 71 | self.assertEqual(response.status_code, 401) 72 | self.assertTrue("WWW-Authenticate" in response.headers) 73 | self.assertEqual( 74 | response.headers["WWW-Authenticate"], 75 | 'Basic realm="Authentication Required"', 76 | ) 77 | -------------------------------------------------------------------------------- /tests/test_basic_custom_realm.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import unittest 3 | 4 | from sanic import Sanic 5 | from sanic.response import text 6 | from sanic_httpauth import HTTPBasicAuth 7 | 8 | 9 | class HTTPAuthTestCase(unittest.TestCase): 10 | def setUp(self): 11 | app = Sanic(__name__) 12 | app.config["SECRET_KEY"] = "my secret" 13 | 14 | basic_auth_my_realm = HTTPBasicAuth(realm="My Realm") 15 | 16 | @basic_auth_my_realm.get_password 17 | def get_basic_password_2(username): 18 | if username == "john": 19 | return "johnhello" 20 | elif username == "susan": 21 | return "susanbye" 22 | else: 23 | return None 24 | 25 | @basic_auth_my_realm.hash_password 26 | def basic_auth_my_realm_hash_password(username, password): 27 | return username + password 28 | 29 | @basic_auth_my_realm.error_handler 30 | def basic_auth_my_realm_error(request): 31 | return text("custom error") 32 | 33 | @app.route("/") 34 | def index(request): 35 | return text("index") 36 | 37 | @app.route("/basic-with-realm") 38 | @basic_auth_my_realm.login_required 39 | def basic_auth_my_realm_route(request): 40 | return text( 41 | f"basic_auth_my_realm:{basic_auth_my_realm.username(request)}") 42 | 43 | self.app = app 44 | self.basic_auth_my_realm = basic_auth_my_realm 45 | self.client = app.test_client 46 | 47 | def test_basic_auth_prompt(self): 48 | req, response = self.client.get("/basic-with-realm") 49 | self.assertEqual(response.status_code, 401) 50 | self.assertTrue("WWW-Authenticate" in response.headers) 51 | self.assertEqual(response.headers["WWW-Authenticate"], 52 | 'Basic realm="My Realm"') 53 | self.assertEqual(response.content.decode("utf-8"), "custom error") 54 | 55 | def test_basic_auth_login_valid(self): 56 | creds = base64.b64encode(b"john:hello").decode("utf-8") 57 | req, response = self.client.get( 58 | "/basic-with-realm", headers={"Authorization": "Basic " + creds} 59 | ) 60 | self.assertEqual(response.content.decode("utf-8"), 61 | "basic_auth_my_realm:john") 62 | 63 | def test_basic_auth_login_invalid(self): 64 | creds = base64.b64encode(b"john:bye").decode("utf-8") 65 | req, response = self.client.get( 66 | "/basic-with-realm", headers={"Authorization": "Basic " + creds} 67 | ) 68 | self.assertEqual(response.status_code, 401) 69 | self.assertTrue("WWW-Authenticate" in response.headers) 70 | self.assertEqual(response.headers["WWW-Authenticate"], 71 | 'Basic realm="My Realm"') 72 | -------------------------------------------------------------------------------- /tests/test_digest_ha1_password.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from hashlib import md5 as basic_md5 3 | from unittest.mock import MagicMock 4 | 5 | from sanic import Sanic 6 | from sanic.response import text 7 | from sanic_httpauth import HTTPDigestAuth 8 | from sanic_httpauth_compat import parse_dict_header 9 | from sanic_session import InMemorySessionInterface, Session 10 | 11 | 12 | def md5(str): 13 | if type(str).__name__ == "str": 14 | str = str.encode("utf-8") 15 | return basic_md5(str) 16 | 17 | 18 | def get_ha1(user, pw, realm): 19 | a1 = user + ":" + realm + ":" + pw 20 | return md5(a1).hexdigest() 21 | 22 | 23 | class HTTPAuthTestCase(unittest.TestCase): 24 | def setUp(self): 25 | app = Sanic(__name__) 26 | app.config["SECRET_KEY"] = "my secret" 27 | self.nonce = None 28 | self.opaque = None 29 | 30 | Session(app, interface=InMemorySessionInterface( 31 | cookie_name="test_session")) 32 | 33 | digest_auth_ha1_pw = HTTPDigestAuth(use_ha1_pw=True, qop=None) 34 | digest_auth_ha1_pw._generate_random = MagicMock( 35 | side_effect=["9549bf6d4fd6206e2945e8501481ddd5", 36 | "47c67cc7bedf6bc754f044f77f32b99e"]) 37 | 38 | @digest_auth_ha1_pw.get_password 39 | def get_digest_password(username): 40 | if username == "susan": 41 | return get_ha1(username, "hello", digest_auth_ha1_pw.realm) 42 | elif username == "john": 43 | return get_ha1(username, "bye", digest_auth_ha1_pw.realm) 44 | else: 45 | return None 46 | 47 | @app.route("/") 48 | def index(request): 49 | return "index" 50 | 51 | @app.route("/digest_ha1_pw") 52 | @digest_auth_ha1_pw.login_required 53 | def digest_auth_ha1_pw_route(request): 54 | return text( 55 | f"digest_auth_ha1_pw:{digest_auth_ha1_pw.username(request)}") 56 | 57 | self.app = app 58 | self.client = app.test_client 59 | 60 | def test_digest_ha1_pw_auth_login_valid(self): 61 | req, response = self.client.get("/digest_ha1_pw") 62 | self.assertTrue(response.status_code == 401) 63 | header = (f'Digest realm="Authentication Required", ' 64 | f'nonce="9549bf6d4fd6206e2945e8501481ddd5", qop="None", ' 65 | f'opaque="47c67cc7bedf6bc754f044f77f32b99e"') 66 | response.headers["WWW-Authenticate"] = header 67 | auth_type, auth_info = header.split(None, 1) 68 | d = parse_dict_header(auth_info) 69 | 70 | auth_response = "7afa823fb21430c8acb89ed054a5add6" 71 | req, response = self.client.get( 72 | "/digest_ha1_pw", 73 | headers={ 74 | "Authorization": 'Digest username="john",realm="{0}",' 75 | 'nonce="{1}",uri="/digest_ha1_pw",' 76 | 'response="{2}",' 77 | 'opaque="{3}"'.format( 78 | d["realm"], d["nonce"], auth_response, d["opaque"] 79 | ) 80 | }, 81 | cookies={"test_session": response.cookies.get("test_session")}, 82 | ) 83 | self.assertEqual(response.content, b"digest_auth_ha1_pw:john") 84 | -------------------------------------------------------------------------------- /tests/test_multi.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import unittest 3 | from sanic import Sanic 4 | from sanic_httpauth import HTTPBasicAuth, HTTPTokenAuth, MultiAuth 5 | from sanic.response import text 6 | 7 | 8 | class HTTPAuthTestCase(unittest.TestCase): 9 | def setUp(self): 10 | app = Sanic(__name__) 11 | app.config["SECRET_KEY"] = "my secret" 12 | 13 | basic_auth = HTTPBasicAuth() 14 | token_auth = HTTPTokenAuth("MyToken") 15 | multi_auth = MultiAuth(basic_auth, token_auth) 16 | 17 | @basic_auth.verify_password 18 | def verify_password(username, password): 19 | return username == "john" and password == "hello" 20 | 21 | @token_auth.verify_token 22 | def verify_token(token): 23 | return token == "this-is-the-token!" 24 | 25 | @token_auth.error_handler 26 | def error_handler(request): 27 | return text( 28 | "error", status=401, 29 | headers={"WWW-Authenticate": 'MyToken realm="Foo"'} 30 | ) 31 | 32 | @app.route("/") 33 | def index(request): 34 | return text("index") 35 | 36 | @app.route("/protected") 37 | @multi_auth.login_required 38 | def auth_route(request): 39 | return text("access granted") 40 | 41 | self.app = app 42 | self.client = app.test_client 43 | 44 | def test_multi_auth_prompt(self): 45 | req, response = self.client.get("/protected") 46 | self.assertEqual(response.status_code, 401) 47 | self.assertTrue("WWW-Authenticate" in response.headers) 48 | self.assertEqual( 49 | response.headers["WWW-Authenticate"], 50 | 'Basic realm="Authentication Required"', 51 | ) 52 | 53 | def test_multi_auth_login_valid_basic(self): 54 | creds = base64.b64encode(b"john:hello").decode("utf-8") 55 | req, response = self.client.get( 56 | "/protected", headers={"Authorization": "Basic " + creds} 57 | ) 58 | self.assertEqual(response.content.decode("utf-8"), "access granted") 59 | 60 | def test_multi_auth_login_invalid_basic(self): 61 | creds = base64.b64encode(b"john:bye").decode("utf-8") 62 | req, response = self.client.get( 63 | "/protected", headers={"Authorization": "Basic " + creds} 64 | ) 65 | self.assertEqual(response.status_code, 401) 66 | self.assertTrue("WWW-Authenticate" in response.headers) 67 | self.assertEqual( 68 | response.headers["WWW-Authenticate"], 69 | 'Basic realm="Authentication Required"', 70 | ) 71 | 72 | def test_multi_auth_login_valid_token(self): 73 | req, response = self.client.get( 74 | "/protected", 75 | headers={"Authorization": "MyToken this-is-the-token!"} 76 | ) 77 | self.assertEqual(response.content.decode("utf-8"), "access granted") 78 | 79 | def test_multi_auth_login_invalid_token(self): 80 | req, response = self.client.get( 81 | "/protected", 82 | headers={"Authorization": "MyToken this-is-not-the-token!"} 83 | ) 84 | self.assertEqual(response.status_code, 401) 85 | self.assertTrue("WWW-Authenticate" in response.headers) 86 | self.assertEqual(response.headers["WWW-Authenticate"], 87 | 'MyToken realm="Foo"') 88 | 89 | def test_multi_auth_login_invalid_scheme(self): 90 | req, response = self.client.get( 91 | "/protected", headers={"Authorization": "Foo this-is-the-token!"} 92 | ) 93 | self.assertEqual(response.status_code, 401) 94 | self.assertTrue("WWW-Authenticate" in response.headers) 95 | self.assertEqual( 96 | response.headers["WWW-Authenticate"], 97 | 'Basic realm="Authentication Required"', 98 | ) 99 | 100 | def test_multi_malformed_header(self): 101 | req, response = self.client.get( 102 | "/protected", headers={"Authorization": "token-without-scheme"} 103 | ) 104 | self.assertEqual(response.status_code, 401) 105 | -------------------------------------------------------------------------------- /tests/test_token.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from sanic import Sanic 3 | from sanic_cors import CORS 4 | from sanic_httpauth import HTTPTokenAuth 5 | from sanic.response import text 6 | 7 | 8 | class HTTPAuthTestCase(unittest.TestCase): 9 | def setUp(self): 10 | app = Sanic(__name__) 11 | app.config["SECRET_KEY"] = "my secret" 12 | app.config["CORS_AUTOMATIC_OPTIONS"] = True 13 | 14 | CORS(app) 15 | token_auth = HTTPTokenAuth("MyToken") 16 | 17 | @token_auth.verify_token 18 | def verify_token(token): 19 | return token == "this-is-the-token!" 20 | 21 | @token_auth.error_handler 22 | def error_handler(request): 23 | return text( 24 | "error", status=401, 25 | headers={"WWW-Authenticate": 'MyToken realm="Foo"'} 26 | ) 27 | 28 | @app.route("/") 29 | def index(request): 30 | return text("index") 31 | 32 | @app.route("/protected") 33 | @token_auth.login_required 34 | def token_auth_route(request): 35 | return text("token_auth") 36 | 37 | self.app = app 38 | self.token_auth = token_auth 39 | self.client = app.test_client 40 | 41 | def test_token_auth_prompt(self): 42 | rq, response = self.client.get("/protected") 43 | self.assertEqual(response.status, 401) 44 | self.assertTrue("WWW-Authenticate" in response.headers) 45 | self.assertEqual(response.headers["WWW-Authenticate"], 46 | 'MyToken realm="Foo"') 47 | 48 | def test_token_auth_ignore_options(self): 49 | rq, response = self.client.options("/protected") 50 | self.assertEqual(response.status, 200) 51 | self.assertTrue("WWW-Authenticate" not in response.headers) 52 | 53 | def test_token_auth_login_valid(self): 54 | rq, response = self.client.get( 55 | "/protected", 56 | headers={"Authorization": "MyToken this-is-the-token!"} 57 | ) 58 | self.assertEqual(response.content.decode("utf-8"), "token_auth") 59 | 60 | def test_token_auth_login_valid_different_case(self): 61 | rq, response = self.client.get( 62 | "/protected", 63 | headers={"Authorization": "mytoken this-is-the-token!"} 64 | ) 65 | self.assertEqual(response.content.decode("utf-8"), "token_auth") 66 | 67 | def test_token_auth_login_invalid_token(self): 68 | rq, response = self.client.get( 69 | "/protected", 70 | headers={"Authorization": "MyToken this-is-not-the-token!"} 71 | ) 72 | self.assertEqual(response.status, 401) 73 | self.assertTrue("WWW-Authenticate" in response.headers) 74 | self.assertEqual(response.headers["WWW-Authenticate"], 75 | 'MyToken realm="Foo"') 76 | 77 | def test_token_auth_login_invalid_scheme(self): 78 | rq, response = self.client.get( 79 | "/protected", headers={"Authorization": "Foo this-is-the-token!"} 80 | ) 81 | self.assertEqual(response.status, 401) 82 | self.assertTrue("WWW-Authenticate" in response.headers) 83 | self.assertEqual(response.headers["WWW-Authenticate"], 84 | 'MyToken realm="Foo"') 85 | 86 | def test_token_auth_login_invalid_header(self): 87 | rq, response = self.client.get( 88 | "/protected", headers={"Authorization": "this-is-a-bad-header"} 89 | ) 90 | self.assertEqual(response.status, 401) 91 | self.assertTrue("WWW-Authenticate" in response.headers) 92 | self.assertEqual(response.headers["WWW-Authenticate"], 93 | 'MyToken realm="Foo"') 94 | 95 | def test_token_auth_login_invalid_no_callback(self): 96 | token_auth2 = HTTPTokenAuth("Token", realm="foo") 97 | 98 | @self.app.route("/protected2") 99 | @token_auth2.login_required 100 | def token_auth_route2(request): 101 | return text("token_auth2") 102 | 103 | rq, response = self.client.get( 104 | "/protected2", 105 | headers={"Authorization": "Token this-is-the-token!"} 106 | ) 107 | self.assertEqual(response.status, 401) 108 | self.assertTrue("WWW-Authenticate" in response.headers) 109 | self.assertEqual(response.headers["WWW-Authenticate"], 110 | 'Token realm="foo"') 111 | -------------------------------------------------------------------------------- /docs/_themes/flask_theme_support.py: -------------------------------------------------------------------------------- 1 | # flasky extensions. flasky pygments style based on tango style 2 | from pygments.style import Style 3 | from pygments.token import Keyword, Name, Comment, String, Error, \ 4 | Number, Operator, Generic, Whitespace, Punctuation, Other, Literal 5 | 6 | 7 | class FlaskyStyle(Style): 8 | background_color = "#f8f8f8" 9 | default_style = "" 10 | 11 | styles = { 12 | # No corresponding class for the following: 13 | #Text: "", # class: '' 14 | Whitespace: "underline #f8f8f8", # class: 'w' 15 | Error: "#a40000 border:#ef2929", # class: 'err' 16 | Other: "#000000", # class 'x' 17 | 18 | Comment: "italic #8f5902", # class: 'c' 19 | Comment.Preproc: "noitalic", # class: 'cp' 20 | 21 | Keyword: "bold #004461", # class: 'k' 22 | Keyword.Constant: "bold #004461", # class: 'kc' 23 | Keyword.Declaration: "bold #004461", # class: 'kd' 24 | Keyword.Namespace: "bold #004461", # class: 'kn' 25 | Keyword.Pseudo: "bold #004461", # class: 'kp' 26 | Keyword.Reserved: "bold #004461", # class: 'kr' 27 | Keyword.Type: "bold #004461", # class: 'kt' 28 | 29 | Operator: "#582800", # class: 'o' 30 | Operator.Word: "bold #004461", # class: 'ow' - like keywords 31 | 32 | Punctuation: "bold #000000", # class: 'p' 33 | 34 | # because special names such as Name.Class, Name.Function, etc. 35 | # are not recognized as such later in the parsing, we choose them 36 | # to look the same as ordinary variables. 37 | Name: "#000000", # class: 'n' 38 | Name.Attribute: "#c4a000", # class: 'na' - to be revised 39 | Name.Builtin: "#004461", # class: 'nb' 40 | Name.Builtin.Pseudo: "#3465a4", # class: 'bp' 41 | Name.Class: "#000000", # class: 'nc' - to be revised 42 | Name.Constant: "#000000", # class: 'no' - to be revised 43 | Name.Decorator: "#888", # class: 'nd' - to be revised 44 | Name.Entity: "#ce5c00", # class: 'ni' 45 | Name.Exception: "bold #cc0000", # class: 'ne' 46 | Name.Function: "#000000", # class: 'nf' 47 | Name.Property: "#000000", # class: 'py' 48 | Name.Label: "#f57900", # class: 'nl' 49 | Name.Namespace: "#000000", # class: 'nn' - to be revised 50 | Name.Other: "#000000", # class: 'nx' 51 | Name.Tag: "bold #004461", # class: 'nt' - like a keyword 52 | Name.Variable: "#000000", # class: 'nv' - to be revised 53 | Name.Variable.Class: "#000000", # class: 'vc' - to be revised 54 | Name.Variable.Global: "#000000", # class: 'vg' - to be revised 55 | Name.Variable.Instance: "#000000", # class: 'vi' - to be revised 56 | 57 | Number: "#990000", # class: 'm' 58 | 59 | Literal: "#000000", # class: 'l' 60 | Literal.Date: "#000000", # class: 'ld' 61 | 62 | String: "#4e9a06", # class: 's' 63 | String.Backtick: "#4e9a06", # class: 'sb' 64 | String.Char: "#4e9a06", # class: 'sc' 65 | String.Doc: "italic #8f5902", # class: 'sd' - like a comment 66 | String.Double: "#4e9a06", # class: 's2' 67 | String.Escape: "#4e9a06", # class: 'se' 68 | String.Heredoc: "#4e9a06", # class: 'sh' 69 | String.Interpol: "#4e9a06", # class: 'si' 70 | String.Other: "#4e9a06", # class: 'sx' 71 | String.Regex: "#4e9a06", # class: 'sr' 72 | String.Single: "#4e9a06", # class: 's1' 73 | String.Symbol: "#4e9a06", # class: 'ss' 74 | 75 | Generic: "#000000", # class: 'g' 76 | Generic.Deleted: "#a40000", # class: 'gd' 77 | Generic.Emph: "italic #000000", # class: 'ge' 78 | Generic.Error: "#ef2929", # class: 'gr' 79 | Generic.Heading: "bold #000080", # class: 'gh' 80 | Generic.Inserted: "#00A000", # class: 'gi' 81 | Generic.Output: "#888", # class: 'go' 82 | Generic.Prompt: "#745334", # class: 'gp' 83 | Generic.Strong: "bold #000000", # class: 'gs' 84 | Generic.Subheading: "bold #800080", # class: 'gu' 85 | Generic.Traceback: "bold #a40000", # class: 'gt' 86 | } 87 | -------------------------------------------------------------------------------- /docs/_themes/flask_small/static/flasky.css_t: -------------------------------------------------------------------------------- 1 | /* 2 | * flasky.css_t 3 | * ~~~~~~~~~~~~ 4 | * 5 | * Sphinx stylesheet -- flasky theme based on nature theme. 6 | * 7 | * :copyright: Copyright 2007-2010 by the Sphinx team, see AUTHORS. 8 | * :license: BSD, see LICENSE for details. 9 | * 10 | */ 11 | 12 | @import url("basic.css"); 13 | 14 | /* -- page layout ----------------------------------------------------------- */ 15 | 16 | body { 17 | font-family: 'Georgia', serif; 18 | font-size: 17px; 19 | color: #000; 20 | background: white; 21 | margin: 0; 22 | padding: 0; 23 | } 24 | 25 | div.documentwrapper { 26 | float: left; 27 | width: 100%; 28 | } 29 | 30 | div.bodywrapper { 31 | margin: 40px auto 0 auto; 32 | width: 700px; 33 | } 34 | 35 | hr { 36 | border: 1px solid #B1B4B6; 37 | } 38 | 39 | div.body { 40 | background-color: #ffffff; 41 | color: #3E4349; 42 | padding: 0 30px 30px 30px; 43 | } 44 | 45 | img.floatingflask { 46 | padding: 0 0 10px 10px; 47 | float: right; 48 | } 49 | 50 | div.footer { 51 | text-align: right; 52 | color: #888; 53 | padding: 10px; 54 | font-size: 14px; 55 | width: 650px; 56 | margin: 0 auto 40px auto; 57 | } 58 | 59 | div.footer a { 60 | color: #888; 61 | text-decoration: underline; 62 | } 63 | 64 | div.related { 65 | line-height: 32px; 66 | color: #888; 67 | } 68 | 69 | div.related ul { 70 | padding: 0 0 0 10px; 71 | } 72 | 73 | div.related a { 74 | color: #444; 75 | } 76 | 77 | /* -- body styles ----------------------------------------------------------- */ 78 | 79 | a { 80 | color: #004B6B; 81 | text-decoration: underline; 82 | } 83 | 84 | a:hover { 85 | color: #6D4100; 86 | text-decoration: underline; 87 | } 88 | 89 | div.body { 90 | padding-bottom: 40px; /* saved for footer */ 91 | } 92 | 93 | div.body h1, 94 | div.body h2, 95 | div.body h3, 96 | div.body h4, 97 | div.body h5, 98 | div.body h6 { 99 | font-family: 'Garamond', 'Georgia', serif; 100 | font-weight: normal; 101 | margin: 30px 0px 10px 0px; 102 | padding: 0; 103 | } 104 | 105 | {% if theme_index_logo %} 106 | div.indexwrapper h1 { 107 | text-indent: -999999px; 108 | background: url({{ theme_index_logo }}) no-repeat center center; 109 | height: {{ theme_index_logo_height }}; 110 | } 111 | {% endif %} 112 | 113 | div.body h2 { font-size: 180%; } 114 | div.body h3 { font-size: 150%; } 115 | div.body h4 { font-size: 130%; } 116 | div.body h5 { font-size: 100%; } 117 | div.body h6 { font-size: 100%; } 118 | 119 | a.headerlink { 120 | color: white; 121 | padding: 0 4px; 122 | text-decoration: none; 123 | } 124 | 125 | a.headerlink:hover { 126 | color: #444; 127 | background: #eaeaea; 128 | } 129 | 130 | div.body p, div.body dd, div.body li { 131 | line-height: 1.4em; 132 | } 133 | 134 | div.admonition { 135 | background: #fafafa; 136 | margin: 20px -30px; 137 | padding: 10px 30px; 138 | border-top: 1px solid #ccc; 139 | border-bottom: 1px solid #ccc; 140 | } 141 | 142 | div.admonition p.admonition-title { 143 | font-family: 'Garamond', 'Georgia', serif; 144 | font-weight: normal; 145 | font-size: 24px; 146 | margin: 0 0 10px 0; 147 | padding: 0; 148 | line-height: 1; 149 | } 150 | 151 | div.admonition p.last { 152 | margin-bottom: 0; 153 | } 154 | 155 | div.highlight{ 156 | background-color: white; 157 | } 158 | 159 | dt:target, .highlight { 160 | background: #FAF3E8; 161 | } 162 | 163 | div.note { 164 | background-color: #eee; 165 | border: 1px solid #ccc; 166 | } 167 | 168 | div.seealso { 169 | background-color: #ffc; 170 | border: 1px solid #ff6; 171 | } 172 | 173 | div.topic { 174 | background-color: #eee; 175 | } 176 | 177 | div.warning { 178 | background-color: #ffe4e4; 179 | border: 1px solid #f66; 180 | } 181 | 182 | p.admonition-title { 183 | display: inline; 184 | } 185 | 186 | p.admonition-title:after { 187 | content: ":"; 188 | } 189 | 190 | pre, tt { 191 | font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; 192 | font-size: 0.85em; 193 | } 194 | 195 | img.screenshot { 196 | } 197 | 198 | tt.descname, tt.descclassname { 199 | font-size: 0.95em; 200 | } 201 | 202 | tt.descname { 203 | padding-right: 0.08em; 204 | } 205 | 206 | img.screenshot { 207 | -moz-box-shadow: 2px 2px 4px #eee; 208 | -webkit-box-shadow: 2px 2px 4px #eee; 209 | box-shadow: 2px 2px 4px #eee; 210 | } 211 | 212 | table.docutils { 213 | border: 1px solid #888; 214 | -moz-box-shadow: 2px 2px 4px #eee; 215 | -webkit-box-shadow: 2px 2px 4px #eee; 216 | box-shadow: 2px 2px 4px #eee; 217 | } 218 | 219 | table.docutils td, table.docutils th { 220 | border: 1px solid #888; 221 | padding: 0.25em 0.7em; 222 | } 223 | 224 | table.field-list, table.footnote { 225 | border: none; 226 | -moz-box-shadow: none; 227 | -webkit-box-shadow: none; 228 | box-shadow: none; 229 | } 230 | 231 | table.footnote { 232 | margin: 15px 0; 233 | width: 100%; 234 | border: 1px solid #eee; 235 | } 236 | 237 | table.field-list th { 238 | padding: 0 0.8em 0 0; 239 | } 240 | 241 | table.field-list td { 242 | padding: 0; 243 | } 244 | 245 | table.footnote td { 246 | padding: 0.5em; 247 | } 248 | 249 | dl { 250 | margin: 0; 251 | padding: 0; 252 | } 253 | 254 | dl dd { 255 | margin-left: 30px; 256 | } 257 | 258 | pre { 259 | padding: 0; 260 | margin: 15px -30px; 261 | padding: 8px; 262 | line-height: 1.3em; 263 | padding: 7px 30px; 264 | background: #eee; 265 | border-radius: 2px; 266 | -moz-border-radius: 2px; 267 | -webkit-border-radius: 2px; 268 | } 269 | 270 | dl pre { 271 | margin-left: -60px; 272 | padding-left: 60px; 273 | } 274 | 275 | tt { 276 | background-color: #ecf0f3; 277 | color: #222; 278 | /* padding: 1px 2px; */ 279 | } 280 | 281 | tt.xref, a tt { 282 | background-color: #FBFBFB; 283 | } 284 | 285 | a:hover tt { 286 | background: #EEE; 287 | } 288 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Flask-HTTPAuth.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Flask-HTTPAuth.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Flask-HTTPAuth" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Flask-HTTPAuth" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Flask-HTTPAuth.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Flask-HTTPAuth.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /tests/test_digest_get_password_without_session.py: -------------------------------------------------------------------------------- 1 | import re 2 | import unittest 3 | from hashlib import md5 as basic_md5 4 | from unittest.mock import patch 5 | 6 | from sanic import Sanic 7 | from sanic.response import text 8 | from sanic_cors import CORS 9 | from sanic_httpauth import HTTPDigestAuth 10 | from sanic_httpauth_compat import parse_dict_header 11 | 12 | 13 | def md5(str): 14 | if type(str).__name__ == "str": 15 | str = str.encode("utf-8") 16 | return basic_md5(str) 17 | 18 | 19 | def get_ha1(user, pw, realm): 20 | a1 = user + ":" + realm + ":" + pw 21 | return md5(a1).hexdigest() 22 | 23 | 24 | class HTTPAuthTestCase(unittest.TestCase): 25 | def setUp(self): 26 | app = Sanic(__name__) 27 | app.config["SECRET_KEY"] = "my secret" 28 | app.config["CORS_AUTOMATIC_OPTIONS"] = True 29 | 30 | CORS(app) 31 | digest_auth = HTTPDigestAuth(use_session=False, qop="auth") 32 | 33 | @digest_auth.get_password 34 | def get_digest_password_2(username): 35 | if username == "susan": 36 | return "hello" 37 | elif username == "john": 38 | return "bye" 39 | else: 40 | return None 41 | 42 | @app.route("/") 43 | def index(request): 44 | return text("index") 45 | 46 | @app.route("/digest") 47 | @digest_auth.login_required 48 | def digest_auth_route(request): 49 | return text(f"digest_auth:{digest_auth.username(request)}") 50 | 51 | self.app = app 52 | self.digest_auth = digest_auth 53 | self.client = app.test_client 54 | 55 | def test_digest_auth_prompt(self): 56 | req, response = self.client.get("/digest") 57 | self.assertEqual(response.status_code, 401) 58 | self.assertTrue("WWW-Authenticate" in response.headers) 59 | self.assertTrue( 60 | re.match( 61 | r'^Digest realm="Authentication Required", ' 62 | r'nonce="[0-9a-f]+", qop="auth", opaque="[0-9a-f]+"$', 63 | response.headers["WWW-Authenticate"], 64 | ) 65 | ) 66 | 67 | def test_digest_auth_ignore_options(self): 68 | req, response = self.client.options("/digest") 69 | self.assertEqual(response.status_code, 200) 70 | self.assertTrue("WWW-Authenticate" not in response.headers) 71 | 72 | @patch.object(HTTPDigestAuth, '_generate_random') 73 | def test_digest_auth_login_valid(self, generate_random_mock): 74 | generate_random_mock.side_effect = [ 75 | "9549bf6d4fd6206e2945e8501481ddd5", 76 | "47c67cc7bedf6bc754f044f77f32b99e"] 77 | req, response = self.client.get("/digest") 78 | self.assertTrue(response.status_code == 401) 79 | header = response.headers.get("WWW-Authenticate") 80 | auth_type, auth_info = header.split(None, 1) 81 | d = parse_dict_header(auth_info) 82 | 83 | auth_response = "21ca18e29c5dcc5c418d95fe8bef9477" 84 | 85 | req, response = self.client.get( 86 | "/digest", 87 | headers={ 88 | "Authorization": 'Digest username="john",realm="{0}",' 89 | 'nonce="{1}",uri="/digest",response="{2}",' 90 | 'opaque="{3}", nc="00000001",cnonce="5fd0a782"'.format( 91 | d["realm"], d["nonce"], auth_response, d["opaque"] 92 | ) 93 | }, 94 | ) 95 | print(response.content) 96 | self.assertEqual(response.content, b"digest_auth:john") 97 | 98 | def test_digest_auth_login_bad_realm(self): 99 | req, response = self.client.get("/digest") 100 | self.assertTrue(response.status_code == 401) 101 | self.assertTrue(response.cookies is not None) 102 | header = response.headers.get("WWW-Authenticate") 103 | auth_type, auth_info = header.split(None, 1) 104 | d = parse_dict_header(auth_info) 105 | 106 | auth_response = md5("Authentication").hexdigest() 107 | 108 | req, response = self.client.get( 109 | "/digest", 110 | headers={ 111 | "Authorization": 'Digest username="john",realm="{0}",' 112 | 'nonce="{1}",qop= "auth",uri="/digest",response="{2}",' 113 | 'opaque="{3}"'.format( 114 | d["realm"], d["nonce"], auth_response, d["opaque"] 115 | ) 116 | }, 117 | ) 118 | self.assertEqual(response.status_code, 401) 119 | self.assertTrue("WWW-Authenticate" in response.headers) 120 | self.assertTrue( 121 | re.match( 122 | r'^Digest realm="Authentication Required", ' 123 | r'nonce="[0-9a-f]+", qop="auth", opaque="[0-9a-f]+"$', 124 | response.headers["WWW-Authenticate"], 125 | ) 126 | ) 127 | 128 | def test_digest_auth_login_invalid2(self): 129 | req, response = self.client.get("/digest") 130 | self.assertEqual(response.status_code, 401) 131 | header = response.headers.get("WWW-Authenticate") 132 | auth_type, auth_info = header.split(None, 1) 133 | d = parse_dict_header(auth_info) 134 | 135 | auth_response = md5("Authentication").hexdigest() 136 | 137 | req, response = self.client.get( 138 | "/digest", 139 | headers={ 140 | "Authorization": 'Digest username="david",realm="{0}",' 141 | 'nonce="{1}",uri="/digest",response="{2}",' 142 | 'opaque="{3}"'.format( 143 | d["realm"], d["nonce"], auth_response, d["opaque"] 144 | ) 145 | }, 146 | ) 147 | self.assertEqual(response.status_code, 401) 148 | self.assertTrue("WWW-Authenticate" in response.headers) 149 | self.assertTrue( 150 | re.match( 151 | r'^Digest realm="Authentication Required", ' 152 | r'nonce="[0-9a-f]+", qop="auth", opaque="[0-9a-f]+"$', 153 | response.headers["WWW-Authenticate"], 154 | ) 155 | ) 156 | 157 | def test_digest_generate_ha1(self): 158 | ha1 = self.digest_auth.generate_ha1("pawel", "test") 159 | ha1_expected = get_ha1("pawel", "test", self.digest_auth.realm) 160 | self.assertEqual(ha1, ha1_expected) 161 | 162 | def test_digest_custom_nonce_checker(self): 163 | @self.digest_auth.generate_nonce 164 | def noncemaker(request): 165 | return "not a good nonce" 166 | 167 | @self.digest_auth.generate_opaque 168 | def opaquemaker(request): 169 | return "some opaque" 170 | 171 | verify_nonce_called = [] 172 | 173 | @self.digest_auth.verify_nonce 174 | def verify_nonce(request, provided_nonce): 175 | verify_nonce_called.append(provided_nonce) 176 | return True 177 | 178 | verify_opaque_called = [] 179 | 180 | @self.digest_auth.verify_opaque 181 | def verify_opaque(request, provided_opaque): 182 | verify_opaque_called.append(provided_opaque) 183 | return True 184 | 185 | req, response = self.client.get("/digest") 186 | self.assertEqual(response.status_code, 401) 187 | header = response.headers.get("WWW-Authenticate") 188 | auth_type, auth_info = header.split(None, 1) 189 | d = parse_dict_header(auth_info) 190 | 191 | self.assertEqual(d["nonce"], "not a good nonce") 192 | self.assertEqual(d["opaque"], "some opaque") 193 | 194 | auth_response = "648057b3e2e2cfeb838d03dba6bce576" 195 | 196 | req, response = self.client.get( 197 | "/digest", 198 | headers={ 199 | "Authorization": 'Digest username="john",realm="{0}",' 200 | 'nonce="{1}",uri="/digest",response="{2}",' 201 | 'opaque="{3}",nc="00000001",cnonce="5fd0a782"'.format( 202 | d["realm"], d["nonce"], auth_response, d["opaque"] 203 | ) 204 | }, 205 | ) 206 | self.assertEqual(response.content, b"digest_auth:john") 207 | self.assertEqual( 208 | verify_nonce_called, ["not a good nonce"], 209 | "Should have verified the nonce." 210 | ) 211 | self.assertEqual( 212 | verify_opaque_called, ["some opaque"], 213 | "Should have verified the opaque." 214 | ) 215 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Flask-HTTPAuth documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Jul 26 14:48:13 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | sys.path.insert(0, os.path.abspath('..')) 20 | sys.path.append(os.path.abspath('_themes')) 21 | 22 | # -- General configuration ----------------------------------------------------- 23 | 24 | # If your documentation needs a minimal Sphinx version, state it here. 25 | #needs_sphinx = '1.0' 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be extensions 28 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 29 | extensions = ['sphinx.ext.autodoc'] 30 | 31 | # Add any paths that contain templates here, relative to this directory. 32 | templates_path = ['_templates'] 33 | 34 | # The suffix of source filenames. 35 | source_suffix = '.rst' 36 | 37 | # The encoding of source files. 38 | #source_encoding = 'utf-8-sig' 39 | 40 | # The master toctree document. 41 | master_doc = 'index' 42 | 43 | # General information about the project. 44 | project = u'Flask-HTTPAuth' 45 | copyright = u'2013, Miguel Grinberg' 46 | 47 | # The version info for the project you're documenting, acts as replacement for 48 | # |version| and |release|, also used in various other places throughout the 49 | # built documents. 50 | # 51 | # The short X.Y version. 52 | #version = '0.7' 53 | # The full version, including alpha/beta/rc tags. 54 | #release = '0.7.0' 55 | 56 | # The language for content autogenerated by Sphinx. Refer to documentation 57 | # for a list of supported languages. 58 | #language = None 59 | 60 | # There are two options for replacing |today|: either, you set today to some 61 | # non-false value, then it is used: 62 | #today = '' 63 | # Else, today_fmt is used as the format for a strftime call. 64 | #today_fmt = '%B %d, %Y' 65 | 66 | # List of patterns, relative to source directory, that match files and 67 | # directories to ignore when looking for source files. 68 | exclude_patterns = ['_build'] 69 | 70 | # The reST default role (used for this markup: `text`) to use for all documents. 71 | #default_role = None 72 | 73 | # If true, '()' will be appended to :func: etc. cross-reference text. 74 | #add_function_parentheses = True 75 | 76 | # If true, the current module name will be prepended to all description 77 | # unit titles (such as .. function::). 78 | #add_module_names = True 79 | 80 | # If true, sectionauthor and moduleauthor directives will be shown in the 81 | # output. They are ignored by default. 82 | #show_authors = False 83 | 84 | # The name of the Pygments (syntax highlighting) style to use. 85 | pygments_style = 'sphinx' 86 | 87 | # A list of ignored prefixes for module index sorting. 88 | #modindex_common_prefix = [] 89 | 90 | # If true, keep warnings as "system message" paragraphs in the built documents. 91 | #keep_warnings = False 92 | 93 | 94 | # -- Options for HTML output --------------------------------------------------- 95 | 96 | # The theme to use for HTML and HTML Help pages. See the documentation for 97 | # a list of builtin themes. 98 | html_theme = 'flask_small' 99 | 100 | # Theme options are theme-specific and customize the look and feel of a theme 101 | # further. For a list of options available for each theme, see the 102 | # documentation. 103 | html_theme_options = { 104 | 'index_logo': 'logo.png', 105 | 'github_fork': 'miguelgrinberg/Flask-HTTPAuth' 106 | } 107 | 108 | # Add any paths that contain custom themes here, relative to this directory. 109 | html_theme_path = ['_themes'] 110 | 111 | # The name for this set of Sphinx documents. If None, it defaults to 112 | # " v documentation". 113 | #html_title = None 114 | 115 | # A shorter title for the navigation bar. Default is the same as html_title. 116 | #html_short_title = None 117 | 118 | # The name of an image file (relative to this directory) to place at the top 119 | # of the sidebar. 120 | #html_logo = None 121 | 122 | # The name of an image file (within the static path) to use as favicon of the 123 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 124 | # pixels large. 125 | #html_favicon = None 126 | 127 | # Add any paths that contain custom static files (such as style sheets) here, 128 | # relative to this directory. They are copied after the builtin static files, 129 | # so a file named "default.css" will overwrite the builtin "default.css". 130 | html_static_path = ['_static'] 131 | 132 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 133 | # using the given strftime format. 134 | #html_last_updated_fmt = '%b %d, %Y' 135 | 136 | # If true, SmartyPants will be used to convert quotes and dashes to 137 | # typographically correct entities. 138 | #html_use_smartypants = True 139 | 140 | # Custom sidebar templates, maps document names to template names. 141 | #html_sidebars = {} 142 | 143 | # Additional templates that should be rendered to pages, maps page names to 144 | # template names. 145 | #html_additional_pages = {} 146 | 147 | # If false, no module index is generated. 148 | #html_domain_indices = True 149 | 150 | # If false, no index is generated. 151 | #html_use_index = True 152 | 153 | # If true, the index is split into individual pages for each letter. 154 | #html_split_index = False 155 | 156 | # If true, links to the reST sources are added to the pages. 157 | #html_show_sourcelink = True 158 | 159 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 160 | #html_show_sphinx = True 161 | 162 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 163 | #html_show_copyright = True 164 | 165 | # If true, an OpenSearch description file will be output, and all pages will 166 | # contain a tag referring to it. The value of this option must be the 167 | # base URL from which the finished HTML is served. 168 | #html_use_opensearch = '' 169 | 170 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 171 | #html_file_suffix = None 172 | 173 | # Output file base name for HTML help builder. 174 | htmlhelp_basename = 'Flask-HTTPAuthdoc' 175 | 176 | 177 | # -- Options for LaTeX output -------------------------------------------------- 178 | 179 | latex_elements = { 180 | # The paper size ('letterpaper' or 'a4paper'). 181 | #'papersize': 'letterpaper', 182 | 183 | # The font size ('10pt', '11pt' or '12pt'). 184 | #'pointsize': '10pt', 185 | 186 | # Additional stuff for the LaTeX preamble. 187 | #'preamble': '', 188 | } 189 | 190 | # Grouping the document tree into LaTeX files. List of tuples 191 | # (source start file, target name, title, author, documentclass [howto/manual]). 192 | latex_documents = [ 193 | ('index', 'Flask-HTTPAuth.tex', u'Flask-HTTPAuth Documentation', 194 | u'Miguel Grinberg', 'manual'), 195 | ] 196 | 197 | # The name of an image file (relative to this directory) to place at the top of 198 | # the title page. 199 | #latex_logo = None 200 | 201 | # For "manual" documents, if this is true, then toplevel headings are parts, 202 | # not chapters. 203 | #latex_use_parts = False 204 | 205 | # If true, show page references after internal links. 206 | #latex_show_pagerefs = False 207 | 208 | # If true, show URL addresses after external links. 209 | #latex_show_urls = False 210 | 211 | # Documents to append as an appendix to all manuals. 212 | #latex_appendices = [] 213 | 214 | # If false, no module index is generated. 215 | #latex_domain_indices = True 216 | 217 | 218 | # -- Options for manual page output -------------------------------------------- 219 | 220 | # One entry per manual page. List of tuples 221 | # (source start file, name, description, authors, manual section). 222 | man_pages = [ 223 | ('index', 'flask-httpauth', u'Flask-HTTPAuth Documentation', 224 | [u'Miguel Grinberg'], 1) 225 | ] 226 | 227 | # If true, show URL addresses after external links. 228 | #man_show_urls = False 229 | 230 | 231 | # -- Options for Texinfo output ------------------------------------------------ 232 | 233 | # Grouping the document tree into Texinfo files. List of tuples 234 | # (source start file, target name, title, author, 235 | # dir menu entry, description, category) 236 | texinfo_documents = [ 237 | ('index', 'Flask-HTTPAuth', u'Flask-HTTPAuth Documentation', 238 | u'Miguel Grinberg', 'Flask-HTTPAuth', 'One line description of project.', 239 | 'Miscellaneous'), 240 | ] 241 | 242 | # Documents to append as an appendix to all manuals. 243 | #texinfo_appendices = [] 244 | 245 | # If false, no module index is generated. 246 | #texinfo_domain_indices = True 247 | 248 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 249 | #texinfo_show_urls = 'footnote' 250 | 251 | # If true, do not generate a @detailmenu in the "Top" node's menu. 252 | #texinfo_no_detailmenu = False 253 | -------------------------------------------------------------------------------- /tests/test_digest_get_password.py: -------------------------------------------------------------------------------- 1 | import re 2 | import unittest 3 | from hashlib import md5 as basic_md5 4 | from unittest.mock import patch 5 | 6 | from sanic import Sanic 7 | from sanic.response import text 8 | from sanic_cors import CORS 9 | from sanic_httpauth import HTTPDigestAuth 10 | from sanic_httpauth_compat import parse_dict_header 11 | from sanic_session import InMemorySessionInterface, Session 12 | 13 | 14 | def md5(str): 15 | if type(str).__name__ == "str": 16 | str = str.encode("utf-8") 17 | return basic_md5(str) 18 | 19 | 20 | def get_ha1(user, pw, realm): 21 | a1 = user + ":" + realm + ":" + pw 22 | return md5(a1).hexdigest() 23 | 24 | 25 | class HTTPAuthTestCase(unittest.TestCase): 26 | def setUp(self): 27 | app = Sanic(__name__) 28 | app.config["SECRET_KEY"] = "my secret" 29 | app.config["CORS_AUTOMATIC_OPTIONS"] = True 30 | 31 | CORS(app) 32 | Session(app, interface=InMemorySessionInterface( 33 | cookie_name="test_session")) 34 | digest_auth = HTTPDigestAuth(qop="auth") 35 | 36 | @digest_auth.get_password 37 | def get_digest_password_2(username): 38 | if username == "susan": 39 | return "hello" 40 | elif username == "john": 41 | return "bye" 42 | else: 43 | return None 44 | 45 | @app.route("/") 46 | def index(request): 47 | return text("index") 48 | 49 | @app.route("/digest") 50 | @digest_auth.login_required 51 | def digest_auth_route(request): 52 | return text(f"digest_auth:{digest_auth.username(request)}") 53 | 54 | self.app = app 55 | self.digest_auth = digest_auth 56 | self.client = app.test_client 57 | 58 | def test_digest_auth_prompt(self): 59 | req, response = self.client.get("/digest") 60 | self.assertEqual(response.status_code, 401) 61 | self.assertTrue("WWW-Authenticate" in response.headers) 62 | self.assertTrue( 63 | re.match( 64 | r'^Digest realm="Authentication Required", ' 65 | r'nonce="[0-9a-f]+", qop="auth", opaque="[0-9a-f]+"$', 66 | response.headers["WWW-Authenticate"], 67 | ) 68 | ) 69 | self.assertTrue(response.cookies.get("test_session") is not None) 70 | 71 | def test_digest_auth_ignore_options(self): 72 | req, response = self.client.options("/digest") 73 | self.assertEqual(response.status_code, 200) 74 | self.assertTrue("WWW-Authenticate" not in response.headers) 75 | 76 | @patch.object(HTTPDigestAuth, '_generate_random') 77 | def test_digest_auth_login_valid(self, generate_random_mock): 78 | generate_random_mock.side_effect = [ 79 | "9549bf6d4fd6206e2945e8501481ddd5", 80 | "47c67cc7bedf6bc754f044f77f32b99e"] 81 | req, response = self.client.get("/digest") 82 | self.assertTrue(response.status_code == 401) 83 | header = response.headers["WWW-Authenticate"] 84 | print(header) 85 | auth_type, auth_info = header.split(None, 1) 86 | d = parse_dict_header(auth_info) 87 | 88 | auth_response = "21ca18e29c5dcc5c418d95fe8bef9477" 89 | 90 | req, response = self.client.get( 91 | "/digest", 92 | headers={ 93 | "Authorization": 'Digest username="john",realm="{0}",' 94 | 'nonce="{1}", qop="auth", uri="/digest",response="{2}",' 95 | 'opaque="{3}",nc="00000001",cnonce="5fd0a782"'.format( 96 | d["realm"], d["nonce"], auth_response, d["opaque"] 97 | ) 98 | }, 99 | cookies={"test_session": response.cookies.get("test_session")}, 100 | ) 101 | self.assertEqual(response.content, b"digest_auth:john") 102 | 103 | def test_digest_auth_login_bad_realm(self): 104 | req, response = self.client.get("/digest") 105 | self.assertTrue(response.status_code == 401) 106 | self.assertTrue(response.cookies.get("test_session") is not None) 107 | header = (f'Digest realm="Wrong Realm", ' 108 | f'nonce="9549bf6d4fd6206e2945e8501481ddd5", qop="auth", ' 109 | f'nc="00000001", cnonce="5fd0a782", ' 110 | f'opaque="47c67cc7bedf6bc754f044f77f32b99e"') 111 | response.headers["WWW-Authenticate"] = header 112 | auth_type, auth_info = header.split(None, 1) 113 | d = parse_dict_header(auth_info) 114 | 115 | auth_response = "" 116 | 117 | req, response = self.client.get( 118 | "/digest", 119 | headers={ 120 | "Authorization": 'Digest username="john",realm="{0}",' 121 | 'nonce="{1}",uri="/digest",response="{2}",' 122 | 'opaque="{3}"'.format( 123 | d["realm"], d["nonce"], auth_response, d["opaque"] 124 | ) 125 | }, 126 | cookies={"test_session": response.cookies.get("test_session")}, 127 | ) 128 | self.assertEqual(response.status_code, 401) 129 | self.assertTrue("WWW-Authenticate" in response.headers) 130 | self.assertTrue( 131 | re.match( 132 | r'^Digest realm="Authentication Required", ' 133 | r'nonce="[0-9a-f]+", qop="auth", opaque="[0-9a-f]+"$', 134 | response.headers["WWW-Authenticate"], 135 | ) 136 | ) 137 | 138 | def test_digest_auth_login_invalid2(self): 139 | req, response = self.client.get("/digest") 140 | self.assertEqual(response.status_code, 401) 141 | self.assertTrue(response.cookies.get("test_session") is not None) 142 | header = response.headers.get("WWW-Authenticate") 143 | auth_type, auth_info = header.split(None, 1) 144 | d = parse_dict_header(auth_info) 145 | 146 | a1 = "david:" + "Authentication Required" + ":bye" 147 | ha1 = md5(a1).hexdigest() 148 | a2 = "GET:/digest" 149 | ha2 = md5(a2).hexdigest() 150 | a3 = ha1 + ":" + d["nonce"] + ":" + ha2 151 | auth_response = md5(a3).hexdigest() 152 | 153 | req, response = self.client.get( 154 | "/digest", 155 | headers={ 156 | "Authorization": 'Digest username="david",realm="{0}",' 157 | 'nonce="{1}",uri="/digest",response="{2}",' 158 | 'opaque="{3}"'.format( 159 | d["realm"], d["nonce"], auth_response, d["opaque"] 160 | ) 161 | }, 162 | cookies={"test_session": response.cookies.get("test_session")}, 163 | ) 164 | self.assertEqual(response.status_code, 401) 165 | self.assertTrue("WWW-Authenticate" in response.headers) 166 | self.assertTrue( 167 | re.match( 168 | r'^Digest realm="Authentication Required", ' 169 | r'nonce="[0-9a-f]+", qop="auth", opaque="[0-9a-f]+"$', 170 | response.headers["WWW-Authenticate"], 171 | ) 172 | ) 173 | 174 | def test_digest_generate_ha1(self): 175 | ha1 = self.digest_auth.generate_ha1("pawel", "test") 176 | ha1_expected = get_ha1("pawel", "test", self.digest_auth.realm) 177 | self.assertEqual(ha1, ha1_expected) 178 | 179 | def test_digest_custom_nonce_checker(self): 180 | @self.digest_auth.generate_nonce 181 | def noncemaker(request): 182 | return "not a good nonce" 183 | 184 | @self.digest_auth.generate_opaque 185 | def opaquemaker(request): 186 | return "some opaque" 187 | 188 | verify_nonce_called = [] 189 | 190 | @self.digest_auth.verify_nonce 191 | def verify_nonce(request, provided_nonce): 192 | verify_nonce_called.append(provided_nonce) 193 | return True 194 | 195 | verify_opaque_called = [] 196 | 197 | @self.digest_auth.verify_opaque 198 | def verify_opaque(request, provided_opaque): 199 | verify_opaque_called.append(provided_opaque) 200 | return True 201 | 202 | req, response = self.client.get("/digest") 203 | self.assertEqual(response.status_code, 401) 204 | header = response.headers["WWW-Authenticate"] 205 | auth_type, auth_info = header.split(None, 1) 206 | d = parse_dict_header(auth_info) 207 | 208 | self.assertEqual(d["nonce"], "not a good nonce") 209 | self.assertEqual(d["opaque"], "some opaque") 210 | 211 | auth_response = "648057b3e2e2cfeb838d03dba6bce576" 212 | 213 | req, response = self.client.get( 214 | "/digest", 215 | headers={ 216 | "Authorization": 'Digest username="john",realm="{0}",' 217 | 'nonce="{1}",qop="auth",uri="/digest",response="{2}",' 218 | 'opaque="{3}",nc="00000001",cnonce="5fd0a782"'.format( 219 | d["realm"], d["nonce"], auth_response, d["opaque"] 220 | ) 221 | }, 222 | cookies={"test_session": response.cookies.get("test_session")}, 223 | ) 224 | self.assertEqual(response.content, b"digest_auth:john") 225 | self.assertEqual( 226 | verify_nonce_called, ["not a good nonce"], 227 | "Should have verified the nonce." 228 | ) 229 | self.assertEqual( 230 | verify_opaque_called, ["some opaque"], 231 | "Should have verified the opaque." 232 | ) 233 | -------------------------------------------------------------------------------- /sanic_httpauth_compat.py: -------------------------------------------------------------------------------- 1 | # Borrowed code from werkzeug: https://github.com/pallets/werkzeug 2 | import base64 3 | import hmac 4 | import logging 5 | import sanic.response 6 | import sys 7 | 8 | from sanic.request import Request 9 | from urllib.request import parse_http_list as _parse_list_header 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | _builtin_safe_str_cmp = getattr(hmac, "compare_digest", None) 14 | 15 | 16 | def safe_str_cmp(a, b): 17 | """This function compares strings in somewhat constant time. This 18 | requires that the length of at least one string is known in advance. 19 | Returns `True` if the two strings are equal, or `False` if they are not. 20 | .. versionadded:: 0.7 21 | """ 22 | if isinstance(a, str): 23 | a = a.encode("utf-8") 24 | if isinstance(b, str): 25 | b = b.encode("utf-8") 26 | 27 | if _builtin_safe_str_cmp is not None: 28 | return _builtin_safe_str_cmp(a, b) 29 | 30 | if len(a) != len(b): 31 | return False 32 | 33 | rv = 0 34 | if PY2: 35 | for x, y in izip(a, b): 36 | rv |= ord(x) ^ ord(y) 37 | else: 38 | for x, y in izip(a, b): 39 | rv |= x ^ y 40 | 41 | return rv == 0 42 | 43 | 44 | class ImmutableDictMixin(object): 45 | """Makes a :class:`dict` immutable. 46 | .. versionadded:: 0.5 47 | :private: 48 | """ 49 | 50 | _hash_cache = None 51 | 52 | @classmethod 53 | def fromkeys(cls, keys, value=None): 54 | instance = super(cls, cls).__new__(cls) 55 | instance.__init__(zip(keys, repeat(value))) 56 | return instance 57 | 58 | def __reduce_ex__(self, protocol): 59 | return type(self), (dict(self),) 60 | 61 | def _iter_hashitems(self): 62 | return iteritems(self) 63 | 64 | def __hash__(self): 65 | if self._hash_cache is not None: 66 | return self._hash_cache 67 | rv = self._hash_cache = hash(frozenset(self._iter_hashitems())) 68 | return rv 69 | 70 | def setdefault(self, key, default=None): 71 | is_immutable(self) 72 | 73 | def update(self, *args, **kwargs): 74 | is_immutable(self) 75 | 76 | def pop(self, key, default=None): 77 | is_immutable(self) 78 | 79 | def popitem(self): 80 | is_immutable(self) 81 | 82 | def __setitem__(self, key, value): 83 | is_immutable(self) 84 | 85 | def __delitem__(self, key): 86 | is_immutable(self) 87 | 88 | def clear(self): 89 | is_immutable(self) 90 | 91 | 92 | class Authorization(ImmutableDictMixin, dict): 93 | """Represents an `Authorization` header sent by the client. You should 94 | not create this kind of object yourself but use it when it's returned by 95 | the `parse_authorization_header` function. 96 | This object is a dict subclass and can be altered by setting dict items 97 | but it should be considered immutable as it's returned by the client and 98 | not meant for modifications. 99 | .. versionchanged:: 0.5 100 | This object became immutable. 101 | """ 102 | 103 | def __init__(self, auth_type, data=None): 104 | dict.__init__(self, data or {}) 105 | self.type = auth_type 106 | 107 | username = property( 108 | lambda self: self.get("username"), 109 | doc=""" 110 | The username transmitted. This is set for both basic and digest 111 | auth all the time.""", 112 | ) 113 | password = property( 114 | lambda self: self.get("password"), 115 | doc=""" 116 | When the authentication type is basic this is the password 117 | transmitted by the client, else `None`.""", 118 | ) 119 | realm = property( 120 | lambda self: self.get("realm"), 121 | doc=""" 122 | This is the server realm sent back for HTTP digest auth.""", 123 | ) 124 | nonce = property( 125 | lambda self: self.get("nonce"), 126 | doc=""" 127 | The nonce the server sent for digest auth, sent back by the client. 128 | A nonce should be unique for every 401 response for HTTP digest 129 | auth.""", 130 | ) 131 | uri = property( 132 | lambda self: self.get("uri"), 133 | doc=""" 134 | The URI from Request-URI of the Request-Line; duplicated because 135 | proxies are allowed to change the Request-Line in transit. HTTP 136 | digest auth only.""", 137 | ) 138 | nc = property( 139 | lambda self: self.get("nc"), 140 | doc=""" 141 | The nonce count value transmitted by clients if a qop-header is 142 | also transmitted. HTTP digest auth only.""", 143 | ) 144 | cnonce = property( 145 | lambda self: self.get("cnonce"), 146 | doc=""" 147 | If the server sent a qop-header in the ``WWW-Authenticate`` 148 | header, the client has to provide this value for HTTP digest auth. 149 | See the RFC for more details.""", 150 | ) 151 | response = property( 152 | lambda self: self.get("response"), 153 | doc=""" 154 | A string of 32 hex digits computed as defined in RFC 2617, which 155 | proves that the user knows a password. Digest auth only.""", 156 | ) 157 | opaque = property( 158 | lambda self: self.get("opaque"), 159 | doc=""" 160 | The opaque header from the server returned unchanged by the client. 161 | It is recommended that this string be base64 or hexadecimal data. 162 | Digest auth only.""", 163 | ) 164 | qop = property( 165 | lambda self: self.get("qop"), 166 | doc=""" 167 | Indicates what "quality of protection" the client has applied to 168 | the message for HTTP digest auth. Note that this is a single token, 169 | not a quoted list of alternatives as in WWW-Authenticate.""", 170 | ) 171 | 172 | 173 | def to_unicode( 174 | x, charset=sys.getdefaultencoding(), errors="strict", allow_none_charset=False 175 | ): 176 | if x is None: 177 | return None 178 | if not isinstance(x, bytes): 179 | return str(x) 180 | if charset is None and allow_none_charset: 181 | return x 182 | return x.decode(charset, errors) 183 | 184 | 185 | def bytes_to_wsgi(data): 186 | assert isinstance(data, bytes), "data must be bytes" 187 | if isinstance(data, str): 188 | return data 189 | else: 190 | return data.decode("latin1") 191 | 192 | 193 | def wsgi_to_bytes(data): 194 | """coerce wsgi unicode represented bytes to real ones""" 195 | if isinstance(data, bytes): 196 | return data 197 | return data.encode("latin1") # XXX: utf8 fallback? 198 | 199 | 200 | def unquote_header_value(value, is_filename=False): 201 | r"""Unquotes a header value. (Reversal of :func:`quote_header_value`). 202 | This does not use the real unquoting but what browsers are actually 203 | using for quoting. 204 | .. versionadded:: 0.5 205 | :param value: the header value to unquote. 206 | """ 207 | if value and value[0] == value[-1] == '"': 208 | # this is not the real unquoting, but fixing this so that the 209 | # RFC is met will result in bugs with internet explorer and 210 | # probably some other browsers as well. IE for example is 211 | # uploading files with "C:\foo\bar.txt" as filename 212 | value = value[1:-1] 213 | 214 | # if this is a filename and the starting characters look like 215 | # a UNC path, then just return the value without quotes. Using the 216 | # replace sequence below on a UNC path has the effect of turning 217 | # the leading double slash into a single slash and then 218 | # _fix_ie_filename() doesn't work correctly. See #458. 219 | if not is_filename or value[:2] != "\\\\": 220 | return value.replace("\\\\", "\\").replace('\\"', '"') 221 | return value 222 | 223 | 224 | def parse_dict_header(value, cls=dict): 225 | """Parse lists of key, value pairs as described by RFC 2068 Section 2 and 226 | convert them into a python dict (or any other mapping object created from 227 | the type with a dict like interface provided by the `cls` argument): 228 | >>> d = parse_dict_header('foo="is a fish", bar="as well"') 229 | >>> type(d) is dict 230 | True 231 | >>> sorted(d.items()) 232 | [('bar', 'as well'), ('foo', 'is a fish')] 233 | If there is no value for a key it will be `None`: 234 | >>> parse_dict_header('key_without_value') 235 | {'key_without_value': None} 236 | To create a header from the :class:`dict` again, use the 237 | :func:`dump_header` function. 238 | .. versionchanged:: 0.9 239 | Added support for `cls` argument. 240 | :param value: a string with a dict header. 241 | :param cls: callable to use for storage of parsed results. 242 | :return: an instance of `cls` 243 | """ 244 | result = cls() 245 | if not isinstance(value, str): 246 | # XXX: validate 247 | value = bytes_to_wsgi(value) 248 | for item in _parse_list_header(value): 249 | if "=" not in item: 250 | result[item] = None 251 | continue 252 | name, value = item.split("=", 1) 253 | if value[:1] == value[-1:] == '"': 254 | value = unquote_header_value(value[1:-1]) 255 | result[name] = value 256 | return result 257 | 258 | 259 | def parse_authorization_header(value): 260 | """Parse an HTTP basic/digest authorization header transmitted by the web 261 | browser. The return value is either `None` if the header was invalid or 262 | not given, otherwise an :class:`~werkzeug.datastructures.Authorization` 263 | object. 264 | :param value: the authorization header to parse. 265 | :return: a :class:`~werkzeug.datastructures.Authorization` object or `None`. 266 | """ 267 | if not value: 268 | return 269 | value = wsgi_to_bytes(value) 270 | try: 271 | auth_type, auth_info = value.split(None, 1) 272 | auth_type = auth_type.lower() 273 | except ValueError: 274 | return 275 | if auth_type == b"basic": 276 | try: 277 | username, password = base64.b64decode(auth_info).split(b":", 1) 278 | except Exception: 279 | return 280 | return Authorization( 281 | "basic", 282 | { 283 | "username": to_unicode(username, "utf-8"), 284 | "password": to_unicode(password, "utf-8"), 285 | }, 286 | ) 287 | elif auth_type == b"digest": 288 | auth_map = parse_dict_header(auth_info) 289 | for key in "username", "realm", "nonce", "uri", "response": 290 | if key not in auth_map: 291 | return 292 | if "qop" in auth_map: 293 | if not auth_map.get("nc") or not auth_map.get("cnonce"): 294 | return 295 | return Authorization("digest", auth_map) 296 | 297 | 298 | def get_request(*args, **kwargs): 299 | for a in args: 300 | if isinstance(a, Request): 301 | return a 302 | for k, v in kwargs.items(): 303 | if isinstance(v, Request): 304 | return v 305 | -------------------------------------------------------------------------------- /docs/_themes/flask/static/flasky.css_t: -------------------------------------------------------------------------------- 1 | /* 2 | * flasky.css_t 3 | * ~~~~~~~~~~~~ 4 | * 5 | * :copyright: Copyright 2010 by Armin Ronacher. 6 | * :license: Flask Design License, see LICENSE for details. 7 | */ 8 | 9 | {% set page_width = '940px' %} 10 | {% set sidebar_width = '220px' %} 11 | 12 | @import url("basic.css"); 13 | 14 | /* -- page layout ----------------------------------------------------------- */ 15 | 16 | body { 17 | font-family: 'Georgia', serif; 18 | font-size: 17px; 19 | background-color: white; 20 | color: #000; 21 | margin: 0; 22 | padding: 0; 23 | } 24 | 25 | div.document { 26 | width: {{ page_width }}; 27 | margin: 30px auto 0 auto; 28 | } 29 | 30 | div.documentwrapper { 31 | float: left; 32 | width: 100%; 33 | } 34 | 35 | div.bodywrapper { 36 | margin: 0 0 0 {{ sidebar_width }}; 37 | } 38 | 39 | div.sphinxsidebar { 40 | width: {{ sidebar_width }}; 41 | } 42 | 43 | hr { 44 | border: 1px solid #B1B4B6; 45 | } 46 | 47 | div.body { 48 | background-color: #ffffff; 49 | color: #3E4349; 50 | padding: 0 30px 0 30px; 51 | } 52 | 53 | img.floatingflask { 54 | padding: 0 0 10px 10px; 55 | float: right; 56 | } 57 | 58 | div.footer { 59 | width: {{ page_width }}; 60 | margin: 20px auto 30px auto; 61 | font-size: 14px; 62 | color: #888; 63 | text-align: right; 64 | } 65 | 66 | div.footer a { 67 | color: #888; 68 | } 69 | 70 | div.related { 71 | display: none; 72 | } 73 | 74 | div.sphinxsidebar a { 75 | color: #444; 76 | text-decoration: none; 77 | border-bottom: 1px dotted #999; 78 | } 79 | 80 | div.sphinxsidebar a:hover { 81 | border-bottom: 1px solid #999; 82 | } 83 | 84 | div.sphinxsidebar { 85 | font-size: 14px; 86 | line-height: 1.5; 87 | } 88 | 89 | div.sphinxsidebarwrapper { 90 | padding: 18px 10px; 91 | } 92 | 93 | div.sphinxsidebarwrapper p.logo { 94 | padding: 0 0 20px 0; 95 | margin: 0; 96 | text-align: center; 97 | } 98 | 99 | div.sphinxsidebar h3, 100 | div.sphinxsidebar h4 { 101 | font-family: 'Garamond', 'Georgia', serif; 102 | color: #444; 103 | font-size: 24px; 104 | font-weight: normal; 105 | margin: 0 0 5px 0; 106 | padding: 0; 107 | } 108 | 109 | div.sphinxsidebar h4 { 110 | font-size: 20px; 111 | } 112 | 113 | div.sphinxsidebar h3 a { 114 | color: #444; 115 | } 116 | 117 | div.sphinxsidebar p.logo a, 118 | div.sphinxsidebar h3 a, 119 | div.sphinxsidebar p.logo a:hover, 120 | div.sphinxsidebar h3 a:hover { 121 | border: none; 122 | } 123 | 124 | div.sphinxsidebar p { 125 | color: #555; 126 | margin: 10px 0; 127 | } 128 | 129 | div.sphinxsidebar ul { 130 | margin: 10px 0; 131 | padding: 0; 132 | color: #000; 133 | } 134 | 135 | div.sphinxsidebar input { 136 | border: 1px solid #ccc; 137 | font-family: 'Georgia', serif; 138 | font-size: 1em; 139 | } 140 | 141 | /* -- body styles ----------------------------------------------------------- */ 142 | 143 | a { 144 | color: #004B6B; 145 | text-decoration: underline; 146 | } 147 | 148 | a:hover { 149 | color: #6D4100; 150 | text-decoration: underline; 151 | } 152 | 153 | div.body h1, 154 | div.body h2, 155 | div.body h3, 156 | div.body h4, 157 | div.body h5, 158 | div.body h6 { 159 | font-family: 'Garamond', 'Georgia', serif; 160 | font-weight: normal; 161 | margin: 30px 0px 10px 0px; 162 | padding: 0; 163 | } 164 | 165 | {% if theme_index_logo %} 166 | div.indexwrapper h1 { 167 | text-indent: -999999px; 168 | background: url({{ theme_index_logo }}) no-repeat center center; 169 | height: {{ theme_index_logo_height }}; 170 | } 171 | {% endif %} 172 | div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; } 173 | div.body h2 { font-size: 180%; } 174 | div.body h3 { font-size: 150%; } 175 | div.body h4 { font-size: 130%; } 176 | div.body h5 { font-size: 100%; } 177 | div.body h6 { font-size: 100%; } 178 | 179 | a.headerlink { 180 | color: #ddd; 181 | padding: 0 4px; 182 | text-decoration: none; 183 | } 184 | 185 | a.headerlink:hover { 186 | color: #444; 187 | background: #eaeaea; 188 | } 189 | 190 | div.body p, div.body dd, div.body li { 191 | line-height: 1.4em; 192 | } 193 | 194 | div.admonition { 195 | background: #fafafa; 196 | margin: 20px -30px; 197 | padding: 10px 30px; 198 | border-top: 1px solid #ccc; 199 | border-bottom: 1px solid #ccc; 200 | } 201 | 202 | div.admonition tt.xref, div.admonition a tt { 203 | border-bottom: 1px solid #fafafa; 204 | } 205 | 206 | dd div.admonition { 207 | margin-left: -60px; 208 | padding-left: 60px; 209 | } 210 | 211 | div.admonition p.admonition-title { 212 | font-family: 'Garamond', 'Georgia', serif; 213 | font-weight: normal; 214 | font-size: 24px; 215 | margin: 0 0 10px 0; 216 | padding: 0; 217 | line-height: 1; 218 | } 219 | 220 | div.admonition p.last { 221 | margin-bottom: 0; 222 | } 223 | 224 | div.highlight { 225 | background-color: white; 226 | } 227 | 228 | dt:target, .highlight { 229 | background: #FAF3E8; 230 | } 231 | 232 | div.note { 233 | background-color: #eee; 234 | border: 1px solid #ccc; 235 | } 236 | 237 | div.seealso { 238 | background-color: #ffc; 239 | border: 1px solid #ff6; 240 | } 241 | 242 | div.topic { 243 | background-color: #eee; 244 | } 245 | 246 | p.admonition-title { 247 | display: inline; 248 | } 249 | 250 | p.admonition-title:after { 251 | content: ":"; 252 | } 253 | 254 | pre, tt { 255 | font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; 256 | font-size: 0.9em; 257 | } 258 | 259 | img.screenshot { 260 | } 261 | 262 | tt.descname, tt.descclassname { 263 | font-size: 0.95em; 264 | } 265 | 266 | tt.descname { 267 | padding-right: 0.08em; 268 | } 269 | 270 | img.screenshot { 271 | -moz-box-shadow: 2px 2px 4px #eee; 272 | -webkit-box-shadow: 2px 2px 4px #eee; 273 | box-shadow: 2px 2px 4px #eee; 274 | } 275 | 276 | table.docutils { 277 | border: 1px solid #888; 278 | -moz-box-shadow: 2px 2px 4px #eee; 279 | -webkit-box-shadow: 2px 2px 4px #eee; 280 | box-shadow: 2px 2px 4px #eee; 281 | } 282 | 283 | table.docutils td, table.docutils th { 284 | border: 1px solid #888; 285 | padding: 0.25em 0.7em; 286 | } 287 | 288 | table.field-list, table.footnote { 289 | border: none; 290 | -moz-box-shadow: none; 291 | -webkit-box-shadow: none; 292 | box-shadow: none; 293 | } 294 | 295 | table.footnote { 296 | margin: 15px 0; 297 | width: 100%; 298 | border: 1px solid #eee; 299 | background: #fdfdfd; 300 | font-size: 0.9em; 301 | } 302 | 303 | table.footnote + table.footnote { 304 | margin-top: -15px; 305 | border-top: none; 306 | } 307 | 308 | table.field-list th { 309 | padding: 0 0.8em 0 0; 310 | } 311 | 312 | table.field-list td { 313 | padding: 0; 314 | } 315 | 316 | table.footnote td.label { 317 | width: 0px; 318 | padding: 0.3em 0 0.3em 0.5em; 319 | } 320 | 321 | table.footnote td { 322 | padding: 0.3em 0.5em; 323 | } 324 | 325 | dl { 326 | margin: 0; 327 | padding: 0; 328 | } 329 | 330 | dl dd { 331 | margin-left: 30px; 332 | } 333 | 334 | blockquote { 335 | margin: 0 0 0 30px; 336 | padding: 0; 337 | } 338 | 339 | ul, ol { 340 | margin: 10px 0 10px 30px; 341 | padding: 0; 342 | } 343 | 344 | pre { 345 | background: #eee; 346 | padding: 7px 30px; 347 | margin: 15px -30px; 348 | line-height: 1.3em; 349 | } 350 | 351 | dl pre, blockquote pre, li pre { 352 | margin-left: -60px; 353 | padding-left: 60px; 354 | } 355 | 356 | dl dl pre { 357 | margin-left: -90px; 358 | padding-left: 90px; 359 | } 360 | 361 | tt { 362 | background-color: #ecf0f3; 363 | color: #222; 364 | /* padding: 1px 2px; */ 365 | } 366 | 367 | tt.xref, a tt { 368 | background-color: #FBFBFB; 369 | border-bottom: 1px solid white; 370 | } 371 | 372 | a.reference { 373 | text-decoration: none; 374 | border-bottom: 1px dotted #004B6B; 375 | } 376 | 377 | a.reference:hover { 378 | border-bottom: 1px solid #6D4100; 379 | } 380 | 381 | a.footnote-reference { 382 | text-decoration: none; 383 | font-size: 0.7em; 384 | vertical-align: top; 385 | border-bottom: 1px dotted #004B6B; 386 | } 387 | 388 | a.footnote-reference:hover { 389 | border-bottom: 1px solid #6D4100; 390 | } 391 | 392 | a:hover tt { 393 | background: #EEE; 394 | } 395 | 396 | 397 | @media screen and (max-width: 870px) { 398 | 399 | div.sphinxsidebar { 400 | display: none; 401 | } 402 | 403 | div.document { 404 | width: 100%; 405 | 406 | } 407 | 408 | div.documentwrapper { 409 | margin-left: 0; 410 | margin-top: 0; 411 | margin-right: 0; 412 | margin-bottom: 0; 413 | } 414 | 415 | div.bodywrapper { 416 | margin-top: 0; 417 | margin-right: 0; 418 | margin-bottom: 0; 419 | margin-left: 0; 420 | } 421 | 422 | ul { 423 | margin-left: 0; 424 | } 425 | 426 | .document { 427 | width: auto; 428 | } 429 | 430 | .footer { 431 | width: auto; 432 | } 433 | 434 | .bodywrapper { 435 | margin: 0; 436 | } 437 | 438 | .footer { 439 | width: auto; 440 | } 441 | 442 | .github { 443 | display: none; 444 | } 445 | 446 | 447 | 448 | } 449 | 450 | 451 | 452 | @media screen and (max-width: 875px) { 453 | 454 | body { 455 | margin: 0; 456 | padding: 20px 30px; 457 | } 458 | 459 | div.documentwrapper { 460 | float: none; 461 | background: white; 462 | } 463 | 464 | div.sphinxsidebar { 465 | display: block; 466 | float: none; 467 | width: 102.5%; 468 | margin: 50px -30px -20px -30px; 469 | padding: 10px 20px; 470 | background: #333; 471 | color: white; 472 | } 473 | 474 | div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p, 475 | div.sphinxsidebar h3 a { 476 | color: white; 477 | } 478 | 479 | div.sphinxsidebar a { 480 | color: #aaa; 481 | } 482 | 483 | div.sphinxsidebar p.logo { 484 | display: none; 485 | } 486 | 487 | div.document { 488 | width: 100%; 489 | margin: 0; 490 | } 491 | 492 | div.related { 493 | display: block; 494 | margin: 0; 495 | padding: 10px 0 20px 0; 496 | } 497 | 498 | div.related ul, 499 | div.related ul li { 500 | margin: 0; 501 | padding: 0; 502 | } 503 | 504 | div.footer { 505 | display: none; 506 | } 507 | 508 | div.bodywrapper { 509 | margin: 0; 510 | } 511 | 512 | div.body { 513 | min-height: 0; 514 | padding: 0; 515 | } 516 | 517 | .rtd_doc_footer { 518 | display: none; 519 | } 520 | 521 | .document { 522 | width: auto; 523 | } 524 | 525 | .footer { 526 | width: auto; 527 | } 528 | 529 | .footer { 530 | width: auto; 531 | } 532 | 533 | .github { 534 | display: none; 535 | } 536 | } 537 | 538 | 539 | /* scrollbars */ 540 | 541 | ::-webkit-scrollbar { 542 | width: 6px; 543 | height: 6px; 544 | } 545 | 546 | ::-webkit-scrollbar-button:start:decrement, 547 | ::-webkit-scrollbar-button:end:increment { 548 | display: block; 549 | height: 10px; 550 | } 551 | 552 | ::-webkit-scrollbar-button:vertical:increment { 553 | background-color: #fff; 554 | } 555 | 556 | ::-webkit-scrollbar-track-piece { 557 | background-color: #eee; 558 | -webkit-border-radius: 3px; 559 | } 560 | 561 | ::-webkit-scrollbar-thumb:vertical { 562 | height: 50px; 563 | background-color: #ccc; 564 | -webkit-border-radius: 3px; 565 | } 566 | 567 | ::-webkit-scrollbar-thumb:horizontal { 568 | width: 50px; 569 | background-color: #ccc; 570 | -webkit-border-radius: 3px; 571 | } 572 | 573 | /* misc. */ 574 | 575 | .revsys-inline { 576 | display: none!important; 577 | } -------------------------------------------------------------------------------- /sanic_httpauth.py: -------------------------------------------------------------------------------- 1 | """ 2 | sanic_httpauth 3 | ================== 4 | 5 | This module provides Basic and Digest HTTP authentication for Sanic routes. 6 | 7 | :copyright: (C) 2020 by Svitlana Kost. 8 | :copyright: (C) 2019 by Mihai Balint. 9 | :copyright: (C) 2014 by Miguel Grinberg. 10 | :license: MIT, see LICENSE for more details. 11 | """ 12 | import logging 13 | 14 | from functools import wraps 15 | from hashlib import md5 16 | from random import Random, SystemRandom 17 | from sanic.response import text 18 | 19 | from sanic_httpauth_compat import safe_str_cmp, Authorization 20 | from sanic_httpauth_compat import parse_authorization_header, get_request 21 | 22 | __version__ = "0.2.0" 23 | log = logging.getLogger(__name__) 24 | 25 | 26 | class HTTPAuth(object): 27 | def __init__(self, scheme=None, realm=None): 28 | self.scheme = scheme 29 | self.realm = realm or "Authentication Required" 30 | self.get_password_callback = None 31 | self.auth_error_callback = None 32 | 33 | def default_get_password(username): 34 | return None 35 | 36 | def default_auth_error(request): 37 | return text("Unauthorized Access", status=401) 38 | 39 | self.get_password(default_get_password) 40 | self.error_handler(default_auth_error) 41 | 42 | def get_password(self, f): 43 | self.get_password_callback = f 44 | return f 45 | 46 | def error_handler(self, f): 47 | @wraps(f) 48 | def decorated(*args, **kwargs): 49 | request = get_request(*args, **kwargs) 50 | res = f(*args, **kwargs) 51 | 52 | if res.status == 200: 53 | # if user didn't set status code, use 401 54 | res.status = 401 55 | if "WWW-Authenticate" not in res.headers.keys(): 56 | res.headers["WWW-Authenticate"] = self.authenticate_header( 57 | request) 58 | return res 59 | 60 | self.auth_error_callback = decorated 61 | return decorated 62 | 63 | def authenticate_header(self, request): 64 | return '{0} realm="{1}"'.format(self.scheme, self.realm) 65 | 66 | def get_auth(self, request): 67 | auth = parse_authorization_header(request.headers.get("Authorization")) 68 | try: 69 | if auth is None and "Authorization" in request.headers: 70 | auth_headers = request.headers["Authorization"] 71 | auth_type, value = auth_headers.split(None, 1) 72 | auth = Authorization(auth_type, {"token": value}) 73 | except ValueError: 74 | # The Authorization header is either empty or has no token 75 | pass 76 | 77 | # if the auth type does not match, we act as if there is no auth 78 | # this is better than failing directly, as it allows the callback 79 | # to handle special cases, like supporting multiple auth types 80 | if auth is not None and auth.type.lower() != self.scheme.lower(): 81 | auth = None 82 | 83 | return auth 84 | 85 | def get_auth_password(self, auth): 86 | password = None 87 | 88 | if auth and auth.username: 89 | password = self.get_password_callback(auth.username) 90 | 91 | return password 92 | 93 | def login_required(self, f): 94 | @wraps(f) 95 | def decorated(*args, **kwargs): 96 | # print(*args, **kwargs) 97 | request = get_request(*args, **kwargs) 98 | 99 | auth = self.get_auth(request) 100 | request.ctx.authorization = auth 101 | 102 | # Sanic-CORS normally handles OPTIONS requests on its own, 103 | # but in the case it is configured to forward those to the 104 | # application, we need to ignore authentication headers and let 105 | # the request through to avoid unwanted interactions with CORS. 106 | if request.method != "OPTIONS": # pragma: no cover 107 | password = self.get_auth_password(auth) 108 | 109 | if not self.authenticate(request, auth, password): 110 | return self.auth_error_callback(request) 111 | 112 | return f(*args, **kwargs) 113 | 114 | return decorated 115 | 116 | def username(self, request): 117 | if not request.ctx.authorization: 118 | return "" 119 | return request.ctx.authorization.username 120 | 121 | 122 | class HTTPBasicAuth(HTTPAuth): 123 | def __init__(self, scheme=None, realm=None): 124 | super(HTTPBasicAuth, self).__init__(scheme or "Basic", realm) 125 | 126 | self.hash_password_callback = None 127 | self.verify_password_callback = None 128 | 129 | def hash_password(self, f): 130 | self.hash_password_callback = f 131 | return f 132 | 133 | def verify_password(self, f): 134 | self.verify_password_callback = f 135 | return f 136 | 137 | def authenticate(self, request, auth, stored_password): 138 | if auth: 139 | username = auth.username 140 | client_password = auth.password 141 | else: 142 | username = "" 143 | client_password = "" 144 | if self.verify_password_callback: 145 | return self.verify_password_callback(username, client_password) 146 | if not auth: 147 | return False 148 | if self.hash_password_callback: 149 | try: 150 | client_password = self.hash_password_callback(client_password) 151 | except TypeError: 152 | client_password = self.hash_password_callback( 153 | username, client_password) 154 | return ( 155 | (client_password is not None and 156 | stored_password is not None and 157 | safe_str_cmp(client_password, stored_password)) 158 | ) 159 | 160 | 161 | class HTTPDigestAuth(HTTPAuth): 162 | def __init__(self, scheme=None, realm=None, use_ha1_pw=False, qop=None, 163 | use_session=True, use_opaque=True): 164 | super(HTTPDigestAuth, self).__init__(scheme or "Digest", realm) 165 | self.use_ha1_pw = use_ha1_pw 166 | self.use_session = use_session 167 | self.use_opaque = use_opaque 168 | self.qop = qop 169 | self.nonce = None 170 | self.opaque = None 171 | self.random = SystemRandom() 172 | try: 173 | self.random.random() 174 | except NotImplementedError: # pragma: no cover 175 | self.random = Random() 176 | 177 | self.generate_nonce_callback = None 178 | self.verify_nonce_callback = None 179 | self.generate_opaque_callback = None 180 | self.verify_opaque_callback = None 181 | 182 | def default_generate_nonce(request): 183 | self.nonce = self._generate_random() 184 | if use_session: 185 | request.ctx.session["auth_nonce"] = self.nonce 186 | return self.nonce 187 | 188 | def default_verify_nonce(request, nonce): 189 | if use_session: 190 | session_nonce = request.ctx.session.get("auth_nonce") 191 | else: 192 | session_nonce = self.nonce 193 | if nonce is None or session_nonce is None: 194 | return False 195 | return safe_str_cmp(nonce, session_nonce) 196 | 197 | def default_generate_opaque(request): 198 | self.opaque = self._generate_random() 199 | if use_session: 200 | request.ctx.session["auth_opaque"] = self.opaque 201 | return self.opaque 202 | 203 | def default_verify_opaque(request, opaque): 204 | if not self.use_opaque: 205 | return True 206 | if use_session: 207 | session_opaque = request.ctx.session.get("auth_opaque") 208 | else: 209 | session_opaque = self.opaque 210 | if opaque is None or session_opaque is None: 211 | return False 212 | return safe_str_cmp(opaque, session_opaque) 213 | 214 | self.generate_nonce(default_generate_nonce) 215 | self.generate_opaque(default_generate_opaque) 216 | self.verify_nonce(default_verify_nonce) 217 | self.verify_opaque(default_verify_opaque) 218 | 219 | def _generate_random(self): 220 | return md5(str(self.random.random()).encode("utf-8")).hexdigest() 221 | 222 | def generate_nonce(self, f): 223 | self.generate_nonce_callback = f 224 | return f 225 | 226 | def verify_nonce(self, f): 227 | self.verify_nonce_callback = f 228 | return f 229 | 230 | def generate_opaque(self, f): 231 | self.generate_opaque_callback = f 232 | return f 233 | 234 | def verify_opaque(self, f): 235 | self.verify_opaque_callback = f 236 | return f 237 | 238 | def get_nonce(self, request): 239 | if self.generate_nonce_callback: 240 | return self.generate_nonce_callback(request) 241 | 242 | def get_opaque(self, request): 243 | if self.generate_opaque_callback: 244 | return self.generate_opaque_callback(request) 245 | 246 | def generate_ha1(self, username, password): 247 | a1 = username + ":" + self.realm + ":" + password 248 | return md5(a1.encode("utf-8")).hexdigest() 249 | 250 | def authenticate_header(self, request): 251 | nonce = self.get_nonce(request) 252 | header = (f'{self.scheme} realm="{self.realm}", nonce="{nonce}", ' 253 | f'qop="{self.qop or ""}"') 254 | if self.use_opaque: 255 | opaque = self.get_opaque(request) 256 | header = ', '.join([header, f'opaque="{opaque}"']) 257 | return header 258 | 259 | def authenticate(self, request, auth, stored_password_or_ha1): 260 | if ( 261 | not auth or 262 | not auth.username or 263 | not auth.realm or 264 | not auth.uri or 265 | not auth.nonce or 266 | not auth.response or 267 | not stored_password_or_ha1 268 | ): 269 | return False 270 | if not (self.verify_nonce_callback(request, auth.nonce)) or not ( 271 | self.verify_opaque_callback(request, auth.opaque)): 272 | return False 273 | if self.use_ha1_pw: 274 | ha1 = stored_password_or_ha1 275 | else: 276 | a1 = ":".join([auth.username, auth.realm, stored_password_or_ha1]) 277 | ha1 = md5(a1.encode("utf-8")).hexdigest() 278 | if self.qop == "auth" or self.qop is None: 279 | a2 = ":".join([request.method, auth.uri]) 280 | ha2 = md5(a2.encode("utf-8")).hexdigest() 281 | elif self.qop == "auth-int": 282 | raise NotImplementedError( 283 | "Not Implemented digest auth with qop auth-int") 284 | if self.qop == "auth" or self.qop == "auth-int": 285 | a3 = ":".join([ha1, auth.nonce, auth.nc, auth.cnonce, 286 | self.qop, ha2]) 287 | response = md5(a3.encode("utf-8")).hexdigest() 288 | else: 289 | a3 = ":".join([ha1, auth.nonce, ha2]) 290 | response = md5(a3.encode("utf-8")).hexdigest() 291 | return safe_str_cmp(response, auth.response) 292 | 293 | 294 | class HTTPTokenAuth(HTTPAuth): 295 | def __init__(self, scheme="Bearer", realm=None): 296 | super(HTTPTokenAuth, self).__init__(scheme, realm) 297 | 298 | self.verify_token_callback = None 299 | 300 | def verify_token(self, f): 301 | self.verify_token_callback = f 302 | return f 303 | 304 | def authenticate(self, request, auth, stored_password): 305 | if auth: 306 | token = auth["token"] 307 | else: 308 | token = "" 309 | if self.verify_token_callback: 310 | return self.verify_token_callback(token) 311 | return False 312 | 313 | def token(self, request): 314 | if not request.ctx.authorization: 315 | return "" 316 | return request.ctx.authorization.get("token") 317 | 318 | 319 | class MultiAuth(object): 320 | def __init__(self, main_auth, *args): 321 | self.main_auth = main_auth 322 | self.additional_auth = args 323 | 324 | def login_required(self, f): 325 | @wraps(f) 326 | def decorated(*args, **kwargs): 327 | request = get_request(*args, **kwargs) 328 | selected_auth = None 329 | if "Authorization" in request.headers: 330 | try: 331 | auth_headers = request.headers["Authorization"] 332 | scheme, creds = auth_headers.split(None, 1) 333 | except ValueError: 334 | # malformed Authorization header 335 | pass 336 | else: 337 | for auth in self.additional_auth: 338 | if auth.scheme == scheme: 339 | selected_auth = auth 340 | break 341 | if selected_auth is None: 342 | selected_auth = self.main_auth 343 | 344 | return selected_auth.login_required(f)(*args, **kwargs) 345 | 346 | return decorated 347 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Flask-HTTPAuth change log 2 | 3 | **Release 0.2.0** - 2020-10-21 4 | - Library fixes to get it working with the latest version of Sanic (thanks **KostSvitlana**) 5 | 6 | **Release 0.1.2** - 2019-06-28 7 | - Fix @auth.login_required when applied on instance methods 8 | 9 | **Release 0.1.1** - 2019-06-27 10 | - Update README and examples 11 | 12 | **Release 0.1.0** - 2019-06-27 13 | - Replace Flask with Sanic 14 | 15 | **Release 3.3.0** - 2019-05-19 16 | 17 | - Use constant time string comparisons [#82](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/82) ([commit1](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/788d42ea9c4d536af628e0e7f4cb1fb84fc59a8e), [commit2](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/97f0e641a6d5eb34054de1ca255e932313d441ee)) (thanks **Brendan Long**!) 18 | - Edited and changed the usage of JWT, because in fact the code and documentation uses JWS tokens. [#79](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/79) ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/3f743c661e281d728bd2f98af8cca000a975bb8a)) (thanks **unuseless**!) 19 | - Documentation fix [#78](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/78) ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/c38c52326b78c91d4410f347abcd8bc49cc63ca4)) 20 | - Documentation improvements [#77](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/77) ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/ce5e5b4c9e8b748eba886ded5180e1e5d5036528)) 21 | - helper release script ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/7276d8db4b695645b01f3275addbec10418da63d)) 22 | 23 | **Release 3.2.4** - 2018-06-17 24 | 25 | - Refactored HTTPAuth login_required [#74](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/74) ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/68ee1e7a92355ba0f3f9b48c9489a67ab762e106)) (thanks **nestedsoftware**!) 26 | - remove incorrect references to JWT in example application [#69](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/69) ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/a310b78db2b947ab70f3fc35c1a586d822acc7ca)) 27 | - Fix typo in docs [#70](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/70) ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/b6457ae5648a50df75f3c40af4b4b3f0155fc25f)) (thanks **Grey Li**!) 28 | - Fix documentation [#67](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/67) ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/9bd8f4b4f3574c7ef3e2fb9596bc9e9981275011)) (thanks **Eugene Rymarev**!) 29 | - correct spelling mistake [#56](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/56) ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/f7c5bbd1b3a53080171bbdc5f1f1842f7a825f6a)) (thanks **Edward Betts**!) 30 | - travis build fix for py36 ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/6e7f32984bda8b82200793c1b3ec44ff3df3ad2b)) 31 | 32 | **Release 3.2.3** - 2017-06-05 33 | 34 | - Include docs and tests in pypi source tarball [#55](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/55) ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/054810ee351148b14571ba0a89ec17a543c35078)) (thanks **Chandan Kumar**!) 35 | 36 | **Release 3.2.2** - 2017-01-30 37 | 38 | - Validate authorization header in multi auth [#51](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/51) ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/7a895d676a1b6998f58b61a177286b62dc2872f5)) 39 | - index.rst: Add a missing variable in a code snippet [#49](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/49) ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/f7fe976bbdc699e8bafaed729dfdd74d2b27d7db)) (thanks **Baptiste Fontaine**!) 40 | 41 | **Release 3.2.1** - 2016-09-04 42 | 43 | - add `__version__` to package ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/d188450987f226568fe0cdee0b6d480b375af64a)) 44 | - Add readme and license files to the built package [#45](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/45) ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/1c35bec606f147bb23725d6ff3b0411f06828492)) 45 | 46 | **Release 3.2.0** - 2016-08-20 47 | 48 | - Fix TCP Connection reset by peer error [#39](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/39) ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/94f6c6d5a4866a43ff4f269eb351dce6232791a2)) (thanks **Joe Kemp**!) 49 | 50 | **Release 3.1.2** - 2016-04-21 51 | 52 | - Add robustness to password check ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/051fd88ee36a21a13255b4ec69e172c9ae4ad46d)) 53 | 54 | **Release 3.1.1** - 2016-03-24 55 | 56 | - pass params to view function in MultiAuth [#36](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/36) ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/319974602e55529006b9a8a4fde04ef08e042e83)) (thanks **vovanz**!) 57 | - add examples to flake8 build ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/61b1b71b3b29f2936ac6a2077883da1faeaad09f)) 58 | - Added multi auth tests ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/c443e7ebcc227fd3690c2cf943d414087d7b931d)) 59 | - removed dead code ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/4d2232e2a77f5e10e1731936f4ac64439049b220)) 60 | 61 | **Release 3.1.0** - 2016-03-13 62 | 63 | - examples ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/609806a1c10264818e08ba0ce9b7babeaf101656)) 64 | - Added support for multiple authentication methods ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/6c3f94d9eda85b78a8c36cd5e05d6d9836bee2d0)) 65 | - Added change log ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/8b427b962114a6ef13badaf8f2f1b396c540955a)) 66 | - Add additional token auth test ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/29edb1948f086babbd1a9e0c87a0a35c05f0a63b)) 67 | 68 | **Release 3.0.2** - 2016-03-12 69 | 70 | - Let callback decide what to do when authentication type does not match ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/b942f980970d2e387a80f68de4ea2bb8728b149c)) 71 | 72 | **Release 3.0.1** - 2016-03-09 73 | 74 | - Catching exception when Authorization header is empty ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/88d073e05b56b810feb447d1c9cee7a9a9ac9b1b)) (thanks **Kari Hreinsson**!) 75 | - Documentation fix, validate_token() -> verify_token() ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/f4b41d736311638978c95c9b5fd458063a009280)) (thanks **Kari Hreinsson**!) 76 | 77 | **Release 3.0.0** - 2016-03-07 78 | 79 | - documentation for new token auth ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/c0ae42df517a45be87f419cbb7f8002228a1e83c)) 80 | - switch travis build to use tox ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/00fdebce667e1dbbc5b342a21804cb6ab3b4f417)) 81 | - token auth support, plus test reorg ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/aac866de14c68a4d17d3098f8e96102e837add1d)) 82 | - Added explicity Python 2 & 3 version classifiers to package ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/a6f50e7be6f13bb814c47fe8a3a44cd34138f87e)) 83 | 84 | **Release 2.7.1** - 2016-02-07 85 | 86 | - Remove session dependency in authenticate_header [#31](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/31) ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/8a84c52d2166e7fdfa26b89dfd2df3340787de94)) (thanks **Paweł Stiasny**!) 87 | - Add Install Notes ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/0ff88331c9724999d8f283d79fe95de949e64438)) (thanks **Michael Washburn Jr**!) 88 | - Add syntax highlighting to the README [#28](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/28) ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/5c058b5165cdbc6a869d68410ef2d25e7802d602)) (thanks **Josh Friend**!) 89 | 90 | **Release 2.7.0** - 2015-09-20 91 | 92 | - Support custom authentication scheme and realm ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/bf12f959bba24a2f3d7d799d1b57ef3a5f1001e8)) 93 | 94 | **Release 2.6.0** - 2015-08-23 95 | 96 | - Added information on how to implement digest authentication securely ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/fb02625ca0f7694d8e744e0b3d2c8d4ffcc4d7cd)) 97 | - Allow for custom nonce/opaque generation [#24](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/24) ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/ddaa3b6461705d107655c7f87f90d7ba962d2a84)) (thanks **Matt Haggard**!) 98 | - fixed tests to work with python 2.6 ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/5e85b27a06285fb5bd591f9f65a8a0bebc4a34f2)) 99 | - added travis ci badge ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/ef354fd07abd08137beba6362debdcb4ef23baf6)) 100 | 101 | **release 2.5.0** - 2015-04-26 102 | 103 | - documentation changes ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/5c98ed8370355a60e22e017a79d5575adadb9c07)) 104 | - documentation for stored ha1 feature ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/37fd9288abb4f11abf9f93303d1bce4e6cfc3c19)) 105 | - Include notes for nginx ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/ed8b4a3c954240cde0c66af3d6dae37df48ba976)) (thanks **Erik Stephens**!) 106 | - Include notes for nginx as well ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/5bccbae862cbf1ca7d02f717b076aca86b1456e5)) (thanks **Erik Stephens**!) 107 | - Update docs with WSGI notes ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/9ddd55f0bcb793a49675274dc22ae15122a8a1ff)) (thanks **Erik Stephens**!) 108 | - Update README with WSGI notes ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/af5fa26dc73d401de7760ba3dcd61828c2e548dd)) (thanks **Erik Stephens**!) 109 | - Modified documents and readme for correct import statement [#19](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/19) ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/b75737593f3d97b18620440e7e41ee9b71b23f11)) (thanks **Aayush Kasurde**!) 110 | 111 | **release 2.4.0** - 2015-03-02 112 | 113 | - Support anonymous users in verify_password callback ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/5c5396bbb7af540a7aff786ce3282657566045f2)) 114 | - Add HA1 generation function to HTTPDigestAuth class ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/4f4aed3ed3fa5e96a1a052e4414f14d1fc49b8bb)) (thanks **Pawel Szczurko**!) 115 | - Fix unit test url routes ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/a490a521a17313ce82bfe886912b1620166eb6dd)) (thanks **Pawel Szczurko**!) 116 | - Add option to use ha1 combination as password instead of plain text password ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/c84429f541ed0069f40fb901dcb3df44b801c9a5)) (thanks **Pawel Szczurko**!) 117 | - removed extra strip() calls in unit tests ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/fc34cc5020168ca3824cc4a740b2010bb3132abf)) 118 | 119 | **release 2.3.0** - 2014-09-23 120 | 121 | - pep8 ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/4657d5b37e50483ecccabf0887ea417d3b94ea0a)) 122 | - Fixed problem with couple of decorator that destroy function they decorate [#11](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/11) ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/0adf45bec7e5fb04a0e14e13396fd867879026b4)) (thanks **Nemanja Trifunovic**!) 123 | - Ignore authentication headers for OPTIONS ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/044b7d4a44425a4b9d02280b80988e8986641a0d)) (thanks **Henrique Carvalho Alves**!) 124 | 125 | **release 2.2.1** - 2014-03-17 126 | 127 | - [#5](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/5): correct handling of None return from get_password callback ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/b94dc8e5fb6c914fdf971085b329bf9ad848a8f5)) 128 | - [#5](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/5) ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/051195d68d8aaf6d9e53d14d69a59afd84f24821)) 129 | - Fixed problem when get_password decorator destroys function it decorates [#4](https://github.com/miguelgrinberg/Flask-HTTPAuth/issues/4) ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/0cbee173e96f8e1a533e7d82b5b1fa1bfce3cd04)) (thanks **Nemanja Trifunovic**!) 130 | - custom password verification callback ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/33d60f21a6e64f1b2df24ea5035164110979d8ab)) 131 | 132 | **version 2.1.0** - 2013-09-28 133 | 134 | - pass the username to the hash password callback ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/13075ec4dbe4cb733f4f433e1e25e8a180fce1f6)) 135 | 136 | **Release 2.0.0** - 2013-09-26 137 | 138 | - changed auth.username to auth.username() ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/5168a5f703552ec092e3fef9e087052e35fb6ff0)) 139 | - 2.0 documentation update ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/e668f59cb674e45891b7d9548e5af3028f2fd22d)) 140 | 141 | **Release 1.1.0** - 2013-08-30 142 | 143 | - python 3 support ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/c13ff0a4c1e5922a635ea7c877a2ef6079ddb4e6)) 144 | - documentation update ([commit](https://github.com/miguelgrinberg/Flask-HTTPAuth/commit/c468e1c084e5c25dcaa85b45e5abeb88fbc09420)) 145 | 146 | **Release 1.0.0** - 2013-07-27 147 | 148 | - First official release! 149 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Flask-HTTPAuth documentation master file, created by 2 | sphinx-quickstart on Fri Jul 26 14:48:13 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Flask-HTTPAuth's documentation! 7 | ========================================== 8 | 9 | **Flask-HTTPAuth** is a simple extension that simplifies the use of HTTP authentication with Flask routes. 10 | 11 | Basic authentication examples 12 | ----------------------------- 13 | 14 | The following example application uses HTTP Basic authentication to protect route ``'/'``:: 15 | 16 | from flask import Flask 17 | from flask_httpauth import HTTPBasicAuth 18 | from werkzeug.security import generate_password_hash, check_password_hash 19 | 20 | app = Flask(__name__) 21 | auth = HTTPBasicAuth() 22 | 23 | users = { 24 | "john": generate_password_hash("hello"), 25 | "susan": generate_password_hash("bye") 26 | } 27 | 28 | @auth.verify_password 29 | def verify_password(username, password): 30 | if username in users: 31 | return check_password_hash(users.get(username), password) 32 | return False 33 | 34 | @app.route('/') 35 | @auth.login_required 36 | def index(): 37 | return "Hello, %s!" % auth.username() 38 | 39 | if __name__ == '__main__': 40 | app.run() 41 | 42 | The example above uses the `verify_password` callback, which provides the most degree of flexibility, but there are a few alternatives to it. 43 | 44 | The ``get_password`` callback needs to return the password associated with the username given as argument. Flask-HTTPAuth will allow access only if ``get_password(username) == password``. Example:: 45 | 46 | @auth.get_password 47 | def get_password(username): 48 | return get_password_for_username(username) 49 | 50 | Using this callback alone is in general not a good idea because it requires passwords to be available in plaintext in the server. In the more likely scenario that the passwords are stored hashed in a user database, then an additional callback is needed to define how to hash a password:: 51 | 52 | @auth.hash_password 53 | def hash_pw(password): 54 | return hash_password(password) 55 | 56 | In this example, you have to replace ``hash_password()`` with the specific hashing function used in your application. When the ``hash_password`` callback is provided, access will be granted when ``get_password(username) == hash_password(password)``. 57 | 58 | If the hashing algorithm requires the username to be known then the callback can take two arguments instead of one:: 59 | 60 | @auth.hash_password 61 | def hash_pw(username, password): 62 | salt = get_salt(username) 63 | return hash_password(password, salt) 64 | 65 | Digest authentication example 66 | ----------------------------- 67 | 68 | The following example is similar to the previous one, but HTTP Digest authentication is used:: 69 | 70 | from flask import Flask 71 | from flask_httpauth import HTTPDigestAuth 72 | 73 | app = Flask(__name__) 74 | app.config['SECRET_KEY'] = 'secret key here' 75 | auth = HTTPDigestAuth() 76 | 77 | users = { 78 | "john": "hello", 79 | "susan": "bye" 80 | } 81 | 82 | @auth.get_password 83 | def get_pw(username): 84 | if username in users: 85 | return users.get(username) 86 | return None 87 | 88 | @app.route('/') 89 | @auth.login_required 90 | def index(): 91 | return "Hello, %s!" % auth.username() 92 | 93 | if __name__ == '__main__': 94 | app.run() 95 | 96 | Security Concerns with Digest Authentication 97 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 98 | 99 | The digest authentication algorithm requires a *challenge* to be sent to the client for use in encrypting the password for transmission. This challenge needs to be used again when the password is decoded at the server, so the challenge information needs to be stored so that it can be recalled later. 100 | 101 | By default, Flask-HTTPAuth stores the challenge data in the Flask session. To make the authentication flow secure when using session storage, it is required that server-side sessions are used instead of the default Flask cookie based sessions, as this ensures that the challenge data is not at risk of being captured as it moves in a cookie between server and client. The Flask-Session and Flask-KVSession extensions are both very good options to implement server-side sessions. 102 | 103 | As an alternative to using server-side sessions, an application can implement its own generation and storage of challenge data. To do this, there are four callback functions that the application needs to implement:: 104 | 105 | @auth.generate_nonce 106 | def generate_nonce(): 107 | """Return the nonce value to use for this client.""" 108 | pass 109 | 110 | @auth.generate_opaque 111 | def generate_opaque(): 112 | """Return the opaque value to use for this client.""" 113 | pass 114 | 115 | @auth.verify_nonce 116 | def verify_nonce(nonce): 117 | """Verify that the nonce value sent by the client is correct.""" 118 | pass 119 | 120 | @auth.verify_opaque 121 | def verify_opaque(opaque): 122 | """Verify that the opaque value sent by the client is correct.""" 123 | pass 124 | 125 | For information of what the ``nonce`` and ``opaque`` values are and how they are used in digest authentication, consult `RFC 2617 `_. 126 | 127 | Token Authentication Scheme Example 128 | ----------------------------------- 129 | 130 | The following example application uses a custom HTTP authentication scheme to protect route ``'/'`` with a token:: 131 | 132 | from flask import Flask, g 133 | from flask_httpauth import HTTPTokenAuth 134 | 135 | app = Flask(__name__) 136 | auth = HTTPTokenAuth(scheme='Token') 137 | 138 | tokens = { 139 | "secret-token-1": "john", 140 | "secret-token-2": "susan" 141 | } 142 | 143 | @auth.verify_token 144 | def verify_token(token): 145 | if token in tokens: 146 | g.current_user = tokens[token] 147 | return True 148 | return False 149 | 150 | @app.route('/') 151 | @auth.login_required 152 | def index(): 153 | return "Hello, %s!" % g.current_user 154 | 155 | if __name__ == '__main__': 156 | app.run() 157 | 158 | The ``HTTPTokenAuth`` is a generic authentication handler that can be used with non-standard authentication schemes, with the scheme name given as an argument in the constructor. In the above example, the ``WWW-Authenticate`` header provided by the server will use ``Token`` as scheme:: 159 | 160 | WWW-Authenticate: Token realm="Authentication Required" 161 | 162 | The ``verify_token`` callback receives the authentication credentials provided by the client on the ``Authorization`` header. This can be a simple token, or can contain multiple arguments, which the function will have to parse and extract from the string. 163 | 164 | In the examples directory you can find a complete example that uses 165 | JWS tokens. JWS tokens are similar to JWT tokens. However using JWT 166 | tokens would require an external dependency to handle JWT. 167 | 168 | Using Multiple Authentication Schemes 169 | ------------------------------------- 170 | 171 | Applications sometimes need to support a combination of authentication 172 | methods. For example, a web application could be authenticated by 173 | sending client id and secret over basic authentication, while third 174 | party API clients use a JWS or JWT bearer token. The `MultiAuth` class allows you to protect a route with more than one authentication object. To grant access to the endpoint, one of the authentication methods must validate. 175 | 176 | In the examples directory you can find a complete example that uses basic and token authentication. 177 | 178 | Deployment Considerations 179 | ------------------------- 180 | 181 | Be aware that some web servers do not pass the ``Authorization`` headers to the WSGI application by default. For example, if you use Apache with mod_wsgi, you have to set option ``WSGIPassAuthorization On`` as `documented here `_. 182 | 183 | API Documentation 184 | ----------------- 185 | 186 | .. module:: flask_httpauth 187 | 188 | .. class:: HTTPBasicAuth 189 | 190 | This class handles HTTP Basic authentication for Flask routes. 191 | 192 | .. method:: __init__(scheme=None, realm=None) 193 | 194 | Create a basic authentication object. 195 | 196 | If the optional ``scheme`` argument is provided, it will be used instead of the standard "Basic" scheme in the ``WWW-Authenticate`` response. A fairly common practice is to use a custom scheme to prevent browsers from prompting the user to login. 197 | 198 | The ``realm`` argument can be used to provide an application defined realm with the ``WWW-Authenticate`` header. 199 | 200 | .. method:: get_password(password_callback) 201 | 202 | This callback function will be called by the framework to obtain the password for a given user. Example:: 203 | 204 | @auth.get_password 205 | def get_password(username): 206 | return db.get_user_password(username) 207 | 208 | .. method:: hash_password(hash_password_callback) 209 | 210 | If defined, this callback function will be called by the framework to apply a custom hashing algorithm to the password provided by the client. If this callback isn't provided the password will be checked unchanged. The callback can take one or two arguments. The one argument version receives the password to hash, while the two argument version receives the username and the password in that order. Example single argument callback:: 211 | 212 | @auth.hash_password 213 | def hash_password(password): 214 | return md5(password).hexdigest() 215 | 216 | Example two argument callback:: 217 | 218 | @auth.hash_password 219 | def hash_pw(username, password): 220 | salt = get_salt(username) 221 | return hash(password, salt) 222 | 223 | .. method:: verify_password(verify_password_callback) 224 | 225 | If defined, this callback function will be called by the framework to verify that the username and password combination provided by the client are valid. The callback function takes two arguments, the username and the password and must return ``True`` or ``False``. Example usage:: 226 | 227 | @auth.verify_password 228 | def verify_password(username, password): 229 | user = User.query.filter_by(username).first() 230 | if not user: 231 | return False 232 | return passlib.hash.sha256_crypt.verify(password, user.password_hash) 233 | 234 | If this callback is defined, it is also invoked when the request does not have the ``Authorization`` header with user credentials, and in this case both the ``username`` and ``password`` arguments are set to empty strings. The client can opt to return ``True`` and that will allow anonymous users access to the route. The callback function can indicate that the user is anonymous by writing a state variable to ``flask.g``, which the route can then check to generate an appropriate response. 235 | 236 | Note that when a ``verify_password`` callback is provided the ``get_password`` and ``hash_password`` callbacks are not used. 237 | 238 | .. method:: error_handler(error_callback) 239 | 240 | If defined, this callback function will be called by the framework when it is necessary to send an authentication error back to the client. The return value from this function can be the body of the response as a string or it can also be a response object created with ``make_response``. If this callback isn't provided a default error response is generated. Example:: 241 | 242 | @auth.error_handler 243 | def auth_error(): 244 | return "<h1>Access Denied</h1>" 245 | 246 | .. method:: login_required(view_function_callback) 247 | 248 | This callback function will be called when authentication is successful. This will typically be a Flask view function. Example:: 249 | 250 | @app.route('/private') 251 | @auth.login_required 252 | def private_page(): 253 | return "Only for authorized people!" 254 | 255 | .. method:: username() 256 | 257 | A view function that is protected with this class can access the logged username through this method. Example:: 258 | 259 | @app.route('/') 260 | @auth.login_required 261 | def index(): 262 | return "Hello, %s!" % auth.username() 263 | 264 | .. class:: flask_httpauth.HTTPDigestAuth 265 | 266 | This class handles HTTP Digest authentication for Flask routes. The ``SECRET_KEY`` configuration must be set in the Flask application to enable the session to work. Flask by default stores user sessions in the client as secure cookies, so the client must be able to handle cookies. To support clients that are not web browsers or that cannot handle cookies a `session interface `_ that writes sessions in the server must be used. 267 | 268 | .. method:: __init__(self, scheme=None, realm=None, use_ha1_pw=False) 269 | 270 | Create a digest authentication object. 271 | 272 | If the optional ``scheme`` argument is provided, it will be used instead of the "Digest" scheme in the ``WWW-Authenticate`` response. A fairly common practice is to use a custom scheme to prevent browsers from prompting the user to login. 273 | 274 | The ``realm`` argument can be used to provide an application defined realm with the ``WWW-Authenticate`` header. 275 | 276 | If ``use_ha1_pw`` is False, then the ``get_password`` callback needs to return the plain text password for the given user. If ``use_ha1_pw`` is True, the ``get_password`` callback needs to return the HA1 value for the given user. The advantage of setting ``use_ha1_pw`` to ``True`` is that it allows the application to store the HA1 hash of the password in the user database. 277 | 278 | .. method:: generate_ha1(username, password) 279 | 280 | Generate the HA1 hash that can be stored in the user database when ``use_ha1_pw`` is set to True in the constructor. 281 | 282 | .. method:: generate_nonce(nonce_making_callback) 283 | 284 | If defined, this callback function will be called by the framework to 285 | generate a nonce. If this is defined, ``verify_nonce`` should 286 | also be defined. 287 | 288 | This can be used to use a state storage mechanism other than the session. 289 | 290 | .. method:: verify_nonce(nonce_verify_callback) 291 | 292 | If defined, this callback function will be called by the framework to 293 | verify that a nonce is valid. It will be called with a single argument: 294 | the nonce to be verified. 295 | 296 | This can be used to use a state storage mechanism other than the session. 297 | 298 | .. method:: generate_opaque(opaque_making_callback) 299 | 300 | If defined, this callback function will be called by the framework to 301 | generate an opaque value. If this is defined, ``verify_opaque`` should 302 | also be defined. 303 | 304 | This can be used to use a state storage mechanism other than the session. 305 | 306 | .. method:: verify_opaque(opaque_verify_callback) 307 | 308 | If defined, this callback function will be called by the framework to 309 | verify that an opaque value is valid. It will be called with a single 310 | argument: the opaque value to be verified. 311 | 312 | This can be used to use a state storage mechanism other than the session. 313 | 314 | .. method:: get_password(password_callback) 315 | 316 | See basic authentication for documentation and examples. 317 | 318 | .. method:: error_handler(error_callback) 319 | 320 | See basic authentication for documentation and examples. 321 | 322 | .. method:: login_required(view_function_callback) 323 | 324 | See basic authentication for documentation and examples. 325 | 326 | .. method:: username() 327 | 328 | See basic authentication for documentation and examples. 329 | 330 | .. class:: HTTPTokenAuth 331 | 332 | This class handles HTTP authentication with custom schemes for Flask routes. 333 | 334 | .. method:: __init__(scheme='Bearer', realm=None) 335 | 336 | Create a token authentication object. 337 | 338 | The ``scheme`` argument can be use to specify the scheme to be used in the ``WWW-Authenticate`` response. 339 | 340 | The ``realm`` argument can be used to provide an application defined realm with the ``WWW-Authenticate`` header. 341 | 342 | .. method:: verify_token(verify_token_callback) 343 | 344 | This callback function will be called by the framework to verify that the credentials sent by the client with the ``Authorization`` header are valid. The callback function takes one argument, the username and the password and must return ``True`` or ``False``. Example usage:: 345 | 346 | @auth.verify_token 347 | def verify_token(token): 348 | g.current_user = User.query.filter_by(token=token).first() 349 | return g.current_user is not None 350 | 351 | Note that a ``verify_token`` callback is required when using this class. 352 | 353 | .. method:: error_handler(error_callback) 354 | 355 | See basic authentication for documentation and examples. 356 | 357 | .. method:: login_required(view_function_callback) 358 | 359 | See basic authentication for documentation and examples. 360 | --------------------------------------------------------------------------------