Browse Source

py38: vendor django-sudo (#26646)

josh 3 years ago
parent
commit
0905435d47
10 changed files with 373 additions and 1 deletions
  1. 0 1
      requirements-base.txt
  2. 27 0
      src/sudo/LICENSE
  3. 0 0
      src/sudo/__init__.py
  4. 34 0
      src/sudo/forms.py
  5. 67 0
      src/sudo/middleware.py
  6. 9 0
      src/sudo/models.py
  7. 46 0
      src/sudo/settings.py
  8. 27 0
      src/sudo/signals.py
  9. 59 0
      src/sudo/utils.py
  10. 104 0
      src/sudo/views.py

+ 0 - 1
requirements-base.txt

@@ -11,7 +11,6 @@ datadog==0.29.3
 django-crispy-forms==1.7.2
 django-manifest-loader==1.0.0
 django-picklefield==1.0.0
-django-sudo==3.1.0
 Django==1.11.29
 djangorestframework==3.11.2
 email-reply-parser==0.5.12

+ 27 - 0
src/sudo/LICENSE

@@ -0,0 +1,27 @@
+Copyright (c) 2020, Matt Robenolt
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+  Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+  Redistributions in binary form must reproduce the above copyright notice, this
+  list of conditions and the following disclaimer in the documentation and/or
+  other materials provided with the distribution.
+
+  Neither the name of the {organization} nor the names of its
+  contributors may be used to endorse or promote products derived from
+  this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

+ 0 - 0
src/sudo/__init__.py


+ 34 - 0
src/sudo/forms.py

@@ -0,0 +1,34 @@
+"""
+sudo.forms
+~~~~~~~~~~
+
+:copyright: (c) 2020 by Matt Robenolt.
+:license: BSD, see LICENSE for more details.
+"""
+from django import forms
+from django.contrib import auth
+from django.utils.translation import ugettext_lazy as _
+
+
+class SudoForm(forms.Form):
+    """
+    A simple password input form used by the default :func:`~sudo.views.sudo` view.
+    """
+
+    password = forms.CharField(label=_("Password"), widget=forms.PasswordInput)
+
+    def __init__(self, user, *args, **kwargs):
+        self.user = user
+        super().__init__(*args, **kwargs)
+
+    def clean_password(self):
+        username = self.user.get_username()
+
+        if auth.authenticate(
+            request=None,
+            username=username,
+            password=self.data["password"],
+        ):
+            return self.data["password"]
+
+        raise forms.ValidationError(_("Incorrect password"))

+ 67 - 0
src/sudo/middleware.py

@@ -0,0 +1,67 @@
+"""
+sudo.middleware
+~~~~~~~~~~~~~~~
+
+:copyright: (c) 2020 by Matt Robenolt.
+:license: BSD, see LICENSE for more details.
+"""
+from django.utils.deprecation import MiddlewareMixin
+
+from sudo.settings import (
+    COOKIE_DOMAIN,
+    COOKIE_HTTPONLY,
+    COOKIE_NAME,
+    COOKIE_PATH,
+    COOKIE_SALT,
+    COOKIE_SECURE,
+)
+from sudo.utils import has_sudo_privileges
+
+
+class SudoMiddleware(MiddlewareMixin):
+    """
+    Middleware that contributes ``request.is_sudo()`` and sets the required
+    cookie for sudo mode to work correctly.
+    """
+
+    def has_sudo_privileges(self, request):
+        # Override me to alter behavior
+        return has_sudo_privileges(request)
+
+    def process_request(self, request):
+        assert hasattr(request, "session"), (
+            "The Sudo middleware requires session middleware to be installed."
+            "Edit your MIDDLEWARE setting to insert "
+            "'django.contrib.sessions.middleware.SessionMiddleware' before "
+            "'sudo.middleware.SudoMiddleware'."
+        )
+        request.is_sudo = lambda: self.has_sudo_privileges(request)
+
+    def process_response(self, request, response):
+        is_sudo = getattr(request, "_sudo", None)
+
+        if is_sudo is None:
+            return response
+
+        # We have explicitly had sudo revoked, so clean up cookie
+        if is_sudo is False and COOKIE_NAME in request.COOKIES:
+            response.delete_cookie(COOKIE_NAME)
+            return response
+
+        # Sudo mode has been granted,
+        # and we have a token to send back to the user agent
+        if is_sudo is True and hasattr(request, "_sudo_token"):
+            token = request._sudo_token
+            max_age = request._sudo_max_age
+            response.set_signed_cookie(
+                COOKIE_NAME,
+                token,
+                salt=COOKIE_SALT,
+                max_age=max_age,  # If max_age is None, it's a session cookie
+                secure=request.is_secure() if COOKIE_SECURE is None else COOKIE_SECURE,
+                httponly=COOKIE_HTTPONLY,  # Not accessible by JavaScript
+                path=COOKIE_PATH,
+                domain=COOKIE_DOMAIN,
+            )
+
+        return response

+ 9 - 0
src/sudo/models.py

@@ -0,0 +1,9 @@
+"""
+sudo.models
+~~~~~~~~~~~
+
+:copyright: (c) 2020 by Matt Robenolt.
+:license: BSD, see LICENSE for more details.
+"""
+# Register signals automatically by installing the app
+from sudo.signals import *  # noqa

+ 46 - 0
src/sudo/settings.py

@@ -0,0 +1,46 @@
+"""
+sudo.settings
+~~~~~~~~~~~~~
+
+:copyright: (c) 2020 by Matt Robenolt.
+:license: BSD, see LICENSE for more details.
+"""
+from django.conf import settings
+
+# Default url to be redirected to after elevating permissions
+REDIRECT_URL = getattr(settings, "SUDO_REDIRECT_URL", "/")
+
+# The querystring argument to be used for redirection
+REDIRECT_FIELD_NAME = getattr(settings, "SUDO_REDIRECT_FIELD_NAME", "next")
+
+# How long should sudo mode be active for? Duration in seconds.
+COOKIE_AGE = getattr(settings, "SUDO_COOKIE_AGE", 10800)
+
+# The domain to bind the sudo cookie to. Default to the current domain.
+COOKIE_DOMAIN = getattr(settings, "SUDO_COOKIE_DOMAIN", None)
+
+# Should the cookie only be accessible via http requests?
+# Note: If this is set to False, any JavaScript files have the ability to access
+# this cookie, so this should only be changed if you have a good reason to do so.
+COOKIE_HTTPONLY = getattr(settings, "SUDO_COOKIE_HTTPONLY", True)
+
+# The name of the cookie to be used for sudo mode.
+COOKIE_NAME = getattr(settings, "SUDO_COOKIE_NAME", "sudo")
+
+# Restrict the sudo cookie to a specific path.
+COOKIE_PATH = getattr(settings, "SUDO_COOKIE_PATH", "/")
+
+# Only transmit the sudo cookie over https if True.
+# By default, this will match the current protocol. If your site is
+# https already, this will be True.
+COOKIE_SECURE = getattr(settings, "SUDO_COOKIE_SECURE", None)
+
+# An extra salt to be added into the cookie signature
+COOKIE_SALT = getattr(settings, "SUDO_COOKIE_SALT", "")
+
+# The name of the session attribute used to preserve the redirect destination
+# between the original page request and successful sudo login.
+REDIRECT_TO_FIELD_NAME = getattr(settings, "SUDO_REDIRECT_TO_FIELD_NAME", "sudo_redirect_to")
+
+# The url for the sudo page itself. May be a url or a view name
+URL = getattr(settings, "SUDO_URL", "sudo.views.sudo")

+ 27 - 0
src/sudo/signals.py

@@ -0,0 +1,27 @@
+"""
+sudo.signals
+~~~~~~~~~~~~
+
+:copyright: (c) 2020 by Matt Robenolt.
+:license: BSD, see LICENSE for more details.
+"""
+from django.contrib.auth.signals import user_logged_in, user_logged_out
+from django.dispatch import receiver
+
+from sudo.utils import grant_sudo_privileges, revoke_sudo_privileges
+
+
+@receiver(user_logged_in)
+def grant(sender, request, **kwargs):
+    """
+    Automatically grant sudo privileges when logging in.
+    """
+    grant_sudo_privileges(request)
+
+
+@receiver(user_logged_out)
+def revoke(sender, request, **kwargs):
+    """
+    Automatically revoke sudo privileges when logging out.
+    """
+    revoke_sudo_privileges(request)

+ 59 - 0
src/sudo/utils.py

@@ -0,0 +1,59 @@
+"""
+sudo.utils
+~~~~~~~~~~
+
+:copyright: (c) 2020 by Matt Robenolt.
+:license: BSD, see LICENSE for more details.
+"""
+from django.core.signing import BadSignature
+from django.utils.crypto import constant_time_compare, get_random_string
+
+from sudo.settings import COOKIE_AGE, COOKIE_NAME, COOKIE_SALT
+
+
+def grant_sudo_privileges(request, max_age=COOKIE_AGE):
+    """
+    Assigns a random token to the user's session
+    that allows them to have elevated permissions
+    """
+    user = getattr(request, "user", None)
+
+    # If there's not a user on the request, just noop
+    if user is None:
+        return
+
+    if not user.is_authenticated:
+        raise ValueError("User needs to be logged in to be elevated to sudo")
+
+    # Token doesn't need to be unique,
+    # just needs to be unpredictable and match the cookie and the session
+    token = get_random_string()
+    request.session[COOKIE_NAME] = token
+    request._sudo = True
+    request._sudo_token = token
+    request._sudo_max_age = max_age
+    return token
+
+
+def revoke_sudo_privileges(request):
+    """
+    Revoke sudo privileges from a request explicitly
+    """
+    request._sudo = False
+    if COOKIE_NAME in request.session:
+        del request.session[COOKIE_NAME]
+
+
+def has_sudo_privileges(request):
+    """
+    Check if a request is allowed to perform sudo actions
+    """
+    if getattr(request, "_sudo", None) is None:
+        try:
+            request._sudo = request.user.is_authenticated and constant_time_compare(
+                request.get_signed_cookie(COOKIE_NAME, salt=COOKIE_SALT, max_age=COOKIE_AGE),
+                request.session[COOKIE_NAME],
+            )
+        except (KeyError, BadSignature):
+            request._sudo = False
+    return request._sudo

+ 104 - 0
src/sudo/views.py

@@ -0,0 +1,104 @@
+"""
+sudo.views
+~~~~~~~~~~
+
+:copyright: (c) 2020 by Matt Robenolt.
+:license: BSD, see LICENSE for more details.
+"""
+from urllib.parse import urlparse, urlunparse
+
+from django.contrib.auth.decorators import login_required
+from django.core.exceptions import ImproperlyConfigured
+from django.http import HttpResponseRedirect, QueryDict
+from django.shortcuts import resolve_url
+from django.template.response import TemplateResponse
+from django.utils.decorators import method_decorator
+from django.utils.http import is_safe_url
+from django.utils.module_loading import import_string
+from django.views.decorators.cache import never_cache
+from django.views.decorators.csrf import csrf_protect
+from django.views.decorators.debug import sensitive_post_parameters
+from django.views.generic import View
+
+from sudo.forms import SudoForm
+from sudo.settings import REDIRECT_FIELD_NAME, REDIRECT_TO_FIELD_NAME, REDIRECT_URL, URL
+from sudo.utils import grant_sudo_privileges
+
+
+class SudoView(View):
+    """
+    The default view for the sudo mode page. The role of this page is to
+    prompt the user for their password again, and if successful, redirect
+    them back to ``next``.
+    """
+
+    form_class = SudoForm
+    template_name = "sudo/sudo.html"
+    extra_context = None
+
+    def handle_sudo(self, request, redirect_to, context):
+        return request.method == "POST" and context["form"].is_valid()
+
+    def grant_sudo_privileges(self, request, redirect_to):
+        grant_sudo_privileges(request)
+        # Restore the redirect destination from the GET request
+        redirect_to = request.session.pop(REDIRECT_TO_FIELD_NAME, redirect_to)
+        # Double check we're not redirecting to other sites
+        if not is_safe_url(url=redirect_to, allowed_hosts={request.get_host()}):
+            redirect_to = resolve_url(REDIRECT_URL)
+        return HttpResponseRedirect(redirect_to)
+
+    @method_decorator(sensitive_post_parameters())
+    @method_decorator(never_cache)
+    @method_decorator(csrf_protect)
+    @method_decorator(login_required)
+    def dispatch(self, request):
+        redirect_to = request.GET.get(REDIRECT_FIELD_NAME, REDIRECT_URL)
+
+        # Make sure we're not redirecting to other sites
+        if not is_safe_url(url=redirect_to, allowed_hosts={request.get_host()}):
+            redirect_to = resolve_url(REDIRECT_URL)
+
+        if request.is_sudo():
+            return HttpResponseRedirect(redirect_to)
+
+        if request.method == "GET":
+            request.session[REDIRECT_TO_FIELD_NAME] = redirect_to
+
+        context = {
+            "form": self.form_class(request.user, request.POST or None),
+            "request": request,
+            REDIRECT_FIELD_NAME: redirect_to,
+        }
+        if self.handle_sudo(request, redirect_to, context):
+            return self.grant_sudo_privileges(request, redirect_to)
+        if self.extra_context is not None:
+            context.update(self.extra_context)
+        return TemplateResponse(request, self.template_name, context)
+
+
+def sudo(request, **kwargs):
+    return SudoView(**kwargs).dispatch(request)
+
+
+def redirect_to_sudo(next_url, sudo_url=None):
+    """
+    Redirects the user to the login page, passing the given 'next' page
+    """
+    if sudo_url is None:
+        sudo_url = URL
+
+    try:
+        # django 1.10 and greater can't resolve the string 'sudo.views.sudo' to a URL
+        # https://docs.djangoproject.com/en/1.10/releases/1.10/#removed-features-1-10
+        sudo_url = import_string(sudo_url)
+    except (ImportError, ImproperlyConfigured):
+        pass  # wasn't a dotted path
+
+    sudo_url_parts = list(urlparse(resolve_url(sudo_url)))
+
+    querystring = QueryDict(sudo_url_parts[4], mutable=True)
+    querystring[REDIRECT_FIELD_NAME] = next_url
+    sudo_url_parts[4] = querystring.urlencode(safe="/")
+
+    return HttpResponseRedirect(urlunparse(sudo_url_parts))

Some files were not shown because too many files changed in this diff