"""A Django middleware which checks all incoming POST requests forpotentially-compromised passwords using the Pwned Passwords API."""# SPDX-License-Identifier: BSD-3-Clauseimportloggingimportreimporttypingfrominspectimportiscoroutinefunctionfromdjangoimporthttpfromdjango.confimportsettingsfromdjango.contrib.auth.password_validationimportCommonPasswordValidatorfromdjango.core.exceptionsimportValidationErrorfromdjango.utils.decoratorsimportsync_and_async_middlewarefromdjango.views.decorators.debugimportsensitive_variablesfrom.importapi,exceptionslogger=logging.getLogger(__name__)_fallback_validator=CommonPasswordValidator()def_fallback(password:str)->bool:""" Fallback password check in case of Pwned Passwords errors, using Django's built-in CommonPasswordValidator. """try:_fallback_validator.validate(password)returnFalseexceptValidationError:returnTrue@sensitive_variables()asyncdef_scan_payload_async(request:http.HttpRequest)->typing.List[str]:""" Asynchronous helper function which performs the scan of the request's payload. """settings_dict=getattr(settings,"PWNED_PASSWORDS",{})search_re=re.compile(settings_dict.get("PASSWORD_REGEX",r"PASS"),re.IGNORECASE)keys_to_search=[keyforkeyinrequest.POST.keys()ifsearch_re.search(key)]ifnotkeys_to_search:return[]try:return[keyforkeyinkeys_to_searchifawaitapi.check_password_async(request.POST[key])]exceptexceptions.PwnedPasswordsError:logger.error("Falling back to Django CommonPasswordValidator due ""to error contacting Pwned Passwords.")return[keyforkeyinkeys_to_searchif_fallback(request.POST[key])]@sensitive_variables()def_scan_payload_sync(request:http.HttpRequest)->typing.List[str]:""" Helper function which performs the scan of the request's payload. """settings_dict=getattr(settings,"PWNED_PASSWORDS",{})search_re=re.compile(settings_dict.get("PASSWORD_REGEX",r"PASS"),re.IGNORECASE)keys_to_search=[keyforkeyinrequest.POST.keys()ifsearch_re.search(key)]ifnotkeys_to_search:return[]try:return[keyforkeyinkeys_to_searchifapi.check_password(request.POST[key])]exceptexceptions.PwnedPasswordsError:logger.error("Falling back to Django CommonPasswordValidator due ""to error contacting Pwned Passwords.")return[keyforkeyinkeys_to_searchif_fallback(request.POST[key])]
[docs]@sync_and_async_middlewaredefpwned_passwords_middleware(get_response:typing.Callable)->typing.Callable:""" Factory function returning a middleware -- sync or async as necessary -- which checks ``POST`` submissions that potentially contain passwords against the Pwned Passwords database. To enable the middleware, add ``"pwned_passwords_django.middleware.pwned_passwords_middleware"`` to your :setting:`MIDDLEWARE` setting. This will add a new attribute -- ``pwned_passwords`` -- to each :class:`~django.http.HttpRequest` object. The ``request.pwned_passwords`` attribute will be a :class:`list` of :class:`str`. .. warning:: **Middleware order** The order of middleware classes in the Django :setting:`MIDDLEWARE` setting can be sensitive. In particular, any middlewares which affect file upload handlers *must* be listed above middlewares which inspect :attr:`~django.http.HttpRequest.POST`. Since this middleware has to inspect :attr:`~django.http.HttpRequest.POST` for likely passwords, it must be listed after any middlewares which might change upload handlers. If you're unsure what this means, just put this middleware at the bottom of your :setting:`MIDDLEWARE` list. The ``request.pwned_passwords`` list will be *empty* if any of the following is true: * The request method is not ``POST``. * The request method is ``POST``, but the payload does not appear to contain a password. * The request method is ``POST``, and the payload appears to contain one or more passwords, but none were listed as compromised in Pwned Passwords. If the request method is ``POST``, and the payload appears to contain one or more passwords, and at least one of those is listed in Pwned Passwords, then ``request.pwned_passwords`` will be a list of keys from ``request.POST`` that contained compromised passwords. For example, if ``request.POST`` contains a key named ``password_field``, and ``request.POST["password_field"]`` is a password that appears in the Pwned Passwords database, ``request.pwned_passwords`` will be ``["password_field"]``. .. warning:: **API failures** ``pwned-passwords-django`` needs to communicate with the Pwned Passwords API in order to check passwords. If Pwned Passwords is down or timing out (the default connection timeout is 1 second), or if any other error occurs when checking the password, this middleware will fall back to using Django's :class:`~django.contrib.auth.password_validation.CommonPasswordValidator`, which uses a smaller, locally-stored list of common passwords. Whenever this happens, a message of level :data:`logging.ERROR` will appear in your logs, indicating what type of failure was encountered in talking to the Pwned Passwords API. See :ref:`the error-handling documentation <error-handling>` for details. Here's an example of how you might use `Django's message framework <https://docs.djangoproject.com/en/stable/ref/contrib/messages/>`_ to indicate to a user that they've just submitted a password that appears to be compromised: .. code-block:: python from django.contrib import messages def some_view(request): if request.method == "POST" and request.pwned_passwords: messages.warning( request, "You just entered a password which appears to be compromised!" ) ``pwned-passwords-django`` uses a regular expression to guess which items in :attr:`~django.http.HttpRequest.POST` are likely to be passwords. By default, it matches on any key in :attr:`~django.http.HttpRequest.POST` containing ``"PASS"`` (case-insensitive), which catches input names like ``"password"``, ``"passphrase"``, and so on. If you use something significantly different than this for a password input name, specify it -- as a string, *not* as a compiled regex object! -- in the setting ``settings.PWNED_PASSWORDS["PASSWORD_REGEX"]`` to tell the middleware what to look for. See :ref:`the settings documentation <settings>` for details. """# We need to know whether or not the request we're handling is async: if it is, we# should return an async middleware that uses an async HTTP client to talk to Pwned# Passwords. We determine that by checking whether the get_response() callable is a# coroutine -- if so, we're on the async path.ifiscoroutinefunction(get_response):asyncdefmiddleware(request:http.HttpRequest)->http.HttpResponse:""" Asynchronous middleware function which checks all POST submissions containing likely passwords against the Pwned Passwords database. """request.pwned_passwords=[]ifrequest.method=="POST":request.pwned_passwords=await_scan_payload_async(request)response=awaitget_response(request)returnresponseelse:defmiddleware(request:http.HttpRequest)->http.HttpResponse:""" Synchronous middleware function which checks all POST submissions containing likely passwords against the Pwned Passwords database. """request.pwned_passwords=[]ifrequest.method=="POST":request.pwned_passwords=_scan_payload_sync(request)response=get_response(request)returnresponsereturnmiddleware