Compare commits

..

No commits in common. "2425d99492793dad205369e6db37c5f9355c85f6" and "cb3e313e21b891038a443643013e16310f2ba5db" have entirely different histories.

5 changed files with 52 additions and 157 deletions

View File

@ -12,10 +12,6 @@ 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
@ -32,46 +28,6 @@ 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.
Features
--------
There are a couple of Flask HTTP digest authentication
implementations. Flask-Digest-Auth has the following features:
Flask-Login Integration
#######################
Flask-Digest-Auth features Flask-Login integration. The views
can be totally independent with the actual authentication mechanism.
You can write a Flask module that requires log in, without specify
the actual authentication mechanism. The application can specify
either HTTP Digest Authentication, or the log in forms, as needed.
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 Out Support
###############
Flask-Digest-Auth supports log out. The user will be prompted for
new username and password.
Log In Bookkeeping
##################
You can register a callback to run when the user logs in.
.. _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
@ -342,19 +298,6 @@ the next browser automatic authentication to fail, forcing the browser
to ask the user for the username and password again.
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
Writing Tests
=============

View File

@ -17,7 +17,7 @@
[metadata]
name = flask-digest-auth
version = 0.2.0
version = 0.1.1
author = imacat
author_email = imacat@mail.imacat.idv.tw
description = The Flask HTTP Digest Authentication project.

View File

@ -23,6 +23,7 @@ from __future__ import annotations
import sys
import typing as t
from abc import ABC, abstractmethod
from functools import wraps
from random import random
from secrets import token_urlsafe
@ -35,10 +36,11 @@ from flask_digest_auth.algo import calc_response
from flask_digest_auth.exception import UnauthorizedException
class BasePasswordHashGetter:
class BasePasswordHashGetter(ABC):
"""The base password hash getter."""
@staticmethod
@abstractmethod
def __call__(username: str) -> t.Optional[str]:
"""Returns the password hash of a user.
@ -47,14 +49,13 @@ class BasePasswordHashGetter:
: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:
class BaseUserGetter(ABC):
"""The base user getter."""
@staticmethod
@abstractmethod
def __call__(username: str) -> t.Optional[t.Any]:
"""Returns a user.
@ -63,20 +64,6 @@ class BaseUserGetter:
: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.
"""
class DigestAuth:
@ -96,10 +83,41 @@ class DigestAuth:
self.domain: t.List[str] = []
self.qop: t.List[str] = ["auth", "auth-int"]
self.app: t.Optional[Flask] = None
class DummyPasswordHashGetter(BasePasswordHashGetter):
"""The dummy 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.")
self.__get_password_hash: BasePasswordHashGetter \
= BasePasswordHashGetter()
self.__get_user: BaseUserGetter = BaseUserGetter()
self.__on_login: BaseOnLogInCallback = BaseOnLogInCallback()
= DummyPasswordHashGetter()
class DummyUserGetter(BaseUserGetter):
"""The dummy 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.")
self.__get_user: BaseUserGetter = DummyUserGetter()
def login_required(self, view) -> t.Callable:
"""The view decorator for HTTP digest authentication.
@ -138,9 +156,7 @@ class DigestAuth:
"Not an HTTP digest authorization")
self.authenticate(state)
session["user"] = authorization.username
user = self.__get_user(authorization.username)
g.user = user
self.__on_login(user)
g.user = self.__get_user(authorization.username)
return view(*args, **kwargs)
except UnauthorizedException as e:
if len(e.args) > 0:
@ -272,27 +288,6 @@ class DigestAuth:
self.__get_user = UserGetter()
def register_on_login(self, func: t.Callable[[t.Any], None]) -> None:
"""Registers the callback when the user logs in.
: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:
"""Initializes the Flask application.
@ -339,7 +334,6 @@ class DigestAuth:
user = login_manager.user_callback(
authorization.username)
login_user(user)
self.__on_login(user)
return user
except UnauthorizedException as e:
if str(e) != "":

View File

@ -20,6 +20,7 @@
"""
import typing as t
from secrets import token_urlsafe
from types import SimpleNamespace
from flask import Response, Flask, g, redirect, request
from flask_testing import TestCase
@ -32,20 +33,6 @@ _USERNAME: str = "Mufasa"
_PASSWORD: str = "Circle Of Life"
class User:
"""A dummy user"""
def __init__(self, username: str, password_hash: str):
"""Constructs a dummy user.
:param username: The username.
:param password_hash: The password hash.
"""
self.username: str = username
self.password_hash: str = password_hash
self.visits: int = 0
class AuthenticationTestCase(TestCase):
"""The test case for the HTTP digest authentication."""
@ -63,9 +50,8 @@ class AuthenticationTestCase(TestCase):
auth: DigestAuth = DigestAuth(realm=_REALM)
auth.init_app(app)
user_db: t.Dict[str, User] \
= {_USERNAME: User(
_USERNAME, make_password_hash(_REALM, _USERNAME, _PASSWORD))}
user_db: t.Dict[str, str] \
= {_USERNAME: make_password_hash(_REALM, _USERNAME, _PASSWORD)}
@auth.register_get_password
def get_password_hash(username: str) -> t.Optional[str]:
@ -74,8 +60,7 @@ class AuthenticationTestCase(TestCase):
:param username: The username.
:return: The password hash, or None if the user does not exist.
"""
return user_db[username].password_hash if username in user_db \
else None
return user_db[username] if username in user_db else None
@auth.register_get_user
def get_user(username: str) -> t.Optional[t.Any]:
@ -84,16 +69,8 @@ class AuthenticationTestCase(TestCase):
:param username: The username.
:return: The user, or None if the user does not exist.
"""
return user_db[username] 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
return SimpleNamespace(username=username) if username in user_db \
else None
@app.get("/admin-1/auth", endpoint="admin-1")
@auth.login_required
@ -141,7 +118,6 @@ class AuthenticationTestCase(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data.decode("UTF-8"),
f"Hello, {_USERNAME}! #2")
self.assertEqual(g.user.visits, 1)
def test_stale_opaque(self) -> None:
"""Tests the stale and opaque value.
@ -218,4 +194,3 @@ class AuthenticationTestCase(TestCase):
response = self.client.get(admin_uri)
self.assertEqual(response.status_code, 200)
self.assertEqual(g.user.visits, 2)

View File

@ -21,7 +21,6 @@
import typing as t
from secrets import token_urlsafe
import flask_login
from flask import Response, Flask, g, redirect, request
from flask_testing import TestCase
from werkzeug.datastructures import WWWAuthenticate, Authorization
@ -36,15 +35,12 @@ _PASSWORD: str = "Circle Of Life"
class User:
"""A dummy user."""
def __init__(self, username: str, password_hash: str):
def __init__(self, username: str):
"""Constructs a dummy user.
:param username: The username.
:param password_hash: The password hash.
"""
self.username: str = username
self.password_hash: str = password_hash
self.visits: int = 0
self.is_authenticated: bool = True
self.is_active: bool = True
self.is_anonymous: bool = False
@ -86,9 +82,8 @@ class FlaskLoginTestCase(TestCase):
auth: DigestAuth = DigestAuth(realm=_REALM)
auth.init_app(app)
user_db: t.Dict[str, User] \
= {_USERNAME: User(
_USERNAME, make_password_hash(_REALM, _USERNAME, _PASSWORD))}
user_db: t.Dict[str, str] \
= {_USERNAME: make_password_hash(_REALM, _USERNAME, _PASSWORD)}
@auth.register_get_password
def get_password_hash(username: str) -> t.Optional[str]:
@ -97,17 +92,7 @@ class FlaskLoginTestCase(TestCase):
:param username: The username.
:return: The password hash, or None if the user does not exist.
"""
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
return user_db[username] if username in user_db else None
@login_manager.user_loader
def load_user(user_id: str) -> t.Optional[User]:
@ -116,7 +101,7 @@ class FlaskLoginTestCase(TestCase):
:param user_id: The username.
:return: The user, or None if the user does not exist.
"""
return user_db[user_id] if user_id in user_db else None
return User(user_id) if user_id in user_db else None
@app.get("/admin-1/auth", endpoint="admin-1")
@flask_login.login_required
@ -167,7 +152,6 @@ class FlaskLoginTestCase(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data.decode("UTF-8"),
f"Hello, {_USERNAME}! #2")
self.assertEqual(flask_login.current_user.visits, 1)
def test_stale_opaque(self) -> None:
"""Tests the stale and opaque value.
@ -253,4 +237,3 @@ class FlaskLoginTestCase(TestCase):
response = self.client.get(admin_uri)
self.assertEqual(response.status_code, 200)
self.assertEqual(flask_login.current_user.visits, 2)