Merge pull request #673 from cisagov/nmb/543-login-required-middleware

Use login required middleware
This commit is contained in:
Neil MartinsenBurrell 2023-06-09 14:31:15 -05:00 committed by GitHub
commit 097ad00c64
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 208 additions and 24 deletions

View file

@ -72,8 +72,8 @@ jobs:
# by adding MockUserLogin to settings.MIDDLEWARE # by adding MockUserLogin to settings.MIDDLEWARE
run: | run: |
perl -pi \ perl -pi \
-e 's/"csp.middleware.CSPMiddleware",/$&"registrar.tests.common.MockUserLogin",/' \ -e 's/"django.contrib.auth.middleware.AuthenticationMiddleware",/$&"registrar.tests.common.MockUserLogin",/' \
src/registrar/config/settings.py registrar/config/settings.py
working-directory: ./src working-directory: ./src
- name: OWASP scan - name: OWASP scan

View file

@ -59,7 +59,7 @@ jobs:
# by adding MockUserLogin to settings.MIDDLEWARE # by adding MockUserLogin to settings.MIDDLEWARE
run: | run: |
perl -pi \ 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 registrar/config/settings.py
- name: Start container - name: Start container

View file

@ -24,9 +24,9 @@ django-fsm = "*"
django-phonenumber-field = {extras = ["phonenumberslite"], version = "*"} django-phonenumber-field = {extras = ["phonenumberslite"], version = "*"}
boto3 = "*" boto3 = "*"
typing-extensions ='*' typing-extensions ='*'
django-login-required-middleware = "*"
fred-epplib = {git = "https://github.com/cisagov/epplib.git", ref = "master"} fred-epplib = {git = "https://github.com/cisagov/epplib.git", ref = "master"}
[dev-packages] [dev-packages]
django-debug-toolbar = "*" django-debug-toolbar = "*"
nplusone = "*" nplusone = "*"

35
src/Pipfile.lock generated
View file

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "fd7d0efa9a87dfe4b2bb228ee0e7978fba16c7cfdd3c443870900cfe899e2cfd" "sha256": "1242c67b31261243a35128410d4a928fca3729ddac13b8c8e25adf31445c6328"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": {}, "requires": {},
@ -306,6 +306,13 @@
"index": "pypi", "index": "pypi",
"version": "==2.8.1" "version": "==2.8.1"
}, },
"django-login-required-middleware": {
"hashes": [
"sha256:847ae9a69fd7a07618ed53192b3c06946af70a0caf6d0f4eb40a8f37593cd970"
],
"index": "pypi",
"version": "==0.9.0"
},
"django-phonenumber-field": { "django-phonenumber-field": {
"extras": [ "extras": [
"phonenumberslite" "phonenumberslite"
@ -791,11 +798,11 @@
}, },
"typing-extensions": { "typing-extensions": {
"hashes": [ "hashes": [
"sha256:06006244c70ac8ee83fa8282cb188f697b8db25bc8b4df07be1873c43897060c", "sha256:88a4153d8505aabbb4e13aacb7c486c2b4a33ca3b3f807914a9b4c844c471c26",
"sha256:3a8b36f13dd5fdc5d1b16fe317f5668545de77fa0b8e02006381fd49d731ab98" "sha256:d91d5919357fe7f681a9f2b5b4cb2a5f1ef0a1e9f59c4d8ff0d3491e05c0ffd5"
], ],
"index": "pypi", "index": "pypi",
"version": "==4.6.2" "version": "==4.6.3"
}, },
"urllib3": { "urllib3": {
"hashes": [ "hashes": [
@ -951,19 +958,19 @@
}, },
"django-stubs": { "django-stubs": {
"hashes": [ "hashes": [
"sha256:93baff824f0a056e71036b423b942a74f07b909e45e3fa38185b910f597c5c08", "sha256:66477bdba25407623f4079205e58f3c7265a4f0d8f7c9f540a6edc16f8883a5b",
"sha256:d2c671989efb3f7b0fa91e461909ad5a5a52155fe7fe6d1f2058cb88e3afb123" "sha256:8c15d5f7b05926805cfb25f2bfbf3509c37792fbd8aec5aedea358b85d8bccd5"
], ],
"index": "pypi", "index": "pypi",
"version": "==4.2.0" "version": "==4.2.1"
}, },
"django-stubs-ext": { "django-stubs-ext": {
"hashes": [ "hashes": [
"sha256:55b2e3077f883e0131a7596f8ff8b19f8fc3ca325a3318ccacf5331acb2601e4", "sha256:2696d6f7d8538341b060cffa9565c72ea797e866687e040b86d29cad8799e5fe",
"sha256:7789f0caeca7152fef07ad6b94dec7310a05d0b8dab77f7979e19db0037b5127" "sha256:4b6b63e49f4ba30d93ec46f87507648c99c9de6911e651ad69db7084fd5b2f4e"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.8'",
"version": "==4.2.0" "version": "==4.2.1"
}, },
"django-webtest": { "django-webtest": {
"hashes": [ "hashes": [
@ -1306,11 +1313,11 @@
}, },
"typing-extensions": { "typing-extensions": {
"hashes": [ "hashes": [
"sha256:06006244c70ac8ee83fa8282cb188f697b8db25bc8b4df07be1873c43897060c", "sha256:88a4153d8505aabbb4e13aacb7c486c2b4a33ca3b3f807914a9b4c844c471c26",
"sha256:3a8b36f13dd5fdc5d1b16fe317f5668545de77fa0b8e02006381fd49d731ab98" "sha256:d91d5919357fe7f681a9f2b5b4cb2a5f1ef0a1e9f59c4d8ff0d3491e05c0ffd5"
], ],
"index": "pypi", "index": "pypi",
"version": "==4.6.2" "version": "==4.6.3"
}, },
"urllib3": { "urllib3": {
"hashes": [ "hashes": [

View file

@ -104,6 +104,9 @@ class AvailableAPITest(TestCase):
def test_available_post(self): def test_available_post(self):
"""Cannot post to the /available/ API endpoint.""" """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(): with less_console_noise():
response = self.client.post(API_BASE_PATH + "nonsense") response = self.client.post(API_BASE_PATH + "nonsense")
self.assertEqual(response.status_code, 405) self.assertEqual(response.status_code, 405)

View file

@ -133,6 +133,8 @@ MIDDLEWARE = [
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
# add `user` (the currently-logged-in user) to incoming HttpRequest objects # add `user` (the currently-logged-in user) to incoming HttpRequest objects
"django.contrib.auth.middleware.AuthenticationMiddleware", "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 # provide framework for displaying messages to the user, see documentation
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
# provide clickjacking protection via the X-Frame-Options header # provide clickjacking protection via the X-Frame-Options header
@ -460,6 +462,12 @@ AUTHENTICATION_BACKENDS = [
# the login_required() decorator, LoginRequiredMixin, or AccessMixin # the login_required() decorator, LoginRequiredMixin, or AccessMixin
LOGIN_URL = "/openid/login" 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 # where to go after logging out
LOGOUT_REDIRECT_URL = "home" LOGOUT_REDIRECT_URL = "home"

View file

@ -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)

View file

@ -35,10 +35,9 @@ class TestViews(TestCase):
self.assertContains(response, "OK", status_code=200) self.assertContains(response, "OK", status_code=200)
def test_home_page(self): 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("/") response = self.client.get("/")
self.assertContains(response, "registrar", status_code=200) self.assertEqual(response.status_code, 302)
self.assertContains(response, "Sign in")
def test_whoami_page_no_user(self): def test_whoami_page_no_user(self):
"""Whoami page not accessible without a logged-in user.""" """Whoami page not accessible without a logged-in user."""

View file

@ -1,6 +1,5 @@
import logging import logging
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404, HttpResponse, HttpResponseRedirect from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import resolve, reverse from django.urls import resolve, reverse
@ -44,7 +43,7 @@ class Step(StrEnum):
REVIEW = "review" REVIEW = "review"
class ApplicationWizard(LoginRequiredMixin, TemplateView): class ApplicationWizard(TemplateView):
""" """
A common set of methods and configuration. A common set of methods and configuration.

View file

@ -1,6 +1,11 @@
from django.http import HttpResponse 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): def health(request):
return HttpResponse( return HttpResponse(
'<html lang="en"><head><title>OK - Get.gov</title></head><body>OK</body>' '<html lang="en"><head><title>OK - Get.gov</title></head><body>OK</body>'

View file

@ -17,6 +17,7 @@ django-auditlog==2.3.0
django-cache-url==3.4.4 django-cache-url==3.4.4
django-csp==3.7 django-csp==3.7
django-fsm==2.8.1 django-fsm==2.8.1
django-login-required-middleware==0.9.0
django-phonenumber-field[phonenumberslite]==7.1.0 django-phonenumber-field[phonenumberslite]==7.1.0
django-widget-tweaks==1.4.12 django-widget-tweaks==1.4.12
environs[django]==9.5.0 environs[django]==9.5.0
@ -47,6 +48,6 @@ s3transfer==0.6.1 ; python_version >= '3.7'
setuptools==67.8.0 ; python_version >= '3.7' setuptools==67.8.0 ; python_version >= '3.7'
six==1.16.0 ; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' six==1.16.0 ; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
sqlparse==0.4.4 ; python_version >= '3.5' sqlparse==0.4.4 ; python_version >= '3.5'
typing-extensions==4.6.2 typing-extensions==4.6.3
urllib3==1.26.16 ; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' urllib3==1.26.16 ; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'
whitenoise==6.4.0 whitenoise==6.4.0

View file

@ -55,6 +55,7 @@
10038 OUTOFSCOPE http://app:8080/users/add 10038 OUTOFSCOPE http://app:8080/users/add
10038 OUTOFSCOPE http://app:8080/nameservers 10038 OUTOFSCOPE http://app:8080/nameservers
10038 OUTOFSCOPE http://app:8080/your-contact-information 10038 OUTOFSCOPE http://app:8080/your-contact-information
10038 OUTOFSCOPE http://app:8080/authorizing-official
10038 OUTOFSCOPE http://app:8080/security-email 10038 OUTOFSCOPE http://app:8080/security-email
10038 OUTOFSCOPE http://app:8080/delete 10038 OUTOFSCOPE http://app:8080/delete
10038 OUTOFSCOPE http://app:8080/withdraw 10038 OUTOFSCOPE http://app:8080/withdraw
@ -63,6 +64,7 @@
10038 OUTOFSCOPE http://app:8080/todo 10038 OUTOFSCOPE http://app:8080/todo
# OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers # OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers
10038 OUTOFSCOPE http://app:8080/openid/login/ 10038 OUTOFSCOPE http://app:8080/openid/login/
10038 OUTOFSCOPE http://app:8080/openid/logout/
10039 FAIL (X-Backend-Server Header Information Leak - Passive/beta) 10039 FAIL (X-Backend-Server Header Information Leak - Passive/beta)
10040 FAIL (Secure Pages Include Mixed Content - Passive/release) 10040 FAIL (Secure Pages Include Mixed Content - Passive/release)
10041 FAIL (HTTP to HTTPS Insecure Transition in Form Post - Passive/beta) 10041 FAIL (HTTP to HTTPS Insecure Transition in Form Post - Passive/beta)
@ -149,6 +151,7 @@
# OIDC isn't configured in the test environment and DEBUG=True so these error pages # OIDC isn't configured in the test environment and DEBUG=True so these error pages
# trigger this rule in a way that they won't in production # trigger this rule in a way that they won't in production
90022 OUTOFSCOPE http://app:8080/openid/login/ 90022 OUTOFSCOPE http://app:8080/openid/login/
90022 OUTOFSCOPE http://app:8080/openid/logout/
90023 FAIL (XML External Entity Attack - Active/beta) 90023 FAIL (XML External Entity Attack - Active/beta)
90024 FAIL (Generic Padding Oracle - Active/beta) 90024 FAIL (Generic Padding Oracle - Active/beta)
90025 FAIL (Expression Language Injection - Active/beta) 90025 FAIL (Expression Language Injection - Active/beta)