Browse Source

Add http header auth (#457)

* Add HTTP header authentiation backend/middleware

* Add docs for remote header auth

* Improve docs on external auth

* Add warning for unknown  REMOTE_USER_HEADER_TYPE

* Move active check for header auth to middleware
Add extra header type sanity check to the backend

* Add test cases for remote header login

* Improve header-based authentication

- remove the 'ID' mode
- add CustomHeaderBackend to AUTHENTICATION_BACKENDS conditionally
- rewrite CustomHeaderBackend and CustomHeaderMiddleware to
use less inherited code
- add more test cases

Co-authored-by: Pēteris Caune <[email protected]>
pull/464/head
Shea Polansky 4 years ago
committed by GitHub
parent
commit
54a95a0ee2
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 164 additions and 0 deletions
  1. +22
    -0
      README.md
  2. +30
    -0
      hc/accounts/backends.py
  3. +50
    -0
      hc/accounts/middleware.py
  4. +56
    -0
      hc/accounts/tests/test_remote_user_header_login.py
  5. +6
    -0
      hc/settings.py

+ 22
- 0
README.md View File

@ -134,6 +134,7 @@ Healthchecks reads configuration from the following environment variables:
| PUSHOVER_EMERGENCY_EXPIRATION | `86400` | PUSHOVER_EMERGENCY_EXPIRATION | `86400`
| PUSHOVER_EMERGENCY_RETRY_DELAY | `300` | PUSHOVER_EMERGENCY_RETRY_DELAY | `300`
| PUSHOVER_SUBSCRIPTION_URL | `None` | PUSHOVER_SUBSCRIPTION_URL | `None`
| REMOTE_USER_HEADER | `None` | See [External Authentication](#external-authentication) for details.
| SHELL_ENABLED | `"False"` | SHELL_ENABLED | `"False"`
| SLACK_CLIENT_ID | `None` | SLACK_CLIENT_ID | `None`
| SLACK_CLIENT_SECRET | `None` | SLACK_CLIENT_SECRET | `None`
@ -328,6 +329,27 @@ Note that WebAuthn requires HTTPS, even if running on localhost. To test WebAuth
locally with a self-signed certificate, you can use the `runsslserver` command locally with a self-signed certificate, you can use the `runsslserver` command
from the `django-sslserver` package. from the `django-sslserver` package.
## External Authentication
HealthChecks supports external authentication by means of HTTP headers set by
reverse proxies or the WSGI server. This allows you to integrate it into your
existing authentication system (e.g., LDAP or OAuth) via an authenticating proxy.
When this option is enabled, **healtchecks will trust the header's value implicitly**,
so it is **very important** to ensure that attackers cannot set the value themselves
(and thus impersonate any user). How to do this varies by your chosen proxy,
but generally involves configuring it to strip out headers that normalize to the
same name as the chosen identity header.
To enable this feature, set the `REMOTE_USER_HEADER` value to a header you wish to
authenticate with. HTTP headers will be prefixed with `HTTP_` and have any dashes
converted to underscores. Headers without that prefix can be set by the WSGI server
itself only, which is more secure.
When `REMOTE_USER_HEADER` is set, Healthchecks will:
- assume the header contains user's email address
- look up and automatically log in the user with a matching email address
- automatically create an user account if it does not exist
- disable the default authentication methods (login link to email, password)
## Integrations ## Integrations


+ 30
- 0
hc/accounts/backends.py View File

@ -1,5 +1,7 @@
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.conf import settings
from hc.accounts.models import Profile from hc.accounts.models import Profile
from hc.accounts.views import _make_user
class BasicBackend(object): class BasicBackend(object):
@ -36,3 +38,31 @@ class EmailBackend(BasicBackend):
if user.check_password(password): if user.check_password(password):
return user return user
class CustomHeaderBackend(BasicBackend):
"""
This backend works in conjunction with the ``CustomHeaderMiddleware``,
and is used when the server is handling authentication outside of Django.
"""
def authenticate(self, request, remote_user_email):
"""
The email address passed as remote_user_email is considered trusted.
Return the User object with the given email address. Create a new User
if it does not exist.
"""
# This backend should only be used when header-based authentication is enabled
assert settings.REMOTE_USER_HEADER
# remote_user_email should have a value
assert remote_user_email
try:
user = User.objects.get(email=remote_user_email)
except User.DoesNotExist:
user = _make_user(remote_user_email)
return user

+ 50
- 0
hc/accounts/middleware.py View File

@ -1,3 +1,7 @@
from django.contrib import auth
from django.contrib.auth.middleware import RemoteUserMiddleware
from django.conf import settings
from hc.accounts.models import Profile from hc.accounts.models import Profile
@ -11,3 +15,49 @@ class TeamAccessMiddleware(object):
request.profile = Profile.objects.for_user(request.user) request.profile = Profile.objects.for_user(request.user)
return self.get_response(request) return self.get_response(request)
class CustomHeaderMiddleware(RemoteUserMiddleware):
"""
Middleware for utilizing Web-server-provided authentication.
If request.user is not authenticated, then this middleware:
- looks for an email address in request.META[settings.REMOTE_USER_HEADER]
- looks up and automatically logs in the user with a matching email
"""
def process_request(self, request):
if not settings.REMOTE_USER_HEADER:
return
# Make sure AuthenticationMiddleware is installed
assert hasattr(request, "user")
email = request.META.get(settings.REMOTE_USER_HEADER)
if not email:
# If specified header doesn't exist or is empty then log out any
# authenticated user and return
if request.user.is_authenticated:
auth.logout(request)
return
# If the user is already authenticated and that user is the user we are
# getting passed in the headers, then the correct user is already
# persisted in the session and we don't need to continue.
if request.user.is_authenticated:
if request.user.email == email:
return
else:
# An authenticated user is associated with the request, but
# it does not match the authorized user in the header.
auth.logout(request)
# We are seeing this user for the first time in this session, attempt
# to authenticate the user.
user = auth.authenticate(request, remote_user_email=email)
if user:
# User is valid. Set request.user and persist user in the session
# by logging the user in.
request.user = user
auth.login(request, user)

+ 56
- 0
hc/accounts/tests/test_remote_user_header_login.py View File

@ -0,0 +1,56 @@
from unittest.mock import patch
from django.contrib.auth.models import User
from django.test.utils import override_settings
from hc.test import BaseTestCase
@override_settings(
REMOTE_USER_HEADER="AUTH_USER",
AUTHENTICATION_BACKENDS=("hc.accounts.backends.CustomHeaderBackend",),
)
class RemoteUserHeaderTestCase(BaseTestCase):
@override_settings(REMOTE_USER_HEADER=None)
def test_it_does_nothing_when_not_configured(self):
r = self.client.get("/accounts/profile/", AUTH_USER="[email protected]")
self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/")
def test_it_logs_user_in(self):
r = self.client.get("/accounts/profile/", AUTH_USER="[email protected]")
self.assertContains(r, "[email protected]")
def test_it_does_nothing_when_header_not_set(self):
r = self.client.get("/accounts/profile/")
self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/")
def test_it_does_nothing_when_header_is_empty_string(self):
r = self.client.get("/accounts/profile/", AUTH_USER="")
self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/")
def test_it_creates_user(self):
r = self.client.get("/accounts/profile/", AUTH_USER="[email protected]")
self.assertContains(r, "[email protected]")
q = User.objects.filter(email="[email protected]")
self.assertTrue(q.exists())
def test_it_logs_out_another_user_when_header_is_empty_string(self):
self.client.login(remote_user_email="[email protected]")
r = self.client.get("/accounts/profile/", AUTH_USER="")
self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/")
def test_it_logs_out_another_user(self):
self.client.login(remote_user_email="[email protected]")
r = self.client.get("/accounts/profile/", AUTH_USER="[email protected]")
self.assertContains(r, "[email protected]")
def test_it_handles_already_logged_in_user(self):
self.client.login(remote_user_email="[email protected]")
with patch("hc.accounts.middleware.auth") as mock_auth:
r = self.client.get("/accounts/profile/", AUTH_USER="[email protected]")
self.assertFalse(mock_auth.authenticate.called)
self.assertContains(r, "[email protected]")

+ 6
- 0
hc/settings.py View File

@ -58,12 +58,14 @@ INSTALLED_APPS = (
"hc.payments", "hc.payments",
) )
MIDDLEWARE = ( MIDDLEWARE = (
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"hc.accounts.middleware.CustomHeaderMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"django.middleware.locale.LocaleMiddleware", "django.middleware.locale.LocaleMiddleware",
@ -75,6 +77,10 @@ AUTHENTICATION_BACKENDS = (
"hc.accounts.backends.ProfileBackend", "hc.accounts.backends.ProfileBackend",
) )
REMOTE_USER_HEADER = os.getenv("REMOTE_USER_HEADER")
if REMOTE_USER_HEADER:
AUTHENTICATION_BACKENDS = ("hc.accounts.backends.CustomHeaderBackend",)
ROOT_URLCONF = "hc.urls" ROOT_URLCONF = "hc.urls"
TEMPLATES = [ TEMPLATES = [


Loading…
Cancel
Save