Repository: cisagov/manage.get.gov

Issue:
Prevent unauthenticated access by using middleware
It is easy to forget `@login_required` decorators and `LoginRequired` mixins, so a middlware layer should be installed to ensure only ok-listed routes are accessible when a user is not signed in.

A suitable 3rd party middleware package may not exist, so it may be necessary to write a small middleware. Existing examples in the codebase can be used.

Middleware may also help with bugs like #537 and #405. It could also resolve and close #486.

This work may intersect with #544.


Discussion:
As part of this we should also remove the landing page view and only have home be the dashboard (thus requiring authentication for every view in the site) 

Resolving patch:
diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py
--- a/src/registrar/config/settings.py
+++ b/src/registrar/config/settings.py
@@ -133,6 +133,8 @@
     "django.middleware.csrf.CsrfViewMiddleware",
     # add `user` (the currently-logged-in user) to incoming HttpRequest objects
     "django.contrib.auth.middleware.AuthenticationMiddleware",
+    # Require login for every single request by default
+    "login_required.middleware.LoginRequiredMiddleware",
     # provide framework for displaying messages to the user, see documentation
     "django.contrib.messages.middleware.MessageMiddleware",
     # provide clickjacking protection via the X-Frame-Options header
@@ -460,6 +462,12 @@
 # the login_required() decorator, LoginRequiredMixin, or AccessMixin
 LOGIN_URL = "/openid/login"
 
+# We don't want the OIDC app to be login-required because then it can't handle
+# the initial login requests without erroring.
+LOGIN_REQUIRED_IGNORE_PATHS = [
+    r"/openid/(.+)$",
+]
+
 # where to go after logging out
 LOGOUT_REDIRECT_URL = "home"
 
diff --git a/src/registrar/views/application.py b/src/registrar/views/application.py
--- a/src/registrar/views/application.py
+++ b/src/registrar/views/application.py
@@ -1,6 +1,5 @@
 import logging
 
-from django.contrib.auth.mixins import LoginRequiredMixin
 from django.http import Http404, HttpResponse, HttpResponseRedirect
 from django.shortcuts import redirect, render
 from django.urls import resolve, reverse
@@ -44,7 +43,7 @@ class Step(StrEnum):
     REVIEW = "review"
 
 
-class ApplicationWizard(LoginRequiredMixin, TemplateView):
+class ApplicationWizard(TemplateView):
     """
     A common set of methods and configuration.
 
diff --git a/src/registrar/views/health.py b/src/registrar/views/health.py
--- a/src/registrar/views/health.py
+++ b/src/registrar/views/health.py
@@ -1,6 +1,11 @@
 from django.http import HttpResponse
 
+from login_required import login_not_required
 
+
+# the health check endpoint needs to be globally available so that the
+# PaaS orchestrator can make sure the app has come up properly
+@login_not_required
 def health(request):
     return HttpResponse(
         '<html lang="en"><head><title>OK - Get.gov</title></head><body>OK</body>'


Test patch:
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -59,7 +59,7 @@ jobs:
         # by adding MockUserLogin to settings.MIDDLEWARE
         run: |
           perl -pi \
-          -e 's/"csp.middleware.CSPMiddleware",/$&"registrar.tests.common.MockUserLogin",/' \
+          -e 's/"django.contrib.auth.middleware.AuthenticationMiddleware",/$&"registrar.tests.common.MockUserLogin",/' \
           registrar/config/settings.py
 
       - name: Start container
diff --git a/src/api/tests/test_available.py b/src/api/tests/test_available.py
--- a/src/api/tests/test_available.py
+++ b/src/api/tests/test_available.py
@@ -104,6 +104,9 @@ def test_available_get(self):
 
     def test_available_post(self):
         """Cannot post to the /available/ API endpoint."""
+        # have to log in to test the correct thing now that we require login
+        # for all URLs by default
+        self.client.force_login(self.user)
         with less_console_noise():
             response = self.client.post(API_BASE_PATH + "nonsense")
         self.assertEqual(response.status_code, 405)
diff --git a/src/registrar/tests/test_url_auth.py b/src/registrar/tests/test_url_auth.py
new file mode 100644
--- /dev/null
+++ b/src/registrar/tests/test_url_auth.py
@@ -0,0 +1,159 @@
+"""Test that almost all URLs require authentication.
+
+This uses deep Django URLConf pattern magic and was shamelessly lifted from
+https://github.com/18F/tock/blob/main/tock/tock/tests/test_url_auth.py
+"""
+
+from django.test import TestCase
+from django.urls import reverse, URLPattern
+from django.urls.resolvers import URLResolver
+
+import registrar.config.urls
+
+from .common import less_console_noise
+
+# When a URLconf pattern contains named capture groups, we'll use this
+# dictionary to retrieve a sample value for it, which will be included
+# in the sample URLs we generate, when attempting to perform a GET
+# request on the view.
+SAMPLE_KWARGS = {
+    "app_label": "registrar",
+    "pk": "1",
+    "id": "1",
+    "content_type_id": "2",
+    "object_id": "3",
+    "domain": "whitehouse.gov",
+}
+
+# Our test suite will ignore some namespaces.
+IGNORE_NAMESPACES = [
+    # The Django Debug Toolbar (DJDT) ends up in the URL config but it's always
+    # disabled in production, so don't worry about it.
+    "djdt"
+]
+
+# In general, we don't want to have any unnamed views, because that makes it
+# impossible to generate sample URLs that point at them. We'll make exceptions
+# for some namespaces that we don't have control over, though.
+NAMESPACES_WITH_UNNAMED_VIEWS = ["admin", None]
+
+
+def iter_patterns(urlconf, patterns=None, namespace=None):
+    """
+    Iterate through all patterns in the given Django URLconf.  Yields
+    `(viewname, route)` tuples, where `viewname` is the fully-qualified view name
+    (including its namespace, if any), and `route` is a regular expression that
+    corresponds to the part of the pattern that contains any capturing groups.
+    """
+    if patterns is None:
+        patterns = urlconf.urlpatterns
+    for pattern in patterns:
+        # Resolve if it's a route or an include
+        if isinstance(pattern, URLPattern):
+            viewname = pattern.name
+            if viewname is None and namespace not in NAMESPACES_WITH_UNNAMED_VIEWS:
+                raise AssertionError(
+                    f"namespace {namespace} cannot contain unnamed views"
+                )
+            if namespace and viewname is not None:
+                viewname = f"{namespace}:{viewname}"
+            yield (viewname, pattern.pattern)
+        elif isinstance(pattern, URLResolver):
+            if len(pattern.default_kwargs.keys()) > 0:
+                raise AssertionError("resolvers are not expected to have kwargs")
+            if pattern.namespace and namespace is not None:
+                raise AssertionError("nested namespaces are not currently supported")
+            if pattern.namespace in IGNORE_NAMESPACES:
+                continue
+            yield from iter_patterns(
+                urlconf, pattern.url_patterns, namespace or pattern.namespace
+            )
+        else:
+            raise AssertionError("unknown pattern class")
+
+
+def iter_sample_urls(urlconf):
+    """
+    Yields sample URLs for all entries in the given Django URLconf.
+    This gets pretty deep into the muck of RoutePattern
+    https://docs.djangoproject.com/en/2.1/_modules/django/urls/resolvers/
+    """
+
+    for viewname, route in iter_patterns(urlconf):
+        if not viewname:
+            continue
+        if viewname == "auth_user_password_change":
+            print(route)
+            break
+        named_groups = route.regex.groupindex.keys()
+        kwargs = {}
+        args = ()
+
+        for kwarg in named_groups:
+            if kwarg not in SAMPLE_KWARGS:
+                raise AssertionError(
+                    f'Sample value for {kwarg} in pattern "{route}" not found'
+                )
+            kwargs[kwarg] = SAMPLE_KWARGS[kwarg]
+
+        url = reverse(viewname, args=args, kwargs=kwargs)
+        yield (viewname, url)
+
+
+class TestURLAuth(TestCase):
+    """
+    Tests to ensure that most URLs in a Django URLconf are protected by
+    authentication.
+    """
+
+    # We won't test that the following URLs are protected by auth.
+    # Note that the trailing slash is wobbly depending on how the URL was defined.
+    IGNORE_URLS = [
+        # These are the OIDC auth endpoints that always need
+        # to be public.
+        "/openid/login/",
+        "/openid/logout/",
+        "/openid/callback",
+        "/openid/callback/login/",
+        "/openid/callback/logout/",
+    ]
+
+    def assertURLIsProtectedByAuth(self, url):
+        """
+        Make a GET request to the given URL, and ensure that it either redirects
+        to login or denies access outright.
+        """
+
+        try:
+            with less_console_noise():
+                response = self.client.get(url)
+        except Exception as e:
+            # It'll be helpful to provide information on what URL was being
+            # accessed at the time the exception occurred.  Python 3 will
+            # also include a full traceback of the original exception, so
+            # we don't need to worry about hiding the original cause.
+            raise AssertionError(f'Accessing {url} raised "{e}"', e)
+
+        code = response.status_code
+        if code == 302:
+            redirect = response["location"]
+            self.assertRegex(
+                redirect,
+                r"^\/openid\/login",
+                f"GET {url} should redirect to login or deny access, but instead "
+                f"it redirects to {redirect}",
+            )
+        elif code == 401 or code == 403:
+            pass
+        else:
+            raise AssertionError(
+                f"GET {url} returned HTTP {code}, but should redirect to login or "
+                "deny access",
+            )
+
+    def test_login_required_all_urls(self):
+        """All URLs redirect to the login view."""
+        for viewname, url in iter_sample_urls(registrar.config.urls):
+            if url not in self.IGNORE_URLS:
+                with self.subTest(viewname=viewname):
+                    self.assertURLIsProtectedByAuth(url)
diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py
--- a/src/registrar/tests/test_views.py
+++ b/src/registrar/tests/test_views.py
@@ -35,10 +35,9 @@ def test_health_check_endpoint(self):
         self.assertContains(response, "OK", status_code=200)
 
     def test_home_page(self):
-        """Home page should be available without a login."""
+        """Home page should NOT be available without a login."""
         response = self.client.get("/")
-        self.assertContains(response, "registrar", status_code=200)
-        self.assertContains(response, "Sign in")
+        self.assertEqual(response.status_code, 302)
 
     def test_whoami_page_no_user(self):
         """Whoami page not accessible without a logged-in user."""