Compare commits
	
		
			58 Commits
		
	
	
		
			v0.1.0
			...
			a88fdf81fa
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| a88fdf81fa | |||
| 4993bfdd9b | |||
| 696f350a44 | |||
| 409f794835 | |||
| 92eb011470 | |||
| b5ecd1552b | |||
| 765822a300 | |||
| e9a6449505 | |||
| df15f0b0d5 | |||
| 6c7f7e8c8e | |||
| dfc1108b41 | |||
| 4fe57532b0 | |||
| d104b0f28d | |||
| 30106c7e9f | |||
| 9997985d8c | |||
| 6057fc0987 | |||
| a6dc530ac7 | |||
| 6a14c04aaa | |||
| f9e10ecb2f | |||
| b6bfb2eae9 | |||
| e6b4594393 | |||
| 495e9a9785 | |||
| cbbd2248f0 | |||
| 2028cb1362 | |||
| 7e71115844 | |||
| 491da61a79 | |||
| bbaebbc80d | |||
| 0dfdf70c45 | |||
| 0432561b21 | |||
| 3709cb4d66 | |||
| 9d0d0b2686 | |||
| 8c98d35934 | |||
| 7db38c7eae | |||
| 9616fb3ddc | |||
| f473db29a8 | |||
| b39e9b1321 | |||
| f3b525d715 | |||
| 0f3694ba05 | |||
| 2425d99492 | |||
| be163d35fb | |||
| 2aaaa9f47f | |||
| cb3e313e21 | |||
| 6f49a180e3 | |||
| af8c3a484c | |||
| 65c3322ecc | |||
| cb5cfaf7d4 | |||
| dda8472a76 | |||
| 177f549786 | |||
| ff8ada129d | |||
| ccec1365bf | |||
| 78514a8f17 | |||
| 3dcc409bef | |||
| 2156aa710f | |||
| a43c6aea89 | |||
| 8e29c91f92 | |||
| 8ec1e6fd02 | |||
| b37861eccc | |||
| f2e51b46cb | 
							
								
								
									
										9
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -23,10 +23,13 @@ dist | |||||||
| .pytest_cache | .pytest_cache | ||||||
| venv | venv | ||||||
|  |  | ||||||
| flask_session |  | ||||||
| instance |  | ||||||
|  |  | ||||||
| .DS_Store | .DS_Store | ||||||
| .idea | .idea | ||||||
|  |  | ||||||
|  | instance | ||||||
|  | flask_session | ||||||
|  |  | ||||||
|  | .scannerwork | ||||||
|  | sonar-project.properties | ||||||
|  |  | ||||||
| excludes | excludes | ||||||
|   | |||||||
| @@ -15,4 +15,8 @@ | |||||||
| #  See the License for the specific language governing permissions and | #  See the License for the specific language governing permissions and | ||||||
| #  limitations under the License. | #  limitations under the License. | ||||||
|  |  | ||||||
|  | include docs/* | ||||||
|  | include docs/source/* | ||||||
|  | include docs/source/_static/* | ||||||
|  | include docs/source/_templates/* | ||||||
| include tests/* | include tests/* | ||||||
|   | |||||||
							
								
								
									
										250
									
								
								README.rst
									
									
									
									
									
								
							
							
						
						
									
										250
									
								
								README.rst
									
									
									
									
									
								
							| @@ -6,35 +6,68 @@ Flask HTTP Digest Authentication | |||||||
| Description | Description | ||||||
| =========== | =========== | ||||||
|  |  | ||||||
| *Flask-Digest-Auth* is an HTTP Digest Authentication implementation | *Flask-Digest-Auth* is an `HTTP Digest Authentication`_ implementation | ||||||
| for Flask_ applications.  It authenticates the user for the protected | for Flask_ applications.  It authenticates the user for the protected | ||||||
| views.  It works with Flask-Login_, so that log in protection can be | views. | ||||||
| separated with the authentication mechanism.  You can write Flask |  | ||||||
| modules that work with different authentication mechanisms. |  | ||||||
|  |  | ||||||
| .. _Flask: https://flask.palletsprojects.com | HTTP Digest Authentication is specified in `RFC 2617`_. | ||||||
| .. _Flask-Login: https://flask-login.readthedocs.io |  | ||||||
|  | Refer to the full `Flask-Digest-Auth readthedocs documentation`_. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Why HTTP Digest Authentication? | ||||||
|  | ------------------------------- | ||||||
|  |  | ||||||
|  | *HTTP Digest Authentication* has the advantage that it does not send | ||||||
|  | thee actual password to the server, which greatly enhances the | ||||||
|  | security.  It uses the challenge-response authentication scheme.  The | ||||||
|  | client returns the response calculated from the challenge and the | ||||||
|  | password, but not the original password. | ||||||
|  |  | ||||||
|  | Log in forms has the advantage of freedom, in the senses of both the | ||||||
|  | visual design and the actual implementation.  You may implement your | ||||||
|  | own challenge-response log in form, but then you are reinventing the | ||||||
|  | wheels.  If a pretty log in form is not critical to your project, HTTP | ||||||
|  | Digest Authentication should be a good choice. | ||||||
|  |  | ||||||
|  | Flask-Digest-Auth works with Flask-Login_.  Log in protection can be | ||||||
|  | separated with the authentication mechanism.  You can create protected | ||||||
|  | Flask modules without knowing the actual authentication mechanisms. | ||||||
|  |  | ||||||
|  |  | ||||||
| Installation | Installation | ||||||
| ============ | ============ | ||||||
|  |  | ||||||
| It's suggested that you install with ``pip``: | You can install Flask-Digest-Auth with ``pip``: | ||||||
|  |  | ||||||
| :: | :: | ||||||
|  |  | ||||||
|     pip install flask-digest-auth |     pip install Flask-Digest-Auth | ||||||
|  |  | ||||||
| You may also install from the latest source from the | You may also install the latest source from the | ||||||
| `flask-digest-auth Github repository`_. | `Flask-Digest-Auth GitHub repository`_. | ||||||
|  |  | ||||||
| :: | :: | ||||||
|  |  | ||||||
|     git clone git@github.com:imacat/flask-digest-auth.git |     pip install git+https://github.com/imacat/flask-digest-auth.git | ||||||
|     cd flask-digest-auth |  | ||||||
|     pip install . |  | ||||||
|  |  | ||||||
| .. _flask-digest-auth Github repository: https://github.com/imacat/flask-digest-auth |  | ||||||
|  | Setting the Password | ||||||
|  | ==================== | ||||||
|  |  | ||||||
|  | The password hash of the HTTP Digest Authentication is composed of the | ||||||
|  | realm, the username, and the password.  Example for setting the | ||||||
|  | password: | ||||||
|  |  | ||||||
|  | :: | ||||||
|  |  | ||||||
|  |     from flask_digest_auth import make_password_hash | ||||||
|  |  | ||||||
|  |     user.password = make_password_hash(realm, username, password) | ||||||
|  |  | ||||||
|  | The username is part of the hash.  If the user changes their username, | ||||||
|  | you need to ask their password, to generate and store the new password | ||||||
|  | hash. | ||||||
|  |  | ||||||
|  |  | ||||||
| Flask-Digest-Auth Alone | Flask-Digest-Auth Alone | ||||||
| @@ -43,20 +76,21 @@ Flask-Digest-Auth Alone | |||||||
| Flask-Digest-Auth can authenticate the users alone. | Flask-Digest-Auth can authenticate the users alone. | ||||||
|  |  | ||||||
|  |  | ||||||
| Example for Simple Applications with Flask-Digest-Auth Alone | Simple Applications with Flask-Digest-Auth Alone | ||||||
| ------------------------------------------------------------ | ------------------------------------------------ | ||||||
|  |  | ||||||
| In your ``my_app.py``: | In your ``my_app.py``: | ||||||
|  |  | ||||||
| :: | :: | ||||||
|  |  | ||||||
|     from flask import Flask |     from flask import Flask, request, redirect | ||||||
|     from flask_digest_auth import DigestAuth |     from flask_digest_auth import DigestAuth | ||||||
|  |  | ||||||
|     app: flask = Flask(__name__) |     app: flask = Flask(__name__) | ||||||
|     ... (Configure the Flask application) ... |     ... (Configure the Flask application) ... | ||||||
|  |  | ||||||
|     auth: DigestAuth = DigestAuth(realm="Admin") |     auth: DigestAuth = DigestAuth(realm="Admin") | ||||||
|  |     auth.init_app(app) | ||||||
|  |  | ||||||
|     @auth.register_get_password |     @auth.register_get_password | ||||||
|     def get_password_hash(username: str) -> t.Optional[str]: |     def get_password_hash(username: str) -> t.Optional[str]: | ||||||
| @@ -69,15 +103,21 @@ In your ``my_app.py``: | |||||||
|     @app.get("/admin") |     @app.get("/admin") | ||||||
|     @auth.login_required |     @auth.login_required | ||||||
|     def admin(): |     def admin(): | ||||||
|         ... (Process the view) ... |         return f"Hello, {g.user.username}!" | ||||||
|  |  | ||||||
|  |     @app.post("/logout") | ||||||
|  |     @auth.login_required | ||||||
|  |     def logout(): | ||||||
|  |         auth.logout() | ||||||
|  |         return redirect(request.form.get("next")) | ||||||
|  |  | ||||||
|  |  | ||||||
| Example for Larger Applications with ``create_app()`` with Flask-Digest-Auth Alone | Larger Applications with ``create_app()`` with Flask-Digest-Auth Alone | ||||||
| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||||||
|  |  | ||||||
| In your ``my_app/__init__.py``: | In your ``my_app/__init__.py``: | ||||||
|  |  | ||||||
| ::: | :: | ||||||
|  |  | ||||||
|     from flask import Flask |     from flask import Flask | ||||||
|     from flask_digest_auth import DigestAuth |     from flask_digest_auth import DigestAuth | ||||||
| @@ -89,6 +129,7 @@ In your ``my_app/__init__.py``: | |||||||
|         ... (Configure the Flask application) ... |         ... (Configure the Flask application) ... | ||||||
|  |  | ||||||
|         auth.realm = app.config["REALM"] |         auth.realm = app.config["REALM"] | ||||||
|  |         auth.init_app(app) | ||||||
|  |  | ||||||
|         @auth.register_get_password |         @auth.register_get_password | ||||||
|         def get_password_hash(username: str) -> t.Optional[str]: |         def get_password_hash(username: str) -> t.Optional[str]: | ||||||
| @@ -105,43 +146,61 @@ In your ``my_app/views.py``: | |||||||
| :: | :: | ||||||
|  |  | ||||||
|     from my_app import auth |     from my_app import auth | ||||||
|     from flask import Flask, Blueprint |     from flask import Flask, Blueprint, request, redirect | ||||||
|  |  | ||||||
|     bp = Blueprint("admin", __name__, url_prefix="/admin") |     bp = Blueprint("admin", __name__, url_prefix="/admin") | ||||||
|  |  | ||||||
|     @bp.get("/") |     @bp.get("/admin") | ||||||
|     @auth.login_required |     @auth.login_required | ||||||
|     def admin(): |     def admin(): | ||||||
|         ... (Process the view) ... |         return f"Hello, {g.user.username}!" | ||||||
|  |  | ||||||
|  |     @app.post("/logout") | ||||||
|  |     @auth.login_required | ||||||
|  |     def logout(): | ||||||
|  |         auth.logout() | ||||||
|  |         return redirect(request.form.get("next")) | ||||||
|  |  | ||||||
|     def init_app(app: Flask) -> None: |     def init_app(app: Flask) -> None: | ||||||
|         app.register_blueprint(bp) |         app.register_blueprint(bp) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| Flask-Login Integration | Flask-Login Integration | ||||||
| ======================= | ======================= | ||||||
|  |  | ||||||
| Flask-Digest-Auth can work with Flask-Login.  You can write a Flask | Flask-Digest-Auth works with Flask-Login_.  You can write a Flask | ||||||
| module that requires log in, without specifying the authentication | module that requires log in, without specifying how to log in.  The | ||||||
| mechanism.  The Flask application can specify the actual | application can use either HTTP Digest Authentication, or the log in | ||||||
| authentication mechanism as it sees fit. | forms, as needed. | ||||||
|  |  | ||||||
|  | To use Flask-Login with Flask-Digest-Auth, | ||||||
|  | ``login_manager.init_app(app)`` must be called before | ||||||
|  | ``auth.init_app(app)``. | ||||||
|  |  | ||||||
|  | The currently logged-in user can be retrieved at | ||||||
|  | ``flask_login.current_user``, if any. | ||||||
|  |  | ||||||
|  | The views only depend on Flask-Login, but not the Flask-Digest-Auth. | ||||||
|  | You can change the actual authentication mechanism without changing | ||||||
|  | the views. | ||||||
|  |  | ||||||
|  |  | ||||||
| Example for Simple Applications with Flask-Login Integration | Simple Applications with Flask-Login Integration | ||||||
| ------------------------------------------------------------ | ------------------------------------------------ | ||||||
|  |  | ||||||
| In your ``my_app.py``: | In your ``my_app.py``: | ||||||
|  |  | ||||||
| :: | :: | ||||||
|  |  | ||||||
|     from flask import Flask |     import flask_login | ||||||
|  |     from flask import Flask, request, redirect | ||||||
|     from flask_digest_auth import DigestAuth |     from flask_digest_auth import DigestAuth | ||||||
|     from flask_login import LoginManager |  | ||||||
|  |  | ||||||
|     app: flask = Flask(__name__) |     app: flask = Flask(__name__) | ||||||
|     ... (Configure the Flask application) ... |     ... (Configure the Flask application) ... | ||||||
|  |  | ||||||
|     login_manager: LoginManager = LoginManager() |     login_manager: flask_login.LoginManager = flask_login.LoginManager() | ||||||
|     login_manager.init_app(app) |     login_manager.init_app(app) | ||||||
|  |  | ||||||
|     @login_manager.user_loader |     @login_manager.user_loader | ||||||
| @@ -156,22 +215,31 @@ In your ``my_app.py``: | |||||||
|         ... (Load the password hash) ... |         ... (Load the password hash) ... | ||||||
|  |  | ||||||
|     @app.get("/admin") |     @app.get("/admin") | ||||||
|     @login_manager.login_required |     @flask_login.login_required | ||||||
|     def admin(): |     def admin(): | ||||||
|         ... (Process the view) ... |         return f"Hello, {flask_login.current_user.get_id()}!" | ||||||
|  |  | ||||||
|  |     @app.post("/logout") | ||||||
|  |     @flask_login.login_required | ||||||
|  |     def logout(): | ||||||
|  |         auth.logout() | ||||||
|  |         # Do not call flask_login.logout_user() | ||||||
|  |         return redirect(request.form.get("next")) | ||||||
|  |  | ||||||
|  |  | ||||||
| Example for Larger Applications with ``create_app()`` with Flask-Login Integration | Larger Applications with ``create_app()`` with Flask-Login Integration | ||||||
| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ||||||
|  |  | ||||||
| In your ``my_app/__init__.py``: | In your ``my_app/__init__.py``: | ||||||
|  |  | ||||||
| ::: | :: | ||||||
|  |  | ||||||
|     from flask import Flask |     from flask import Flask | ||||||
|     from flask_digest_auth import DigestAuth |     from flask_digest_auth import DigestAuth | ||||||
|     from flask_login import LoginManager |     from flask_login import LoginManager | ||||||
|  |  | ||||||
|  |     auth: DigestAuth = DigestAuth() | ||||||
|  |  | ||||||
|     def create_app(test_config = None) -> Flask: |     def create_app(test_config = None) -> Flask: | ||||||
|         app: flask = Flask(__name__) |         app: flask = Flask(__name__) | ||||||
|         ... (Configure the Flask application) ... |         ... (Configure the Flask application) ... | ||||||
| @@ -183,7 +251,7 @@ In your ``my_app/__init__.py``: | |||||||
|         def load_user(user_id: str) -> t.Optional[User]: |         def load_user(user_id: str) -> t.Optional[User]: | ||||||
|             ... (Load the user with the username) ... |             ... (Load the user with the username) ... | ||||||
|  |  | ||||||
|         auth: DigestAuth = DigestAuth(realm=app.config["REALM"]) |         auth.realm = app.config["REALM"] | ||||||
|         auth.init_app(app) |         auth.init_app(app) | ||||||
|  |  | ||||||
|         @auth.register_get_password |         @auth.register_get_password | ||||||
| @@ -197,33 +265,73 @@ In your ``my_app/views.py``: | |||||||
| :: | :: | ||||||
|  |  | ||||||
|     import flask_login |     import flask_login | ||||||
|     from flask import Flask, Blueprint |     from flask import Flask, Blueprint, request, redirect | ||||||
|  |     from my_app import auth | ||||||
|  |  | ||||||
|     bp = Blueprint("admin", __name__, url_prefix="/admin") |     bp = Blueprint("admin", __name__, url_prefix="/admin") | ||||||
|  |  | ||||||
|     @bp.get("/") |     @bp.get("/admin") | ||||||
|     @flask_login.login_required |     @flask_login.login_required | ||||||
|     def admin(): |     def admin(): | ||||||
|         ... (Process the view) ... |         return f"Hello, {flask_login.current_user.get_id()}!" | ||||||
|  |  | ||||||
|  |     @app.post("/logout") | ||||||
|  |     @flask_login.login_required | ||||||
|  |     def logout(): | ||||||
|  |         auth.logout() | ||||||
|  |         # Do not call flask_login.logout_user() | ||||||
|  |         return redirect(request.form.get("next")) | ||||||
|  |  | ||||||
|     def init_app(app: Flask) -> None: |     def init_app(app: Flask) -> None: | ||||||
|         app.register_blueprint(bp) |         app.register_blueprint(bp) | ||||||
|  |  | ||||||
| The views only depend on Flask-Login, but not its underlying | The views only depend on Flask-Login, but not the actual | ||||||
| authentication mechanism.  You can always change the | authentication mechanism.  You can change the actual authentication | ||||||
| authentication mechanism without changing the views, or release a | mechanism without changing the views. | ||||||
| protected Flask module without specifying the authentication |  | ||||||
| mechanism. |  | ||||||
|  |  | ||||||
|  |  | ||||||
| Writing Tests | Session Integration | ||||||
| ============= | =================== | ||||||
|  |  | ||||||
| You can write tests with our test client that handles HTTP Digest | Flask-Digest-Auth features session integration.  The user log in | ||||||
| Authentication.  Example for a unittest testcase: | is remembered in the session.  The authentication information is not | ||||||
|  | requested again.  This is different to the practice of the HTTP Digest | ||||||
|  | Authentication, but is convenient for the log in accounting. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Log In Bookkeeping | ||||||
|  | ================== | ||||||
|  |  | ||||||
|  | You can register a callback to run when the user logs in, for ex., | ||||||
|  | logging the log in event, adding the log in counter, etc. | ||||||
|  |  | ||||||
| :: | :: | ||||||
|  |  | ||||||
|  |     @auth.register_on_login | ||||||
|  |     def on_login(user: User) -> None: | ||||||
|  |         user.visits = user.visits + 1 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Log Out | ||||||
|  | ======= | ||||||
|  |  | ||||||
|  | Flask-Digest-Auth supports log out.  The user will be prompted for the | ||||||
|  | new username and password. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Test Client | ||||||
|  | =========== | ||||||
|  |  | ||||||
|  | Flask-Digest-Auth comes with a test client that supports HTTP digest | ||||||
|  | authentication. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | A unittest Test Case | ||||||
|  | -------------------- | ||||||
|  |  | ||||||
|  | :: | ||||||
|  |  | ||||||
|  |     from flask import Flask | ||||||
|     from flask_digest_auth import Client |     from flask_digest_auth import Client | ||||||
|     from flask_testing import TestCase |     from flask_testing import TestCase | ||||||
|     from my_app import create_app |     from my_app import create_app | ||||||
| @@ -246,6 +354,38 @@ Authentication.  Example for a unittest testcase: | |||||||
|             self.assertEqual(response.status_code, 200) |             self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | A pytest Test | ||||||
|  | ------------- | ||||||
|  |  | ||||||
|  | :: | ||||||
|  |  | ||||||
|  |     import pytest | ||||||
|  |     from flask import Flask | ||||||
|  |     from flask_digest_auth import Client | ||||||
|  |     from my_app import create_app | ||||||
|  |  | ||||||
|  |     @pytest.fixture() | ||||||
|  |     def app(): | ||||||
|  |         app: Flask = create_app({ | ||||||
|  |             "SECRET_KEY": token_urlsafe(32), | ||||||
|  |             "TESTING": True | ||||||
|  |         }) | ||||||
|  |         app.test_client_class = Client | ||||||
|  |         yield app | ||||||
|  |  | ||||||
|  |     @pytest.fixture() | ||||||
|  |     def client(app): | ||||||
|  |         return app.test_client() | ||||||
|  |  | ||||||
|  |     def test_admin(app: Flask, client: Client): | ||||||
|  |         with app.app_context(): | ||||||
|  |             response = self.client.get("/admin") | ||||||
|  |             assert response.status_code == 401 | ||||||
|  |             response = self.client.get( | ||||||
|  |                 "/admin", digest_auth=("my_name", "my_pass")) | ||||||
|  |             assert response.status_code == 200 | ||||||
|  |  | ||||||
|  |  | ||||||
| Copyright | Copyright | ||||||
| ========= | ========= | ||||||
|  |  | ||||||
| @@ -263,9 +403,17 @@ Copyright | |||||||
|  See the License for the specific language governing permissions and |  See the License for the specific language governing permissions and | ||||||
|  limitations under the License. |  limitations under the License. | ||||||
|  |  | ||||||
|  |  | ||||||
| Authors | Authors | ||||||
| ======= | ======= | ||||||
|  |  | ||||||
| | imacat | | imacat | ||||||
| | imacat@mail.imacat.idv.tw | | imacat@mail.imacat.idv.tw | ||||||
| | 2022/11/23 | | 2022/11/23 | ||||||
|  |  | ||||||
|  | .. _HTTP Digest Authentication: https://en.wikipedia.org/wiki/Digest_access_authentication | ||||||
|  | .. _RFC 2617: https://www.rfc-editor.org/rfc/rfc2617 | ||||||
|  | .. _Flask: https://flask.palletsprojects.com | ||||||
|  | .. _Flask-Digest-Auth GitHub repository: https://github.com/imacat/flask-digest-auth | ||||||
|  | .. _Flask-Digest-Auth readthedocs documentation: https://flask-digest-auth.readthedocs.io | ||||||
|  | .. _Flask-Login: https://flask-login.readthedocs.io | ||||||
|   | |||||||
							
								
								
									
										20
									
								
								docs/Makefile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								docs/Makefile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | |||||||
|  | # Minimal makefile for Sphinx documentation | ||||||
|  | # | ||||||
|  |  | ||||||
|  | # You can set these variables from the command line, and also | ||||||
|  | # from the environment for the first two. | ||||||
|  | SPHINXOPTS    ?= | ||||||
|  | SPHINXBUILD   ?= sphinx-build | ||||||
|  | SOURCEDIR     = source | ||||||
|  | BUILDDIR      = build | ||||||
|  |  | ||||||
|  | # Put it first so that "make" without argument is like "make help". | ||||||
|  | help: | ||||||
|  | 	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) | ||||||
|  |  | ||||||
|  | .PHONY: help Makefile | ||||||
|  |  | ||||||
|  | # Catch-all target: route all unknown targets to Sphinx using the new | ||||||
|  | # "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS). | ||||||
|  | %: Makefile | ||||||
|  | 	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) | ||||||
							
								
								
									
										35
									
								
								docs/make.bat
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								docs/make.bat
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | |||||||
|  | @ECHO OFF | ||||||
|  |  | ||||||
|  | pushd %~dp0 | ||||||
|  |  | ||||||
|  | REM Command file for Sphinx documentation | ||||||
|  |  | ||||||
|  | if "%SPHINXBUILD%" == "" ( | ||||||
|  | 	set SPHINXBUILD=sphinx-build | ||||||
|  | ) | ||||||
|  | set SOURCEDIR=source | ||||||
|  | set BUILDDIR=build | ||||||
|  |  | ||||||
|  | if "%1" == "" goto help | ||||||
|  |  | ||||||
|  | %SPHINXBUILD% >NUL 2>NUL | ||||||
|  | if errorlevel 9009 ( | ||||||
|  | 	echo. | ||||||
|  | 	echo.The 'sphinx-build' command was not found. Make sure you have Sphinx | ||||||
|  | 	echo.installed, then set the SPHINXBUILD environment variable to point | ||||||
|  | 	echo.to the full path of the 'sphinx-build' executable. Alternatively you | ||||||
|  | 	echo.may add the Sphinx directory to PATH. | ||||||
|  | 	echo. | ||||||
|  | 	echo.If you don't have Sphinx installed, grab it from | ||||||
|  | 	echo.http://sphinx-doc.org/ | ||||||
|  | 	exit /b 1 | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% | ||||||
|  | goto end | ||||||
|  |  | ||||||
|  | :help | ||||||
|  | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% | ||||||
|  |  | ||||||
|  | :end | ||||||
|  | popd | ||||||
							
								
								
									
										1
									
								
								docs/requirements.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								docs/requirements.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | flask | ||||||
							
								
								
									
										0
									
								
								docs/source/_static/.keep
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								docs/source/_static/.keep
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										0
									
								
								docs/source/_templates/.keep
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								docs/source/_templates/.keep
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										59
									
								
								docs/source/conf.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								docs/source/conf.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | |||||||
|  | # Configuration file for the Sphinx documentation builder. | ||||||
|  | # | ||||||
|  | # This file only contains a selection of the most common options. For a full | ||||||
|  | # list see the documentation: | ||||||
|  | # https://www.sphinx-doc.org/en/master/usage/configuration.html | ||||||
|  | import os | ||||||
|  | # -- Path setup -------------------------------------------------------------- | ||||||
|  |  | ||||||
|  | # If extensions (or modules to document with autodoc) are in another directory, | ||||||
|  | # add these directories to sys.path here. If the directory is relative to the | ||||||
|  | # documentation root, use os.path.abspath to make it absolute, like shown here. | ||||||
|  | # | ||||||
|  | import sys | ||||||
|  |  | ||||||
|  | sys.path.insert(0, os.path.abspath('../../src/')) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # -- Project information ----------------------------------------------------- | ||||||
|  |  | ||||||
|  | project = 'Flask-Digest-Auth' | ||||||
|  | copyright = '2022, imacat' | ||||||
|  | author = 'imacat' | ||||||
|  |  | ||||||
|  | # The full version, including alpha/beta/rc tags | ||||||
|  | release = '0.2.3' | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # -- General configuration --------------------------------------------------- | ||||||
|  |  | ||||||
|  | # Add any Sphinx extension module names here, as strings. They can be | ||||||
|  | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom | ||||||
|  | # ones. | ||||||
|  | extensions = [ | ||||||
|  |     "sphinx.ext.autodoc" | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | # Add any paths that contain templates here, relative to this directory. | ||||||
|  | templates_path = ['_templates'] | ||||||
|  |  | ||||||
|  | # List of patterns, relative to source directory, that match files and | ||||||
|  | # directories to ignore when looking for source files. | ||||||
|  | # This pattern also affects html_static_path and html_extra_path. | ||||||
|  | exclude_patterns = [] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # -- Options for HTML output ------------------------------------------------- | ||||||
|  |  | ||||||
|  | # The theme to use for HTML and HTML Help pages.  See the documentation for | ||||||
|  | # a list of builtin themes. | ||||||
|  | # | ||||||
|  | html_theme = 'sphinx_rtd_theme' | ||||||
|  |  | ||||||
|  | # Add any paths that contain custom static files (such as style sheets) here, | ||||||
|  | # relative to this directory. They are copied after the builtin static files, | ||||||
|  | # so a file named "default.css" will overwrite the builtin "default.css". | ||||||
|  | html_static_path = ['_static'] | ||||||
|  |  | ||||||
|  | # For readthedocs.io to work properly. | ||||||
|  | master_doc = 'index' | ||||||
							
								
								
									
										268
									
								
								docs/source/examples.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										268
									
								
								docs/source/examples.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,268 @@ | |||||||
|  | Examples | ||||||
|  | ======== | ||||||
|  |  | ||||||
|  |  | ||||||
|  | .. _example-alone-simple: | ||||||
|  |  | ||||||
|  | Simple Applications with Flask-Digest-Auth Alone | ||||||
|  | ------------------------------------------------ | ||||||
|  |  | ||||||
|  | In your ``my_app.py``: | ||||||
|  |  | ||||||
|  | :: | ||||||
|  |  | ||||||
|  |     from flask import Flask, request, redirect | ||||||
|  |     from flask_digest_auth import DigestAuth | ||||||
|  |  | ||||||
|  |     app: flask = Flask(__name__) | ||||||
|  |     ... (Configure the Flask application) ... | ||||||
|  |  | ||||||
|  |     auth: DigestAuth = DigestAuth(realm="Admin") | ||||||
|  |     auth.init_app(app) | ||||||
|  |  | ||||||
|  |     @auth.register_get_password | ||||||
|  |     def get_password_hash(username: str) -> t.Optional[str]: | ||||||
|  |         ... (Load the password hash) ... | ||||||
|  |  | ||||||
|  |     @auth.register_get_user | ||||||
|  |     def get_user(username: str) -> t.Optional[t.Any]: | ||||||
|  |         ... (Load the user) ... | ||||||
|  |  | ||||||
|  |     @app.get("/admin") | ||||||
|  |     @auth.login_required | ||||||
|  |     def admin(): | ||||||
|  |         return f"Hello, {g.user.username}!" | ||||||
|  |  | ||||||
|  |     @app.post("/logout") | ||||||
|  |     @auth.login_required | ||||||
|  |     def logout(): | ||||||
|  |         auth.logout() | ||||||
|  |         return redirect(request.form.get("next")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | .. _example-alone-large: | ||||||
|  |  | ||||||
|  | Larger Applications with ``create_app()`` with Flask-Digest-Auth Alone | ||||||
|  | ---------------------------------------------------------------------- | ||||||
|  |  | ||||||
|  | In your ``my_app/__init__.py``: | ||||||
|  |  | ||||||
|  | :: | ||||||
|  |  | ||||||
|  |     from flask import Flask | ||||||
|  |     from flask_digest_auth import DigestAuth | ||||||
|  |  | ||||||
|  |     auth: DigestAuth = DigestAuth() | ||||||
|  |  | ||||||
|  |     def create_app(test_config = None) -> Flask: | ||||||
|  |         app: flask = Flask(__name__) | ||||||
|  |         ... (Configure the Flask application) ... | ||||||
|  |  | ||||||
|  |         auth.realm = app.config["REALM"] | ||||||
|  |         auth.init_app(app) | ||||||
|  |  | ||||||
|  |         @auth.register_get_password | ||||||
|  |         def get_password_hash(username: str) -> t.Optional[str]: | ||||||
|  |             ... (Load the password hash) ... | ||||||
|  |  | ||||||
|  |         @auth.register_get_user | ||||||
|  |         def get_user(username: str) -> t.Optional[t.Any]: | ||||||
|  |             ... (Load the user) ... | ||||||
|  |  | ||||||
|  |         return app | ||||||
|  |  | ||||||
|  | In your ``my_app/views.py``: | ||||||
|  |  | ||||||
|  | :: | ||||||
|  |  | ||||||
|  |     from my_app import auth | ||||||
|  |     from flask import Flask, Blueprint, request, redirect | ||||||
|  |  | ||||||
|  |     bp = Blueprint("admin", __name__, url_prefix="/admin") | ||||||
|  |  | ||||||
|  |     @bp.get("/admin") | ||||||
|  |     @auth.login_required | ||||||
|  |     def admin(): | ||||||
|  |         return f"Hello, {g.user.username}!" | ||||||
|  |  | ||||||
|  |     @app.post("/logout") | ||||||
|  |     @auth.login_required | ||||||
|  |     def logout(): | ||||||
|  |         auth.logout() | ||||||
|  |         return redirect(request.form.get("next")) | ||||||
|  |  | ||||||
|  |     def init_app(app: Flask) -> None: | ||||||
|  |         app.register_blueprint(bp) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | .. _example-flask-login-simple: | ||||||
|  |  | ||||||
|  | Simple Applications with Flask-Login Integration | ||||||
|  | ------------------------------------------------ | ||||||
|  |  | ||||||
|  | In your ``my_app.py``: | ||||||
|  |  | ||||||
|  | :: | ||||||
|  |  | ||||||
|  |     import flask_login | ||||||
|  |     from flask import Flask, request, redirect | ||||||
|  |     from flask_digest_auth import DigestAuth | ||||||
|  |  | ||||||
|  |     app: flask = Flask(__name__) | ||||||
|  |     ... (Configure the Flask application) ... | ||||||
|  |  | ||||||
|  |     login_manager: flask_login.LoginManager = flask_login.LoginManager() | ||||||
|  |     login_manager.init_app(app) | ||||||
|  |  | ||||||
|  |     @login_manager.user_loader | ||||||
|  |     def load_user(user_id: str) -> t.Optional[User]: | ||||||
|  |         ... (Load the user with the username) ... | ||||||
|  |  | ||||||
|  |     auth: DigestAuth = DigestAuth(realm="Admin") | ||||||
|  |     auth.init_app(app) | ||||||
|  |  | ||||||
|  |     @auth.register_get_password | ||||||
|  |     def get_password_hash(username: str) -> t.Optional[str]: | ||||||
|  |         ... (Load the password hash) ... | ||||||
|  |  | ||||||
|  |     @app.get("/admin") | ||||||
|  |     @flask_login.login_required | ||||||
|  |     def admin(): | ||||||
|  |         return f"Hello, {flask_login.current_user.get_id()}!" | ||||||
|  |  | ||||||
|  |     @app.post("/logout") | ||||||
|  |     @flask_login.login_required | ||||||
|  |     def logout(): | ||||||
|  |         auth.logout() | ||||||
|  |         # Do not call flask_login.logout_user() | ||||||
|  |         return redirect(request.form.get("next")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | .. _example-flask-login-large: | ||||||
|  |  | ||||||
|  | Larger Applications with ``create_app()`` with Flask-Login Integration | ||||||
|  | ---------------------------------------------------------------------- | ||||||
|  |  | ||||||
|  | In your ``my_app/__init__.py``: | ||||||
|  |  | ||||||
|  | :: | ||||||
|  |  | ||||||
|  |     from flask import Flask | ||||||
|  |     from flask_digest_auth import DigestAuth | ||||||
|  |     from flask_login import LoginManager | ||||||
|  |  | ||||||
|  |     auth: DigestAuth = DigestAuth() | ||||||
|  |  | ||||||
|  |     def create_app(test_config = None) -> Flask: | ||||||
|  |         app: flask = Flask(__name__) | ||||||
|  |         ... (Configure the Flask application) ... | ||||||
|  |  | ||||||
|  |         login_manager: LoginManager = LoginManager() | ||||||
|  |         login_manager.init_app(app) | ||||||
|  |  | ||||||
|  |         @login_manager.user_loader | ||||||
|  |         def load_user(user_id: str) -> t.Optional[User]: | ||||||
|  |             ... (Load the user with the username) ... | ||||||
|  |  | ||||||
|  |         auth.realm = app.config["REALM"] | ||||||
|  |         auth.init_app(app) | ||||||
|  |  | ||||||
|  |         @auth.register_get_password | ||||||
|  |         def get_password_hash(username: str) -> t.Optional[str]: | ||||||
|  |             ... (Load the password hash) ... | ||||||
|  |  | ||||||
|  |         return app | ||||||
|  |  | ||||||
|  | In your ``my_app/views.py``: | ||||||
|  |  | ||||||
|  | :: | ||||||
|  |  | ||||||
|  |     import flask_login | ||||||
|  |     from flask import Flask, Blueprint, request, redirect | ||||||
|  |     from my_app import auth | ||||||
|  |  | ||||||
|  |     bp = Blueprint("admin", __name__, url_prefix="/admin") | ||||||
|  |  | ||||||
|  |     @bp.get("/admin") | ||||||
|  |     @flask_login.login_required | ||||||
|  |     def admin(): | ||||||
|  |         return f"Hello, {flask_login.current_user.get_id()}!" | ||||||
|  |  | ||||||
|  |     @app.post("/logout") | ||||||
|  |     @flask_login.login_required | ||||||
|  |     def logout(): | ||||||
|  |         auth.logout() | ||||||
|  |         # Do not call flask_login.logout_user() | ||||||
|  |         return redirect(request.form.get("next")) | ||||||
|  |  | ||||||
|  |     def init_app(app: Flask) -> None: | ||||||
|  |         app.register_blueprint(bp) | ||||||
|  |  | ||||||
|  | The views only depend on Flask-Login, but not the actual | ||||||
|  | authentication mechanism.  You can change the actual authentication | ||||||
|  | mechanism without changing the views. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | .. _example-unittest: | ||||||
|  |  | ||||||
|  | A unittest Test Case | ||||||
|  | -------------------- | ||||||
|  |  | ||||||
|  | :: | ||||||
|  |  | ||||||
|  |     from flask import Flask | ||||||
|  |     from flask_digest_auth import Client | ||||||
|  |     from flask_testing import TestCase | ||||||
|  |     from my_app import create_app | ||||||
|  |  | ||||||
|  |     class MyTestCase(TestCase): | ||||||
|  |  | ||||||
|  |         def create_app(self): | ||||||
|  |             app: Flask = create_app({ | ||||||
|  |                 "SECRET_KEY": token_urlsafe(32), | ||||||
|  |                 "TESTING": True | ||||||
|  |             }) | ||||||
|  |             app.test_client_class = Client | ||||||
|  |             return app | ||||||
|  |  | ||||||
|  |         def test_admin(self): | ||||||
|  |             response = self.client.get("/admin") | ||||||
|  |             self.assertEqual(response.status_code, 401) | ||||||
|  |             response = self.client.get( | ||||||
|  |                 "/admin", digest_auth=("my_name", "my_pass")) | ||||||
|  |             self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | .. _example-pytest: | ||||||
|  |  | ||||||
|  | A pytest Test | ||||||
|  | ------------- | ||||||
|  |  | ||||||
|  | :: | ||||||
|  |  | ||||||
|  |     import pytest | ||||||
|  |     from flask import Flask | ||||||
|  |     from flask_digest_auth import Client | ||||||
|  |     from my_app import create_app | ||||||
|  |  | ||||||
|  |     @pytest.fixture() | ||||||
|  |     def app(): | ||||||
|  |         app: Flask = create_app({ | ||||||
|  |             "SECRET_KEY": token_urlsafe(32), | ||||||
|  |             "TESTING": True | ||||||
|  |         }) | ||||||
|  |         app.test_client_class = Client | ||||||
|  |         yield app | ||||||
|  |  | ||||||
|  |     @pytest.fixture() | ||||||
|  |     def client(app): | ||||||
|  |         return app.test_client() | ||||||
|  |  | ||||||
|  |     def test_admin(app: Flask, client: Client): | ||||||
|  |         with app.app_context(): | ||||||
|  |             response = self.client.get("/admin") | ||||||
|  |             assert response.status_code == 401 | ||||||
|  |             response = self.client.get( | ||||||
|  |                 "/admin", digest_auth=("my_name", "my_pass")) | ||||||
|  |             assert response.status_code == 200 | ||||||
							
								
								
									
										24
									
								
								docs/source/flask_digest_auth.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								docs/source/flask_digest_auth.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | |||||||
|  | flask\_digest\_auth package | ||||||
|  | =========================== | ||||||
|  |  | ||||||
|  | The ``DigestAuth`` Class | ||||||
|  | ------------------------ | ||||||
|  | .. autoclass:: flask_digest_auth.DigestAuth | ||||||
|  |     :members: | ||||||
|  |     :undoc-members: | ||||||
|  |     :show-inheritance: | ||||||
|  |  | ||||||
|  | The ``make_password_hash`` Function | ||||||
|  | ----------------------------------- | ||||||
|  | .. autofunction:: flask_digest_auth.make_password_hash | ||||||
|  |  | ||||||
|  | The ``calc_response`` Function | ||||||
|  | ------------------------------ | ||||||
|  | .. autofunction:: flask_digest_auth.calc_response | ||||||
|  |  | ||||||
|  | The ``Client`` Test Class | ||||||
|  | ------------------------- | ||||||
|  | .. autoclass:: flask_digest_auth.Client | ||||||
|  |     :members: | ||||||
|  |     :undoc-members: | ||||||
|  |     :show-inheritance: | ||||||
							
								
								
									
										34
									
								
								docs/source/index.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								docs/source/index.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | |||||||
|  | .. flask-digest-auth documentation master file, created by | ||||||
|  |    sphinx-quickstart on Tue Dec  6 15:15:08 2022. | ||||||
|  |    You can adapt this file completely to your liking, but it should at least | ||||||
|  |    contain the root `toctree` directive. | ||||||
|  |  | ||||||
|  | Welcome to Flask-Digest-Auth's documentation! | ||||||
|  | ============================================= | ||||||
|  |  | ||||||
|  | *Flask-Digest-Auth* is an `HTTP Digest Authentication`_ implementation | ||||||
|  | for Flask_ applications.  It authenticates the user for the protected | ||||||
|  | views. | ||||||
|  |  | ||||||
|  | HTTP Digest Authentication is specified in `RFC 2617`_. | ||||||
|  |  | ||||||
|  | .. toctree:: | ||||||
|  |    :maxdepth: 2 | ||||||
|  |    :caption: Contents: | ||||||
|  |  | ||||||
|  |    intro | ||||||
|  |    flask_digest_auth | ||||||
|  |    examples | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Indices and tables | ||||||
|  | ================== | ||||||
|  |  | ||||||
|  | * :ref:`genindex` | ||||||
|  | * :ref:`modindex` | ||||||
|  | * :ref:`search` | ||||||
|  |  | ||||||
|  | .. _HTTP Digest Authentication: https://en.wikipedia.org/wiki/Digest_access_authentication | ||||||
|  | .. _RFC 2617: https://www.rfc-editor.org/rfc/rfc2617 | ||||||
|  | .. _Flask: https://flask.palletsprojects.com | ||||||
							
								
								
									
										148
									
								
								docs/source/intro.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										148
									
								
								docs/source/intro.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,148 @@ | |||||||
|  | Introduction | ||||||
|  | ============ | ||||||
|  |  | ||||||
|  |  | ||||||
|  | *Flask-Digest-Auth* is an `HTTP Digest Authentication`_ implementation | ||||||
|  | for Flask_ applications.  It authenticates the user for the protected | ||||||
|  | views. | ||||||
|  |  | ||||||
|  | HTTP Digest Authentication is specified in `RFC 2617`_. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Why HTTP Digest Authentication? | ||||||
|  | ------------------------------- | ||||||
|  |  | ||||||
|  | *HTTP Digest Authentication* has the advantage that it does not send | ||||||
|  | the actual password to the server, which greatly enhances the | ||||||
|  | security.  It uses the challenge-response authentication scheme.  The | ||||||
|  | client returns the response calculated from the challenge and the | ||||||
|  | password, but not the original password. | ||||||
|  |  | ||||||
|  | Log in forms has the advantage of freedom, in the senses of both the | ||||||
|  | visual design and the actual implementation.  You may implement your | ||||||
|  | own challenge-response log in form, but then you are reinventing the | ||||||
|  | wheels.  If a pretty log in form is not critical to your project, HTTP | ||||||
|  | Digest Authentication should be a good choice. | ||||||
|  |  | ||||||
|  | Flask-Digest-Auth works with Flask-Login_.  Log in protection can be | ||||||
|  | separated with the authentication mechanism.  You can create protected | ||||||
|  | Flask modules without knowing the actual authentication mechanisms. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Installation | ||||||
|  | ------------ | ||||||
|  |  | ||||||
|  | You can install Flask-Digest-Auth with ``pip``: | ||||||
|  |  | ||||||
|  | :: | ||||||
|  |  | ||||||
|  |     pip install Flask-Digest-Auth | ||||||
|  |  | ||||||
|  | You may also install the latest source from the | ||||||
|  | `Flask-Digest-Auth GitHub repository`_. | ||||||
|  |  | ||||||
|  | :: | ||||||
|  |  | ||||||
|  |     pip install git+https://github.com/imacat/flask-digest-auth.git | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Setting the Password | ||||||
|  | -------------------- | ||||||
|  |  | ||||||
|  | The password hash of the HTTP Digest Authentication is composed of the | ||||||
|  | realm, the username, and the password.  Example for setting the | ||||||
|  | password: | ||||||
|  |  | ||||||
|  | :: | ||||||
|  |  | ||||||
|  |     from flask_digest_auth import make_password_hash | ||||||
|  |  | ||||||
|  |     user.password = make_password_hash(realm, username, password) | ||||||
|  |  | ||||||
|  | The username is part of the hash.  If the user changes their username, | ||||||
|  | you need to ask their password, to generate and store the new password | ||||||
|  | hash. | ||||||
|  |  | ||||||
|  | See :meth:`flask_digest_auth.make_password_hash`. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Flask-Digest-Auth Alone | ||||||
|  | ----------------------- | ||||||
|  |  | ||||||
|  | Flask-Digest-Auth can authenticate the users alone. | ||||||
|  |  | ||||||
|  | See :ref:`example-alone-simple` and :ref:`example-alone-large`. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Flask-Login Integration | ||||||
|  | ----------------------- | ||||||
|  |  | ||||||
|  | Flask-Digest-Auth works with Flask-Login_.  You can write a Flask | ||||||
|  | module that requires log in, without specifying how to log in.  The | ||||||
|  | application can use either HTTP Digest Authentication, or the log in | ||||||
|  | forms, as needed. | ||||||
|  |  | ||||||
|  | To use Flask-Login with Flask-Digest-Auth, | ||||||
|  | ``login_manager.init_app(app)`` must be called before | ||||||
|  | ``auth.init_app(app)``. | ||||||
|  |  | ||||||
|  | The currently logged-in user can be retrieved at | ||||||
|  | ``flask_login.current_user``, if any. | ||||||
|  |  | ||||||
|  | See :ref:`example-flask-login-simple` and | ||||||
|  | :ref:`example-flask-login-large`. | ||||||
|  |  | ||||||
|  | The views only depend on Flask-Login, but not the Flask-Digest-Auth. | ||||||
|  | You can change the actual authentication mechanism without changing | ||||||
|  | the views. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Session Integration | ||||||
|  | ------------------- | ||||||
|  |  | ||||||
|  | Flask-Digest-Auth features session integration.  The user log in | ||||||
|  | is remembered in the session.  The authentication information is not | ||||||
|  | requested again.  This is different to the practice of the HTTP Digest | ||||||
|  | Authentication, but is convenient for the log in accounting. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Log In Bookkeeping | ||||||
|  | ------------------ | ||||||
|  |  | ||||||
|  | You can register a callback to run when the user logs in, for ex., | ||||||
|  | logging the log in event, adding the log in counter, etc. | ||||||
|  |  | ||||||
|  | :: | ||||||
|  |  | ||||||
|  |     @auth.register_on_login | ||||||
|  |     def on_login(user: User) -> None: | ||||||
|  |         user.visits = user.visits + 1 | ||||||
|  |  | ||||||
|  | See :meth:`flask_digest_auth.DigestAuth.register_on_login`. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Log Out | ||||||
|  | ------- | ||||||
|  |  | ||||||
|  | Flask-Digest-Auth supports log out.  The user will be prompted for the | ||||||
|  | new username and password. | ||||||
|  |  | ||||||
|  | See :meth:`flask_digest_auth.DigestAuth.logout`. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Test Client | ||||||
|  | ----------- | ||||||
|  |  | ||||||
|  | Flask-Digest-Auth comes with a test client that supports HTTP digest | ||||||
|  | authentication. | ||||||
|  |  | ||||||
|  | See :class:`flask_digest_auth.Client`. | ||||||
|  |  | ||||||
|  | Also see :ref:`example-unittest` and :ref:`example-pytest`. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | .. _HTTP Digest Authentication: https://en.wikipedia.org/wiki/Digest_access_authentication | ||||||
|  | .. _RFC 2617: https://www.rfc-editor.org/rfc/rfc2617 | ||||||
|  | .. _Flask: https://flask.palletsprojects.com | ||||||
|  | .. _Flask-Login: https://flask-login.readthedocs.io | ||||||
|  | .. _Flask-Digest-Auth GitHub repository: https://github.com/imacat/flask-digest-auth | ||||||
| @@ -17,7 +17,7 @@ | |||||||
|  |  | ||||||
| [metadata] | [metadata] | ||||||
| name = flask-digest-auth | name = flask-digest-auth | ||||||
| version = 0.1.0 | version = 0.2.3 | ||||||
| author = imacat | author = imacat | ||||||
| author_email = imacat@mail.imacat.idv.tw | author_email = imacat@mail.imacat.idv.tw | ||||||
| description = The Flask HTTP Digest Authentication project. | description = The Flask HTTP Digest Authentication project. | ||||||
| @@ -38,7 +38,7 @@ classifiers = | |||||||
| package_dir = | package_dir = | ||||||
|     = src |     = src | ||||||
| packages = find: | packages = find: | ||||||
| python_requires = >=3.10 | python_requires = >=3.7 | ||||||
| install_requires = | install_requires = | ||||||
|   flask |   flask | ||||||
| tests_require = | tests_require = | ||||||
|   | |||||||
| @@ -23,11 +23,16 @@ from __future__ import annotations | |||||||
| import typing as t | import typing as t | ||||||
| from hashlib import md5 | from hashlib import md5 | ||||||
|  |  | ||||||
| from flask_digest_auth.exception import UnauthorizedException |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def make_password_hash(realm: str, username: str, password: str) -> str: | def make_password_hash(realm: str, username: str, password: str) -> str: | ||||||
|     """Calculates the password hash for the HTTP digest authentication. |     """Calculates the password hash for the HTTP digest authentication. | ||||||
|  |     Use this function to set the password for the user. | ||||||
|  |  | ||||||
|  |     For example: | ||||||
|  |  | ||||||
|  |     :: | ||||||
|  |  | ||||||
|  |         user.password = make_password_hash(realm, username, password) | ||||||
|  |  | ||||||
|     :param realm: The realm. |     :param realm: The realm. | ||||||
|     :param username: The username. |     :param username: The username. | ||||||
| @@ -49,67 +54,53 @@ def calc_response( | |||||||
|     :param uri: The request URI. |     :param uri: The request URI. | ||||||
|     :param password_hash: The password hash for the HTTP digest authentication. |     :param password_hash: The password hash for the HTTP digest authentication. | ||||||
|     :param nonce: The nonce. |     :param nonce: The nonce. | ||||||
|     :param qop: the quality of protection. |     :param qop: the quality of protection, either ``auth`` or ``auth-int``. | ||||||
|     :param algorithm: The algorithm, either "MD5" or "MD5-sess". |     :param algorithm: The algorithm, either ``MD5`` or ``MD5-sess``. | ||||||
|     :param cnonce: The client nonce, which must exists when qop exists or |     :param cnonce: The client nonce, which must exists when qop exists or | ||||||
|         algorithm="MD5-sess". |         algorithm is ``MD5-sess``. | ||||||
|     :param nc: The request counter, which must exists when qop exists. |     :param nc: The request counter, which must exists when qop exists. | ||||||
|     :param body: The request body, which must exists when qop="auth-int". |     :param body: The request body, which must exists when qop is ``auth-int``. | ||||||
|     :return: The response value. |     :return: The response value. | ||||||
|     :raise UnauthorizedException: When the cnonce is missing with the MD5-sess |     :raise AssertionError: When cnonce is missing with algorithm is | ||||||
|         algorithm, when the body is missing with the auth-int qop, or when the |         ``MD5-sess``, when body is missing with qop is ``auth-int``, or when | ||||||
|         cnonce or nc is missing with the auth or auth-int qop. |         cnonce or nc is missing with qop exits. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     def calc_ha1() -> str: |     def calc_ha1() -> str: | ||||||
|         """Calculates and returns the first hash. |         """Calculates and returns the first hash. | ||||||
|  |  | ||||||
|         :return: The first hash. |         :return: The first hash. | ||||||
|         :raise UnauthorizedException: When the cnonce is missing with the MD5-sess |         :raise AssertionError: When cnonce is missing with | ||||||
|             algorithm. |             algorithm is ``MD5-sess``. | ||||||
|         """ |         """ | ||||||
|         if algorithm is None or algorithm == "MD5": |  | ||||||
|             return password_hash |  | ||||||
|         if algorithm == "MD5-sess": |         if algorithm == "MD5-sess": | ||||||
|             if cnonce is None: |             assert cnonce is not None,\ | ||||||
|                 raise UnauthorizedException( |                 f"Missing \"cnonce\" with algorithm=\"{algorithm}\"" | ||||||
|                     f"Missing \"cnonce\" with algorithm=\"{algorithm}\"") |  | ||||||
|             return md5(f"{password_hash}:{nonce}:{cnonce}".encode("utf8")) \ |             return md5(f"{password_hash}:{nonce}:{cnonce}".encode("utf8")) \ | ||||||
|                 .hexdigest() |                 .hexdigest() | ||||||
|         raise UnauthorizedException( |         # algorithm is None or algorithm == "MD5" | ||||||
|             f"Unsupported algorithm=\"{algorithm}\"") |         return password_hash | ||||||
|  |  | ||||||
|     def calc_ha2() -> str: |     def calc_ha2() -> str: | ||||||
|         """Calculates the second hash. |         """Calculates the second hash. | ||||||
|  |  | ||||||
|         :return: The second hash. |         :return: The second hash. | ||||||
|         :raise UnauthorizedException: When the body is missing with |         :raise AssertionError: When body is missing with qop is ``auth-int``. | ||||||
|             qop="auth-int". |  | ||||||
|         """ |         """ | ||||||
|         if qop is None or qop == "auth": |  | ||||||
|             return md5(f"{method}:{uri}".encode("utf8")).hexdigest() |  | ||||||
|         if qop == "auth-int": |         if qop == "auth-int": | ||||||
|             if body is None: |             assert body is not None, f"Missing \"body\" with qop=\"{qop}\"" | ||||||
|                 raise UnauthorizedException( |  | ||||||
|                     f"Missing \"body\" with qop=\"{qop}\"") |  | ||||||
|             return md5( |             return md5( | ||||||
|                 f"{method}:{uri}:{md5(body).hexdigest()}".encode("utf8")) \ |                 f"{method}:{uri}:{md5(body).hexdigest()}".encode("utf8")) \ | ||||||
|                 .hexdigest() |                 .hexdigest() | ||||||
|         raise UnauthorizedException(f"Unsupported qop=\"{qop}\"") |         # qop is None or qop == "auth" | ||||||
|  |         return md5(f"{method}:{uri}".encode("utf8")).hexdigest() | ||||||
|  |  | ||||||
|     ha1: str = calc_ha1() |     ha1: str = calc_ha1() | ||||||
|     ha2: str = calc_ha2() |     ha2: str = calc_ha2() | ||||||
|     if qop is None: |  | ||||||
|         return md5(f"{ha1}:{nonce}:{ha2}".encode("utf8")).hexdigest() |  | ||||||
|     if qop == "auth" or qop == "auth-int": |     if qop == "auth" or qop == "auth-int": | ||||||
|         if cnonce is None: |         assert cnonce is not None, f"Missing \"cnonce\" with the qop=\"{qop}\"" | ||||||
|             raise UnauthorizedException( |         assert nc is not None, f"Missing \"nc\" with the qop=\"{qop}\"" | ||||||
|                 f"Missing \"cnonce\" with the qop=\"{qop}\"") |  | ||||||
|         if nc is None: |  | ||||||
|             raise UnauthorizedException( |  | ||||||
|                 f"Missing \"nc\" with the qop=\"{qop}\"") |  | ||||||
|         return md5(f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}".encode("utf8"))\ |         return md5(f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}".encode("utf8"))\ | ||||||
|             .hexdigest() |             .hexdigest() | ||||||
|     if cnonce is None: |     # qop is None | ||||||
|         raise UnauthorizedException( |     return md5(f"{ha1}:{nonce}:{ha2}".encode("utf8")).hexdigest() | ||||||
|             f"Unsupported qop=\"{qop}\"") |  | ||||||
|   | |||||||
| @@ -24,15 +24,13 @@ from __future__ import annotations | |||||||
| import sys | import sys | ||||||
| import typing as t | import typing as t | ||||||
| from functools import wraps | from functools import wraps | ||||||
| from random import random | from secrets import token_urlsafe, randbits | ||||||
| from secrets import token_urlsafe |  | ||||||
|  |  | ||||||
| from flask import g, request, Response, session, abort, Flask, Request | from flask import g, request, Response, session, abort, Flask, Request | ||||||
| from itsdangerous import URLSafeTimedSerializer, BadData | from itsdangerous import URLSafeTimedSerializer, BadData | ||||||
| from werkzeug.datastructures import Authorization | from werkzeug.datastructures import Authorization | ||||||
|  |  | ||||||
| from flask_digest_auth.algo import calc_response | from flask_digest_auth.algo import calc_response | ||||||
| from flask_digest_auth.exception import UnauthorizedException |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class DigestAuth: | class DigestAuth: | ||||||
| @@ -51,20 +49,63 @@ class DigestAuth: | |||||||
|         self.use_opaque: bool = True |         self.use_opaque: bool = True | ||||||
|         self.domain: t.List[str] = [] |         self.domain: t.List[str] = [] | ||||||
|         self.qop: t.List[str] = ["auth", "auth-int"] |         self.qop: t.List[str] = ["auth", "auth-int"] | ||||||
|         self.__get_password_hash: t.Callable[[str], t.Optional[str]] \ |         self.app: t.Optional[Flask] = None | ||||||
|             = lambda x: None |         self.__get_password_hash: BasePasswordHashGetter \ | ||||||
|         self.__get_user: t.Callable[[str], t.Optional] = lambda x: None |             = BasePasswordHashGetter() | ||||||
|  |         self.__get_user: BaseUserGetter = BaseUserGetter() | ||||||
|  |         self.__on_login: BaseOnLogInCallback = BaseOnLogInCallback() | ||||||
|  |  | ||||||
|     def login_required(self, view) -> t.Callable: |     def login_required(self, view) -> t.Callable: | ||||||
|         """The view decorator for HTTP digest authentication. |         """The view decorator for HTTP digest authentication. | ||||||
|  |  | ||||||
|         :param view: |         For example: | ||||||
|  |  | ||||||
|  |         :: | ||||||
|  |  | ||||||
|  |             @auth.login_required | ||||||
|  |             def admin(): | ||||||
|  |                 return f"Hello, {g.user.username}!" | ||||||
|  |  | ||||||
|  |         The logged-in user can be retrieved at ``g.user``. | ||||||
|  |  | ||||||
|  |         :param view: The view. | ||||||
|         :return: The login-protected view. |         :return: The login-protected view. | ||||||
|         """ |         """ | ||||||
|  |  | ||||||
|         class NoLogInException(Exception): |         class NoLogInException(Exception): | ||||||
|             """The exception thrown when the user is not authorized.""" |             """The exception thrown when the user is not authorized.""" | ||||||
|  |  | ||||||
|  |         def get_logged_in_user() -> t.Any: | ||||||
|  |             """Returns the currently logged-in user. | ||||||
|  |  | ||||||
|  |             :return: The currently logged-in user. | ||||||
|  |             :raise NoLogInException: When the user is not logged in. | ||||||
|  |             """ | ||||||
|  |             if "user" not in session: | ||||||
|  |                 raise NoLogInException | ||||||
|  |             user: t.Optional[t.Any] = self.__get_user(session["user"]) | ||||||
|  |             if user is None: | ||||||
|  |                 del session["user"] | ||||||
|  |                 raise NoLogInException | ||||||
|  |             return user | ||||||
|  |  | ||||||
|  |         def auth_user(state: AuthState) -> t.Any: | ||||||
|  |             """Authenticates a user. | ||||||
|  |  | ||||||
|  |             :param state: The authentication state. | ||||||
|  |             :return: The user. | ||||||
|  |             :raise UnauthorizedException: When the authentication fails. | ||||||
|  |             """ | ||||||
|  |             authorization: Authorization = request.authorization | ||||||
|  |             if authorization is None: | ||||||
|  |                 raise UnauthorizedException | ||||||
|  |             if authorization.type != "digest": | ||||||
|  |                 raise UnauthorizedException( | ||||||
|  |                     "Not an HTTP digest authorization") | ||||||
|  |             self.__authenticate(state) | ||||||
|  |             session["user"] = authorization.username | ||||||
|  |             return self.__get_user(authorization.username) | ||||||
|  |  | ||||||
|         @wraps(view) |         @wraps(view) | ||||||
|         def login_required_view(*args, **kwargs) -> t.Any: |         def login_required_view(*args, **kwargs) -> t.Any: | ||||||
|             """The login-protected view. |             """The login-protected view. | ||||||
| @@ -74,44 +115,37 @@ class DigestAuth: | |||||||
|             :return: The response. |             :return: The response. | ||||||
|             """ |             """ | ||||||
|             try: |             try: | ||||||
|                 if "user" not in session: |                 g.user = get_logged_in_user() | ||||||
|                     raise NoLogInException |  | ||||||
|                 user: t.Optional[t.Any] = self.__get_user(session["user"]) |  | ||||||
|                 if user is None: |  | ||||||
|                     raise NoLogInException |  | ||||||
|                 g.user = user |  | ||||||
|                 return view(*args, **kwargs) |                 return view(*args, **kwargs) | ||||||
|             except NoLogInException: |             except NoLogInException: | ||||||
|                 state: AuthState = AuthState() |                 pass | ||||||
|                 authorization: Authorization = request.authorization |  | ||||||
|                 try: |             state: AuthState = AuthState() | ||||||
|                     if authorization is None: |             try: | ||||||
|                         raise UnauthorizedException |                 g.user = auth_user(state) | ||||||
|                     if authorization.type != "digest": |                 self.__on_login(g.user) | ||||||
|                         raise UnauthorizedException( |                 return view(*args, **kwargs) | ||||||
|                             "Not an HTTP digest authorization") |             except UnauthorizedException as e: | ||||||
|                     self.authenticate(state) |                 if len(e.args) > 0: | ||||||
|                     session["user"] = authorization.username |                     sys.stderr.write(e.args[0] + "\n") | ||||||
|                     g.user = self.__get_user(authorization.username) |                 response: Response = Response() | ||||||
|                     return view(*args, **kwargs) |                 response.status = 401 | ||||||
|                 except UnauthorizedException as e: |                 response.headers["WWW-Authenticate"] \ | ||||||
|                     if len(e.args) > 0: |                     = self.__make_response_header(state) | ||||||
|                         sys.stderr.write(e.args[0] + "\n") |                 abort(response) | ||||||
|                     response: Response = Response() |  | ||||||
|                     response.status = 401 |  | ||||||
|                     response.headers["WWW-Authenticate"] \ |  | ||||||
|                         = self.make_response_header(state) |  | ||||||
|                     abort(response) |  | ||||||
|  |  | ||||||
|         return login_required_view |         return login_required_view | ||||||
|  |  | ||||||
|     def authenticate(self, state: AuthState) -> None: |     def __authenticate(self, state: AuthState) -> None: | ||||||
|         """Authenticate a user. |         """Authenticate a user. | ||||||
|  |  | ||||||
|         :param state: The authorization state. |         :param state: The authorization state. | ||||||
|         :return: None. |         :return: None. | ||||||
|         :raise UnauthorizedException: When the authentication failed. |         :raise UnauthorizedException: When the authentication failed. | ||||||
|         """ |         """ | ||||||
|  |         if "digest_auth_logout" in session: | ||||||
|  |             del session["digest_auth_logout"] | ||||||
|  |             raise UnauthorizedException("Logging out") | ||||||
|         authorization: Authorization = request.authorization |         authorization: Authorization = request.authorization | ||||||
|         if self.use_opaque: |         if self.use_opaque: | ||||||
|             if authorization.opaque is None: |             if authorization.opaque is None: | ||||||
| @@ -123,8 +157,8 @@ class DigestAuth: | |||||||
|             except BadData: |             except BadData: | ||||||
|                 raise UnauthorizedException("Invalid opaque") |                 raise UnauthorizedException("Invalid opaque") | ||||||
|             state.opaque = authorization.opaque |             state.opaque = authorization.opaque | ||||||
|         password_hash: t.Optional[str] = self.__get_password_hash( |         password_hash: t.Optional[str] \ | ||||||
|             authorization.username) |             = self.__get_password_hash(authorization.username) | ||||||
|         if password_hash is None: |         if password_hash is None: | ||||||
|             raise UnauthorizedException( |             raise UnauthorizedException( | ||||||
|                 f"No such user \"{authorization.username}\"") |                 f"No such user \"{authorization.username}\"") | ||||||
| @@ -147,17 +181,28 @@ class DigestAuth: | |||||||
|             state.stale = True |             state.stale = True | ||||||
|             raise UnauthorizedException("Invalid nonce") |             raise UnauthorizedException("Invalid nonce") | ||||||
|  |  | ||||||
|     def make_response_header(self, state: AuthState) -> str: |     def __make_response_header(self, state: AuthState) -> str: | ||||||
|         """Composes and returns the WWW-Authenticate response header. |         """Composes and returns the ``WWW-Authenticate`` response header. | ||||||
|  |  | ||||||
|         :param state: The authorization state. |         :param state: The authorization state. | ||||||
|         :return: The WWW-Authenticate response header. |         :return: The ``WWW-Authenticate`` response header. | ||||||
|         """ |         """ | ||||||
|         opaque: t.Optional[str] = None if not self.use_opaque else \ |  | ||||||
|             (state.opaque if state.opaque is not None |         def get_opaque() -> t.Optional[str]: | ||||||
|              else self.serializer.dumps(random(), salt="opaque")) |             """Returns the opaque value. | ||||||
|  |  | ||||||
|  |             :return: The opaque value. | ||||||
|  |             """ | ||||||
|  |             if not self.use_opaque: | ||||||
|  |                 return None | ||||||
|  |             if state.opaque is not None: | ||||||
|  |                 return state.opaque | ||||||
|  |             return self.serializer.dumps(randbits(32), salt="opaque") | ||||||
|  |  | ||||||
|  |         opaque: t.Optional[str] = get_opaque() | ||||||
|         nonce: str = self.serializer.dumps( |         nonce: str = self.serializer.dumps( | ||||||
|             random(), salt="nonce" if opaque is None else f"nonce-{opaque}") |             randbits(32), | ||||||
|  |             salt="nonce" if opaque is None else f"nonce-{opaque}") | ||||||
|  |  | ||||||
|         header: str = f"Digest realm=\"{self.realm}\"" |         header: str = f"Digest realm=\"{self.realm}\"" | ||||||
|         if len(self.domain) > 0: |         if len(self.domain) > 0: | ||||||
| @@ -177,37 +222,117 @@ class DigestAuth: | |||||||
|  |  | ||||||
|     def register_get_password(self, func: t.Callable[[str], t.Optional[str]])\ |     def register_get_password(self, func: t.Callable[[str], t.Optional[str]])\ | ||||||
|             -> None: |             -> None: | ||||||
|         """Registers the callback to obtain the password hash. |         """The decorator to register the callback to obtain the password hash. | ||||||
|  |  | ||||||
|  |         For example: | ||||||
|  |  | ||||||
|  |         :: | ||||||
|  |  | ||||||
|  |             @auth.register_get_password | ||||||
|  |             def get_password_hash(username: str) -> Optional[str]: | ||||||
|  |                 user = User.query.filter(User.username == username).first() | ||||||
|  |                 return None if user is None else user.password | ||||||
|  |  | ||||||
|         :param func: The callback that given the username, returns the password |         :param func: The callback that given the username, returns the password | ||||||
|             hash, or None if the user does not exist. |             hash, or None if the user does not exist. | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         self.__get_password_hash = func |  | ||||||
|  |         class PasswordHashGetter(BasePasswordHashGetter): | ||||||
|  |             """The base password hash getter.""" | ||||||
|  |  | ||||||
|  |             @staticmethod | ||||||
|  |             def __call__(username: str) -> t.Optional[str]: | ||||||
|  |                 """Returns the password hash of a user. | ||||||
|  |  | ||||||
|  |                 :param username: The username. | ||||||
|  |                 :return: The password hash, or None if the user does not exist. | ||||||
|  |                 """ | ||||||
|  |                 return func(username) | ||||||
|  |  | ||||||
|  |         self.__get_password_hash = PasswordHashGetter() | ||||||
|  |  | ||||||
|     def register_get_user(self, func: t.Callable[[str], t.Optional[t.Any]])\ |     def register_get_user(self, func: t.Callable[[str], t.Optional[t.Any]])\ | ||||||
|             -> None: |             -> None: | ||||||
|         """Registers the callback to obtain the user. |         """The decorator to register the callback to obtain the user. | ||||||
|  |  | ||||||
|  |         For example: | ||||||
|  |  | ||||||
|  |         :: | ||||||
|  |  | ||||||
|  |             @auth.register_get_user | ||||||
|  |             def get_user(username: str) -> Optional[User]: | ||||||
|  |                 return User.query.filter(User.username == username).first() | ||||||
|  |  | ||||||
|         :param func: The callback that given the username, returns the user, |         :param func: The callback that given the username, returns the user, | ||||||
|             or None if the user does not exist. |             or None if the user does not exist. | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         self.__get_user = func |  | ||||||
|  |         class UserGetter(BaseUserGetter): | ||||||
|  |             """The user getter.""" | ||||||
|  |  | ||||||
|  |             @staticmethod | ||||||
|  |             def __call__(username: str) -> t.Optional[t.Any]: | ||||||
|  |                 """Returns a user. | ||||||
|  |  | ||||||
|  |                 :param username: The username. | ||||||
|  |                 :return: The user, or None if the user does not exist. | ||||||
|  |                 """ | ||||||
|  |                 return func(username) | ||||||
|  |  | ||||||
|  |         self.__get_user = UserGetter() | ||||||
|  |  | ||||||
|  |     def register_on_login(self, func: t.Callable[[t.Any], None]) -> None: | ||||||
|  |         """The decorator to register the callback to run when the user logs in. | ||||||
|  |  | ||||||
|  |         For example: | ||||||
|  |  | ||||||
|  |         :: | ||||||
|  |  | ||||||
|  |             @auth.register_on_login | ||||||
|  |             def on_login(user: User) -> None: | ||||||
|  |                 user.visits = user.visits + 1 | ||||||
|  |  | ||||||
|  |         :param func: The callback given the logged-in user. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         class OnLogInCallback: | ||||||
|  |             """The callback when the user logs in.""" | ||||||
|  |  | ||||||
|  |             @staticmethod | ||||||
|  |             def __call__(user: t.Any) -> None: | ||||||
|  |                 """Runs the callback when the user logs in. | ||||||
|  |  | ||||||
|  |                 :param user: The logged-in user. | ||||||
|  |                 :return: None. | ||||||
|  |                 """ | ||||||
|  |                 func(user) | ||||||
|  |  | ||||||
|  |         self.__on_login = OnLogInCallback() | ||||||
|  |  | ||||||
|     def init_app(self, app: Flask) -> None: |     def init_app(self, app: Flask) -> None: | ||||||
|         """Initializes the Flask application. |         """Initializes the Flask application. | ||||||
|  |  | ||||||
|  |         For example: | ||||||
|  |  | ||||||
|  |         :: | ||||||
|  |  | ||||||
|  |             app: flask = Flask(__name__) | ||||||
|  |             auth: DigestAuth = DigestAuth() | ||||||
|  |             auth.realm = "My Admin" | ||||||
|  |             auth.init_app(app) | ||||||
|  |  | ||||||
|         :param app: The Flask application. |         :param app: The Flask application. | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|  |         app.digest_auth = self | ||||||
|  |         self.app = app | ||||||
|  |  | ||||||
|         try: |         if hasattr(app, "login_manager"): | ||||||
|             from flask_login import LoginManager, login_user |             from flask_login import LoginManager, login_user | ||||||
|  |  | ||||||
|             if not hasattr(app, "login_manager"): |  | ||||||
|                 raise AttributeError( |  | ||||||
|                     "Please run the Flask-Login init-app() first") |  | ||||||
|             login_manager: LoginManager = getattr(app, "login_manager") |             login_manager: LoginManager = getattr(app, "login_manager") | ||||||
|  |  | ||||||
|             @login_manager.unauthorized_handler |             @login_manager.unauthorized_handler | ||||||
| @@ -219,7 +344,7 @@ class DigestAuth: | |||||||
|                 response: Response = Response() |                 response: Response = Response() | ||||||
|                 response.status = 401 |                 response.status = 401 | ||||||
|                 response.headers["WWW-Authenticate"] \ |                 response.headers["WWW-Authenticate"] \ | ||||||
|                     = self.make_response_header(g.digest_auth_state) |                     = self.__make_response_header(g.digest_auth_state) | ||||||
|                 abort(response) |                 abort(response) | ||||||
|  |  | ||||||
|             @login_manager.request_loader |             @login_manager.request_loader | ||||||
| @@ -238,19 +363,43 @@ class DigestAuth: | |||||||
|                     if authorization.type != "digest": |                     if authorization.type != "digest": | ||||||
|                         raise UnauthorizedException( |                         raise UnauthorizedException( | ||||||
|                             "Not an HTTP digest authorization") |                             "Not an HTTP digest authorization") | ||||||
|                     self.authenticate(g.digest_auth_state) |                     self.__authenticate(g.digest_auth_state) | ||||||
|                     user = login_manager.user_callback( |                     user = login_manager.user_callback( | ||||||
|                         authorization.username) |                         authorization.username) | ||||||
|                     login_user(user) |                     login_user(user) | ||||||
|  |                     self.__on_login(user) | ||||||
|                     return user |                     return user | ||||||
|                 except UnauthorizedException as e: |                 except UnauthorizedException as e: | ||||||
|                     if str(e) != "": |                     if str(e) != "": | ||||||
|                         app.logger.warning(str(e)) |                         app.logger.warning(str(e)) | ||||||
|                     return None |                     return None | ||||||
|  |  | ||||||
|  |     def logout(self) -> None: | ||||||
|  |         """Logs out the user. | ||||||
|  |         This actually causes the next authentication to fail, which forces | ||||||
|  |         the browser to ask the user for the username and password again. | ||||||
|  |  | ||||||
|  |         For example: | ||||||
|  |  | ||||||
|  |         :: | ||||||
|  |  | ||||||
|  |             @app.post("/logout") | ||||||
|  |             @auth.login_required | ||||||
|  |             def logout(): | ||||||
|  |                 auth.logout() | ||||||
|  |                 return redirect(request.form.get("next")) | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         if "user" in session: | ||||||
|  |             del session["user"] | ||||||
|  |         try: | ||||||
|  |             if hasattr(self.app, "login_manager"): | ||||||
|  |                 from flask_login import logout_user | ||||||
|  |                 logout_user() | ||||||
|         except ModuleNotFoundError: |         except ModuleNotFoundError: | ||||||
|             raise ModuleNotFoundError( |             pass | ||||||
|                 "init_app() is only for Flask-Login integration") |         session["digest_auth_logout"] = True | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthState: | class AuthState: | ||||||
| @@ -260,3 +409,52 @@ class AuthState: | |||||||
|         """Constructs the authorization state.""" |         """Constructs the authorization state.""" | ||||||
|         self.opaque: t.Optional[str] = None |         self.opaque: t.Optional[str] = None | ||||||
|         self.stale: t.Optional[bool] = None |         self.stale: t.Optional[bool] = None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class UnauthorizedException(Exception): | ||||||
|  |     """The exception thrown when the authentication is failed.""" | ||||||
|  |     pass | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class BasePasswordHashGetter: | ||||||
|  |     """The base password hash getter.""" | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def __call__(username: str) -> t.Optional[str]: | ||||||
|  |         """Returns the password hash of a user. | ||||||
|  |  | ||||||
|  |         :param username: The username. | ||||||
|  |         :return: The password hash, or None if the user does not exist. | ||||||
|  |         :raise UnboundLocalError: When the password hash getter function is | ||||||
|  |             not registered yet. | ||||||
|  |         """ | ||||||
|  |         raise UnboundLocalError("The function to return the password hash" | ||||||
|  |                                 " was not registered yet.") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class BaseUserGetter: | ||||||
|  |     """The base user getter.""" | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def __call__(username: str) -> t.Optional[t.Any]: | ||||||
|  |         """Returns a user. | ||||||
|  |  | ||||||
|  |         :param username: The username. | ||||||
|  |         :return: The user, or None if the user does not exist. | ||||||
|  |         :raise UnboundLocalError: When the user getter function is not | ||||||
|  |             registered yet. | ||||||
|  |         """ | ||||||
|  |         raise UnboundLocalError("The function to return the user" | ||||||
|  |                                 " was not registered yet.") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class BaseOnLogInCallback: | ||||||
|  |     """The base callback when the user logs in.""" | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def __call__(user: t.Any) -> None: | ||||||
|  |         """Runs the callback when the user logs in. | ||||||
|  |  | ||||||
|  |         :param user: The logged-in user. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|   | |||||||
| @@ -1,25 +0,0 @@ | |||||||
| # The Flask HTTP Digest Authentication Project. |  | ||||||
| # Author: imacat@mail.imacat.idv.tw (imacat), 2022/11/3 |  | ||||||
|  |  | ||||||
| #  Copyright (c) 2022 imacat. |  | ||||||
| # |  | ||||||
| #  Licensed under the Apache License, Version 2.0 (the "License"); |  | ||||||
| #  you may not use this file except in compliance with the License. |  | ||||||
| #  You may obtain a copy of the License at |  | ||||||
| # |  | ||||||
| #      http://www.apache.org/licenses/LICENSE-2.0 |  | ||||||
| # |  | ||||||
| #  Unless required by applicable law or agreed to in writing, software |  | ||||||
| #  distributed under the License is distributed on an "AS IS" BASIS, |  | ||||||
| #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |  | ||||||
| #  See the License for the specific language governing permissions and |  | ||||||
| #  limitations under the License. |  | ||||||
|  |  | ||||||
| """The exception. |  | ||||||
|  |  | ||||||
| """ |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class UnauthorizedException(Exception): |  | ||||||
|     """The exception thrown when the authentication is failed.""" |  | ||||||
|     pass |  | ||||||
| @@ -29,15 +29,62 @@ from flask_digest_auth.algo import calc_response, make_password_hash | |||||||
|  |  | ||||||
|  |  | ||||||
| class Client(WerkzeugClient): | class Client(WerkzeugClient): | ||||||
|     """The test client with HTTP digest authentication enabled.""" |     """The test client with HTTP digest authentication enabled. | ||||||
|  |  | ||||||
|  |     For unittest example: | ||||||
|  |  | ||||||
|  |     :: | ||||||
|  |  | ||||||
|  |         class MyTestCase(flask_testing.TestCase): | ||||||
|  |  | ||||||
|  |             def create_app(self): | ||||||
|  |                 app: Flask = create_app({ | ||||||
|  |                     "SECRET_KEY": token_urlsafe(32), | ||||||
|  |                     "TESTING": True | ||||||
|  |                 }) | ||||||
|  |                 app.test_client_class = Client | ||||||
|  |                 return app | ||||||
|  |  | ||||||
|  |             def test_admin(self): | ||||||
|  |                 response = self.client.get("/admin") | ||||||
|  |                 self.assertEqual(response.status_code, 401) | ||||||
|  |                 response = self.client.get( | ||||||
|  |                     "/admin", digest_auth=("my_name", "my_pass")) | ||||||
|  |                 self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |     For pytest example: | ||||||
|  |  | ||||||
|  |     :: | ||||||
|  |  | ||||||
|  |         @pytest.fixture() | ||||||
|  |         def app(): | ||||||
|  |             app: Flask = create_app({ | ||||||
|  |                 "SECRET_KEY": token_urlsafe(32), | ||||||
|  |                 "TESTING": True | ||||||
|  |             }) | ||||||
|  |             app.test_client_class = Client | ||||||
|  |             yield app | ||||||
|  |  | ||||||
|  |         @pytest.fixture() | ||||||
|  |         def client(app): | ||||||
|  |             return app.test_client() | ||||||
|  |  | ||||||
|  |         def test_admin(app: Flask, client: Client): | ||||||
|  |             with app.app_context(): | ||||||
|  |                 response = self.client.get("/admin") | ||||||
|  |                 assert response.status_code == 401 | ||||||
|  |                 response = self.client.get( | ||||||
|  |                     "/admin", digest_auth=("my_name", "my_pass")) | ||||||
|  |                 assert response.status_code == 200 | ||||||
|  |     """ | ||||||
|  |  | ||||||
|     def open(self, *args, digest_auth: t.Optional[t.Tuple[str, str]] = None, |     def open(self, *args, digest_auth: t.Optional[t.Tuple[str, str]] = None, | ||||||
|              **kwargs) -> TestResponse: |              **kwargs) -> TestResponse: | ||||||
|         """Opens a request. |         """Opens a request. | ||||||
|  |  | ||||||
|         :param args: The arguments. |         :param args: The arguments. | ||||||
|         :param digest_auth: The username and password for the HTTP digest |         :param digest_auth: A tuple of the username and password for the HTTP | ||||||
|             authentication. |             digest authentication. | ||||||
|         :param kwargs: The keyword arguments. |         :param kwargs: The keyword arguments. | ||||||
|         :return: The response. |         :return: The response. | ||||||
|         """ |         """ | ||||||
| @@ -49,26 +96,25 @@ class Client(WerkzeugClient): | |||||||
|             return response |             return response | ||||||
|         if hasattr(g, "_login_user"): |         if hasattr(g, "_login_user"): | ||||||
|             delattr(g, "_login_user") |             delattr(g, "_login_user") | ||||||
|         auth_data: Authorization = self.__class__.__make_authorization( |         auth_data: Authorization = self.__class__.make_authorization( | ||||||
|             www_authenticate, args[0], digest_auth[0], digest_auth[1]) |             www_authenticate, args[0], digest_auth[0], digest_auth[1]) | ||||||
|         response = super(Client, self).open(*args, auth=auth_data, **kwargs) |         response = super(Client, self).open(*args, auth=auth_data, **kwargs) | ||||||
|         return response |         return response | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def __make_authorization(www_authenticate: WWWAuthenticate, uri: str, |     def make_authorization(www_authenticate: WWWAuthenticate, uri: str, | ||||||
|                              username: str, password: str) -> Authorization: |                            username: str, password: str) -> Authorization: | ||||||
|         """Composes and returns the request authorization. |         """Composes and returns the request authorization. | ||||||
|  |  | ||||||
|         :param www_authenticate: The WWW-Authenticate response. |         :param www_authenticate: The ``WWW-Authenticate`` response. | ||||||
|         :param uri: The request URI. |         :param uri: The request URI. | ||||||
|         :param username: The username. |         :param username: The username. | ||||||
|         :param password: The password. |         :param password: The password. | ||||||
|         :return: The request authorization. |         :return: The request authorization. | ||||||
|         """ |         """ | ||||||
|         qop: t.Optional[t.Literal["auth", "auth-int"]] = None |         qop: t.Optional[t.Literal["auth", "auth-int"]] = None | ||||||
|         if www_authenticate.qop is not None: |         if www_authenticate.qop is not None and "auth" in www_authenticate.qop: | ||||||
|             if "auth" in www_authenticate.qop: |             qop = "auth" | ||||||
|                 qop = "auth" |  | ||||||
|  |  | ||||||
|         cnonce: t.Optional[str] = None |         cnonce: t.Optional[str] = None | ||||||
|         if qop is not None or www_authenticate.algorithm == "MD5-sess": |         if qop is not None or www_authenticate.algorithm == "MD5-sess": | ||||||
|   | |||||||
| @@ -20,10 +20,10 @@ | |||||||
| """ | """ | ||||||
| import typing as t | import typing as t | ||||||
| from secrets import token_urlsafe | from secrets import token_urlsafe | ||||||
| from types import SimpleNamespace |  | ||||||
|  |  | ||||||
| from flask import Response, Flask, g | from flask import Response, Flask, g, redirect, request | ||||||
| from flask_testing import TestCase | from flask_testing import TestCase | ||||||
|  | from werkzeug.datastructures import WWWAuthenticate, Authorization | ||||||
|  |  | ||||||
| from flask_digest_auth import DigestAuth, make_password_hash, Client | from flask_digest_auth import DigestAuth, make_password_hash, Client | ||||||
|  |  | ||||||
| @@ -32,6 +32,21 @@ _USERNAME: str = "Mufasa" | |||||||
| _PASSWORD: str = "Circle Of Life" | _PASSWORD: str = "Circle Of Life" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class User: | ||||||
|  |     """A dummy user""" | ||||||
|  |  | ||||||
|  |     def __init__(self, username: str, password: str): | ||||||
|  |         """Constructs a dummy user. | ||||||
|  |  | ||||||
|  |         :param username: The username. | ||||||
|  |         :param password: The clear-text password. | ||||||
|  |         """ | ||||||
|  |         self.username: str = username | ||||||
|  |         self.password_hash: str = make_password_hash( | ||||||
|  |             _REALM, username, password) | ||||||
|  |         self.visits: int = 0 | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthenticationTestCase(TestCase): | class AuthenticationTestCase(TestCase): | ||||||
|     """The test case for the HTTP digest authentication.""" |     """The test case for the HTTP digest authentication.""" | ||||||
|  |  | ||||||
| @@ -48,8 +63,9 @@ class AuthenticationTestCase(TestCase): | |||||||
|         app.test_client_class = Client |         app.test_client_class = Client | ||||||
|  |  | ||||||
|         auth: DigestAuth = DigestAuth(realm=_REALM) |         auth: DigestAuth = DigestAuth(realm=_REALM) | ||||||
|         user_db: t.Dict[str, str] \ |         auth.init_app(app) | ||||||
|             = {_USERNAME: make_password_hash(_REALM, _USERNAME, _PASSWORD)} |         self.user: User = User(_USERNAME, _PASSWORD) | ||||||
|  |         user_db: t.Dict[str, User] = {_USERNAME: self.user} | ||||||
|  |  | ||||||
|         @auth.register_get_password |         @auth.register_get_password | ||||||
|         def get_password_hash(username: str) -> t.Optional[str]: |         def get_password_hash(username: str) -> t.Optional[str]: | ||||||
| @@ -58,7 +74,8 @@ class AuthenticationTestCase(TestCase): | |||||||
|             :param username: The username. |             :param username: The username. | ||||||
|             :return: The password hash, or None if the user does not exist. |             :return: The password hash, or None if the user does not exist. | ||||||
|             """ |             """ | ||||||
|             return user_db[username] if username in user_db else None |             return user_db[username].password_hash if username in user_db \ | ||||||
|  |                 else None | ||||||
|  |  | ||||||
|         @auth.register_get_user |         @auth.register_get_user | ||||||
|         def get_user(username: str) -> t.Optional[t.Any]: |         def get_user(username: str) -> t.Optional[t.Any]: | ||||||
| @@ -67,27 +84,45 @@ class AuthenticationTestCase(TestCase): | |||||||
|             :param username: The username. |             :param username: The username. | ||||||
|             :return: The user, or None if the user does not exist. |             :return: The user, or None if the user does not exist. | ||||||
|             """ |             """ | ||||||
|             return SimpleNamespace(username=username) if username in user_db \ |             return user_db[username] if username in user_db else None | ||||||
|                 else None |  | ||||||
|  |  | ||||||
|         @app.get("/login-required-1/auth", endpoint="auth-1") |         @auth.register_on_login | ||||||
|  |         def on_login(user: User): | ||||||
|  |             """The callback when the user logs in. | ||||||
|  |  | ||||||
|  |             :param user: The logged-in user. | ||||||
|  |             :return: None. | ||||||
|  |             """ | ||||||
|  |             user.visits = user.visits + 1 | ||||||
|  |  | ||||||
|  |         @app.get("/admin-1/auth", endpoint="admin-1") | ||||||
|         @auth.login_required |         @auth.login_required | ||||||
|         def login_required_1() -> str: |         def admin_1() -> str: | ||||||
|             """The first dummy view. |             """The first administration section. | ||||||
|  |  | ||||||
|             :return: The response. |             :return: The response. | ||||||
|             """ |             """ | ||||||
|             return f"Hello, {g.user.username}! #1" |             return f"Hello, {g.user.username}! #1" | ||||||
|  |  | ||||||
|         @app.get("/login-required-2/auth", endpoint="auth-2") |         @app.get("/admin-2/auth", endpoint="admin-2") | ||||||
|         @auth.login_required |         @auth.login_required | ||||||
|         def login_required_2() -> str: |         def admin_2() -> str: | ||||||
|             """The second dummy view. |             """The second administration section. | ||||||
|  |  | ||||||
|             :return: The response. |             :return: The response. | ||||||
|             """ |             """ | ||||||
|             return f"Hello, {g.user.username}! #2" |             return f"Hello, {g.user.username}! #2" | ||||||
|  |  | ||||||
|  |         @app.post("/logout", endpoint="logout") | ||||||
|  |         @auth.login_required | ||||||
|  |         def logout() -> redirect: | ||||||
|  |             """Logs out the user. | ||||||
|  |  | ||||||
|  |             :return: The response. | ||||||
|  |             """ | ||||||
|  |             auth.logout() | ||||||
|  |             return redirect(request.form.get("next")) | ||||||
|  |  | ||||||
|         return app |         return app | ||||||
|  |  | ||||||
|     def test_auth(self) -> None: |     def test_auth(self) -> None: | ||||||
| @@ -95,14 +130,92 @@ class AuthenticationTestCase(TestCase): | |||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         response: Response = self.client.get(self.app.url_for("auth-1")) |         response: Response = self.client.get(self.app.url_for("admin-1")) | ||||||
|         self.assertEqual(response.status_code, 401) |         self.assertEqual(response.status_code, 401) | ||||||
|         response = self.client.get( |         response = self.client.get( | ||||||
|             self.app.url_for("auth-1"), digest_auth=(_USERNAME, _PASSWORD)) |             self.app.url_for("admin-1"), digest_auth=(_USERNAME, _PASSWORD)) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertEqual(response.data.decode("UTF-8"), |         self.assertEqual(response.data.decode("UTF-8"), | ||||||
|                          f"Hello, {_USERNAME}! #1") |                          f"Hello, {_USERNAME}! #1") | ||||||
|         response: Response = self.client.get(self.app.url_for("auth-2")) |         response: Response = self.client.get(self.app.url_for("admin-2")) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertEqual(response.data.decode("UTF-8"), |         self.assertEqual(response.data.decode("UTF-8"), | ||||||
|                          f"Hello, {_USERNAME}! #2") |                          f"Hello, {_USERNAME}! #2") | ||||||
|  |         self.assertEqual(self.user.visits, 1) | ||||||
|  |  | ||||||
|  |     def test_stale_opaque(self) -> None: | ||||||
|  |         """Tests the stale and opaque value. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         admin_uri: str = self.app.url_for("admin-1") | ||||||
|  |         response: Response | ||||||
|  |         www_authenticate: WWWAuthenticate | ||||||
|  |         auth_data: Authorization | ||||||
|  |  | ||||||
|  |         response = super(Client, self.client).get(admin_uri) | ||||||
|  |         self.assertEqual(response.status_code, 401) | ||||||
|  |         www_authenticate = response.www_authenticate | ||||||
|  |         self.assertEqual(www_authenticate.type, "digest") | ||||||
|  |         self.assertEqual(www_authenticate.stale, None) | ||||||
|  |         opaque: str = www_authenticate.opaque | ||||||
|  |  | ||||||
|  |         www_authenticate.nonce = "bad" | ||||||
|  |         auth_data = Client.make_authorization( | ||||||
|  |             www_authenticate, admin_uri, _USERNAME, _PASSWORD) | ||||||
|  |         response = super(Client, self.client).get(admin_uri, auth=auth_data) | ||||||
|  |         self.assertEqual(response.status_code, 401) | ||||||
|  |         www_authenticate = response.www_authenticate | ||||||
|  |         self.assertEqual(www_authenticate.stale, True) | ||||||
|  |         self.assertEqual(www_authenticate.opaque, opaque) | ||||||
|  |  | ||||||
|  |         auth_data = Client.make_authorization( | ||||||
|  |             www_authenticate, admin_uri, _USERNAME, _PASSWORD + "2") | ||||||
|  |         response = super(Client, self.client).get(admin_uri, auth=auth_data) | ||||||
|  |         self.assertEqual(response.status_code, 401) | ||||||
|  |         www_authenticate = response.www_authenticate | ||||||
|  |         self.assertEqual(www_authenticate.stale, False) | ||||||
|  |         self.assertEqual(www_authenticate.opaque, opaque) | ||||||
|  |  | ||||||
|  |         auth_data = Client.make_authorization( | ||||||
|  |             www_authenticate, admin_uri, _USERNAME, _PASSWORD) | ||||||
|  |         response = super(Client, self.client).get(admin_uri, auth=auth_data) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |     def test_logout(self) -> None: | ||||||
|  |         """Tests the logging out. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         admin_uri: str = self.app.url_for("admin-1") | ||||||
|  |         logout_uri: str = self.app.url_for("logout") | ||||||
|  |         response: Response | ||||||
|  |  | ||||||
|  |         response = self.client.get(admin_uri) | ||||||
|  |         self.assertEqual(response.status_code, 401) | ||||||
|  |  | ||||||
|  |         response = self.client.get(admin_uri, | ||||||
|  |                                    digest_auth=(_USERNAME, _PASSWORD)) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         response = self.client.get(admin_uri) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         response = self.client.post(logout_uri, data={"next": admin_uri}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.location, admin_uri) | ||||||
|  |  | ||||||
|  |         response = self.client.get(admin_uri) | ||||||
|  |         self.assertEqual(response.status_code, 401) | ||||||
|  |  | ||||||
|  |         response = self.client.get(admin_uri, | ||||||
|  |                                    digest_auth=(_USERNAME, _PASSWORD)) | ||||||
|  |         self.assertEqual(response.status_code, 401) | ||||||
|  |  | ||||||
|  |         response = self.client.get(admin_uri, | ||||||
|  |                                    digest_auth=(_USERNAME, _PASSWORD)) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         response = self.client.get(admin_uri) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         self.assertEqual(self.user.visits, 2) | ||||||
|   | |||||||
| @@ -21,8 +21,9 @@ | |||||||
| import typing as t | import typing as t | ||||||
| from secrets import token_urlsafe | from secrets import token_urlsafe | ||||||
|  |  | ||||||
| from flask import Response, Flask | from flask import Response, Flask, g, redirect, request | ||||||
| from flask_testing import TestCase | from flask_testing import TestCase | ||||||
|  | from werkzeug.datastructures import WWWAuthenticate, Authorization | ||||||
|  |  | ||||||
| from flask_digest_auth import DigestAuth, make_password_hash, Client | from flask_digest_auth import DigestAuth, make_password_hash, Client | ||||||
|  |  | ||||||
| @@ -32,8 +33,18 @@ _PASSWORD: str = "Circle Of Life" | |||||||
|  |  | ||||||
|  |  | ||||||
| class User: | class User: | ||||||
|     def __init__(self, username: str): |     """A dummy user.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, username: str, password: str): | ||||||
|  |         """Constructs a dummy user. | ||||||
|  |  | ||||||
|  |         :param username: The username. | ||||||
|  |         :param password: The clear-text password. | ||||||
|  |         """ | ||||||
|         self.username: str = username |         self.username: str = username | ||||||
|  |         self.password_hash: str = make_password_hash( | ||||||
|  |             _REALM, username, password) | ||||||
|  |         self.visits: int = 0 | ||||||
|         self.is_authenticated: bool = True |         self.is_authenticated: bool = True | ||||||
|         self.is_active: bool = True |         self.is_active: bool = True | ||||||
|         self.is_anonymous: bool = False |         self.is_anonymous: bool = False | ||||||
| @@ -75,8 +86,8 @@ class FlaskLoginTestCase(TestCase): | |||||||
|         auth: DigestAuth = DigestAuth(realm=_REALM) |         auth: DigestAuth = DigestAuth(realm=_REALM) | ||||||
|         auth.init_app(app) |         auth.init_app(app) | ||||||
|  |  | ||||||
|         user_db: t.Dict[str, str] \ |         self.user: User = User(_USERNAME, _PASSWORD) | ||||||
|             = {_USERNAME: make_password_hash(_REALM, _USERNAME, _PASSWORD)} |         user_db: t.Dict[str, User] = {_USERNAME: self.user} | ||||||
|  |  | ||||||
|         @auth.register_get_password |         @auth.register_get_password | ||||||
|         def get_password_hash(username: str) -> t.Optional[str]: |         def get_password_hash(username: str) -> t.Optional[str]: | ||||||
| @@ -85,7 +96,17 @@ class FlaskLoginTestCase(TestCase): | |||||||
|             :param username: The username. |             :param username: The username. | ||||||
|             :return: The password hash, or None if the user does not exist. |             :return: The password hash, or None if the user does not exist. | ||||||
|             """ |             """ | ||||||
|             return user_db[username] if username in user_db else None |             return user_db[username].password_hash if username in user_db \ | ||||||
|  |                 else None | ||||||
|  |  | ||||||
|  |         @auth.register_on_login | ||||||
|  |         def on_login(user: User): | ||||||
|  |             """The callback when the user logs in. | ||||||
|  |  | ||||||
|  |             :param user: The logged-in user. | ||||||
|  |             :return: None. | ||||||
|  |             """ | ||||||
|  |             user.visits = user.visits + 1 | ||||||
|  |  | ||||||
|         @login_manager.user_loader |         @login_manager.user_loader | ||||||
|         def load_user(user_id: str) -> t.Optional[User]: |         def load_user(user_id: str) -> t.Optional[User]: | ||||||
| @@ -94,25 +115,35 @@ class FlaskLoginTestCase(TestCase): | |||||||
|             :param user_id: The username. |             :param user_id: The username. | ||||||
|             :return: The user, or None if the user does not exist. |             :return: The user, or None if the user does not exist. | ||||||
|             """ |             """ | ||||||
|             return User(user_id) if user_id in user_db else None |             return user_db[user_id] if user_id in user_db else None | ||||||
|  |  | ||||||
|         @app.get("/login-required-1/auth", endpoint="auth-1") |         @app.get("/admin-1/auth", endpoint="admin-1") | ||||||
|         @flask_login.login_required |         @flask_login.login_required | ||||||
|         def login_required_1() -> str: |         def admin_1() -> str: | ||||||
|             """The first dummy view. |             """The first administration section. | ||||||
|  |  | ||||||
|             :return: The response. |             :return: The response. | ||||||
|             """ |             """ | ||||||
|             return f"Hello, {flask_login.current_user.username}! #1" |             return f"Hello, {flask_login.current_user.get_id()}! #1" | ||||||
|  |  | ||||||
|         @app.get("/login-required-2/auth", endpoint="auth-2") |         @app.get("/admin-2/auth", endpoint="admin-2") | ||||||
|         @flask_login.login_required |         @flask_login.login_required | ||||||
|         def login_required_2() -> str: |         def admin_2() -> str: | ||||||
|             """The second dummy view. |             """The second administration section. | ||||||
|  |  | ||||||
|             :return: The response. |             :return: The response. | ||||||
|             """ |             """ | ||||||
|             return f"Hello, {flask_login.current_user.username}! #2" |             return f"Hello, {flask_login.current_user.get_id()}! #2" | ||||||
|  |  | ||||||
|  |         @app.post("/logout", endpoint="logout") | ||||||
|  |         @flask_login.login_required | ||||||
|  |         def logout() -> redirect: | ||||||
|  |             """Logs out the user. | ||||||
|  |  | ||||||
|  |             :return: The response. | ||||||
|  |             """ | ||||||
|  |             auth.logout() | ||||||
|  |             return redirect(request.form.get("next")) | ||||||
|  |  | ||||||
|         return app |         return app | ||||||
|  |  | ||||||
| @@ -122,16 +153,106 @@ class FlaskLoginTestCase(TestCase): | |||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         if not self.has_flask_login: |         if not self.has_flask_login: | ||||||
|             self.skipTest("Skipped testing Flask-Login integration without it.") |             self.skipTest("Skipped without Flask-Login.") | ||||||
|  |  | ||||||
|         response: Response = self.client.get(self.app.url_for("auth-1")) |         response: Response = self.client.get(self.app.url_for("admin-1")) | ||||||
|         self.assertEqual(response.status_code, 401) |         self.assertEqual(response.status_code, 401) | ||||||
|         response = self.client.get( |         response = self.client.get( | ||||||
|             self.app.url_for("auth-1"), digest_auth=(_USERNAME, _PASSWORD)) |             self.app.url_for("admin-1"), digest_auth=(_USERNAME, _PASSWORD)) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertEqual(response.data.decode("UTF-8"), |         self.assertEqual(response.data.decode("UTF-8"), | ||||||
|                          f"Hello, {_USERNAME}! #1") |                          f"Hello, {_USERNAME}! #1") | ||||||
|         response: Response = self.client.get(self.app.url_for("auth-2")) |         response: Response = self.client.get(self.app.url_for("admin-2")) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertEqual(response.data.decode("UTF-8"), |         self.assertEqual(response.data.decode("UTF-8"), | ||||||
|                          f"Hello, {_USERNAME}! #2") |                          f"Hello, {_USERNAME}! #2") | ||||||
|  |         self.assertEqual(self.user.visits, 1) | ||||||
|  |  | ||||||
|  |     def test_stale_opaque(self) -> None: | ||||||
|  |         """Tests the stale and opaque value. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         if not self.has_flask_login: | ||||||
|  |             self.skipTest("Skipped without Flask-Login.") | ||||||
|  |  | ||||||
|  |         admin_uri: str = self.app.url_for("admin-1") | ||||||
|  |         response: Response | ||||||
|  |         www_authenticate: WWWAuthenticate | ||||||
|  |         auth_data: Authorization | ||||||
|  |  | ||||||
|  |         response = super(Client, self.client).get(admin_uri) | ||||||
|  |         self.assertEqual(response.status_code, 401) | ||||||
|  |         www_authenticate = response.www_authenticate | ||||||
|  |         self.assertEqual(www_authenticate.type, "digest") | ||||||
|  |         self.assertEqual(www_authenticate.stale, None) | ||||||
|  |         opaque: str = www_authenticate.opaque | ||||||
|  |  | ||||||
|  |         if hasattr(g, "_login_user"): | ||||||
|  |             delattr(g, "_login_user") | ||||||
|  |         www_authenticate.nonce = "bad" | ||||||
|  |         auth_data = Client.make_authorization( | ||||||
|  |             www_authenticate, admin_uri, _USERNAME, _PASSWORD) | ||||||
|  |         response = super(Client, self.client).get(admin_uri, auth=auth_data) | ||||||
|  |         self.assertEqual(response.status_code, 401) | ||||||
|  |         www_authenticate = response.www_authenticate | ||||||
|  |         self.assertEqual(www_authenticate.stale, True) | ||||||
|  |         self.assertEqual(www_authenticate.opaque, opaque) | ||||||
|  |  | ||||||
|  |         if hasattr(g, "_login_user"): | ||||||
|  |             delattr(g, "_login_user") | ||||||
|  |         auth_data = Client.make_authorization( | ||||||
|  |             www_authenticate, admin_uri, _USERNAME, _PASSWORD + "2") | ||||||
|  |         response = super(Client, self.client).get(admin_uri, auth=auth_data) | ||||||
|  |         self.assertEqual(response.status_code, 401) | ||||||
|  |         www_authenticate = response.www_authenticate | ||||||
|  |         self.assertEqual(www_authenticate.stale, False) | ||||||
|  |         self.assertEqual(www_authenticate.opaque, opaque) | ||||||
|  |  | ||||||
|  |         if hasattr(g, "_login_user"): | ||||||
|  |             delattr(g, "_login_user") | ||||||
|  |         auth_data = Client.make_authorization( | ||||||
|  |             www_authenticate, admin_uri, _USERNAME, _PASSWORD) | ||||||
|  |         response = super(Client, self.client).get(admin_uri, auth=auth_data) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |     def test_logout(self) -> None: | ||||||
|  |         """Tests the logging out. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         if not self.has_flask_login: | ||||||
|  |             self.skipTest("Skipped without Flask-Login.") | ||||||
|  |  | ||||||
|  |         admin_uri: str = self.app.url_for("admin-1") | ||||||
|  |         logout_uri: str = self.app.url_for("logout") | ||||||
|  |         response: Response | ||||||
|  |  | ||||||
|  |         response = self.client.get(admin_uri) | ||||||
|  |         self.assertEqual(response.status_code, 401) | ||||||
|  |  | ||||||
|  |         response = self.client.get(admin_uri, | ||||||
|  |                                    digest_auth=(_USERNAME, _PASSWORD)) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         response = self.client.get(admin_uri) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         response = self.client.post(logout_uri, data={"next": admin_uri}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.location, admin_uri) | ||||||
|  |  | ||||||
|  |         response = self.client.get(admin_uri) | ||||||
|  |         self.assertEqual(response.status_code, 401) | ||||||
|  |  | ||||||
|  |         response = self.client.get(admin_uri, | ||||||
|  |                                    digest_auth=(_USERNAME, _PASSWORD)) | ||||||
|  |         self.assertEqual(response.status_code, 401) | ||||||
|  |  | ||||||
|  |         response = self.client.get(admin_uri, | ||||||
|  |                                    digest_auth=(_USERNAME, _PASSWORD)) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         response = self.client.get(admin_uri) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         self.assertEqual(self.user.visits, 2) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user