forked from GithubBackups/healthchecks
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 <cuu508@gmail.com>
This commit is contained in:
parent
5e3e371661
commit
54a95a0ee2
22
README.md
22
README.md
@ -134,6 +134,7 @@ Healthchecks reads configuration from the following environment variables:
|
||||
| PUSHOVER_EMERGENCY_EXPIRATION | `86400`
|
||||
| PUSHOVER_EMERGENCY_RETRY_DELAY | `300`
|
||||
| PUSHOVER_SUBSCRIPTION_URL | `None`
|
||||
| REMOTE_USER_HEADER | `None` | See [External Authentication](#external-authentication) for details.
|
||||
| SHELL_ENABLED | `"False"`
|
||||
| SLACK_CLIENT_ID | `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
|
||||
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
|
||||
|
||||
|
@ -1,5 +1,7 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.conf import settings
|
||||
from hc.accounts.models import Profile
|
||||
from hc.accounts.views import _make_user
|
||||
|
||||
|
||||
class BasicBackend(object):
|
||||
@ -36,3 +38,31 @@ class EmailBackend(BasicBackend):
|
||||
|
||||
if user.check_password(password):
|
||||
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
|
||||
|
@ -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
|
||||
|
||||
|
||||
@ -11,3 +15,49 @@ class TeamAccessMiddleware(object):
|
||||
|
||||
request.profile = Profile.objects.for_user(request.user)
|
||||
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
hc/accounts/tests/test_remote_user_header_login.py
Normal file
56
hc/accounts/tests/test_remote_user_header_login.py
Normal 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="alice@example.org")
|
||||
self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/")
|
||||
|
||||
def test_it_logs_user_in(self):
|
||||
r = self.client.get("/accounts/profile/", AUTH_USER="alice@example.org")
|
||||
self.assertContains(r, "alice@example.org")
|
||||
|
||||
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="dave@example.org")
|
||||
self.assertContains(r, "dave@example.org")
|
||||
|
||||
q = User.objects.filter(email="dave@example.org")
|
||||
self.assertTrue(q.exists())
|
||||
|
||||
def test_it_logs_out_another_user_when_header_is_empty_string(self):
|
||||
self.client.login(remote_user_email="bob@example.org")
|
||||
|
||||
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="bob@example.org")
|
||||
|
||||
r = self.client.get("/accounts/profile/", AUTH_USER="alice@example.org")
|
||||
self.assertContains(r, "alice@example.org")
|
||||
|
||||
def test_it_handles_already_logged_in_user(self):
|
||||
self.client.login(remote_user_email="alice@example.org")
|
||||
|
||||
with patch("hc.accounts.middleware.auth") as mock_auth:
|
||||
r = self.client.get("/accounts/profile/", AUTH_USER="alice@example.org")
|
||||
|
||||
self.assertFalse(mock_auth.authenticate.called)
|
||||
self.assertContains(r, "alice@example.org")
|
@ -58,12 +58,14 @@ INSTALLED_APPS = (
|
||||
"hc.payments",
|
||||
)
|
||||
|
||||
|
||||
MIDDLEWARE = (
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"hc.accounts.middleware.CustomHeaderMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"django.middleware.locale.LocaleMiddleware",
|
||||
@ -75,6 +77,10 @@ AUTHENTICATION_BACKENDS = (
|
||||
"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"
|
||||
|
||||
TEMPLATES = [
|
||||
|
Loading…
x
Reference in New Issue
Block a user