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_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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
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",
|
"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…
x
Reference in New Issue
Block a user