Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add http header auth #457

Merged
merged 7 commits into from Dec 9, 2020
Merged

Add http header auth #457

merged 7 commits into from Dec 9, 2020

Conversation

Phyxius
Copy link
Contributor

@Phyxius Phyxius commented Dec 3, 2020

I wanted to self-host healthchecks and integrate it with my central authentication system (see #185), so rather than develop something specific to my needs, I added support for HTTP header-based authentication. This way, people can integrate whatever auth system they want (LDAP, mTLS, SAML, OAuth, whatever) at the reverse proxy level and remove the need for healthchecks to care about the implementation details.

I added two new settings (with corresponding environment variables):

  1. REMOTE_USER_HEADER — set this to the 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.
  2. REMOTE_USER_HEADER_TYPE — If set to EMAIL, the specified header will be treated as the user's email. If set to ID, the specified header will be set to the user's UUID. Any other value (including empty, the default) disables header-based authentication.

@coveralls
Copy link

coveralls commented Dec 3, 2020

Coverage Status

Coverage increased (+0.05%) to 89.478% when pulling 1d58dc4 on Phyxius:add-http-header-auth into 70ef9c1 on healthchecks:master.

@cuu508
Copy link
Member

cuu508 commented Dec 3, 2020

Thanks for the PR, I think this is an useful feature and a clever way to offload authentication to a reverse proxy or middleware.

It would be good to have test cases that exercise the code in CustomHeaderBackend, would you be able to add them?

if REMOTE_USER_HEADER_TYPE not in ["EMAIL", "ID"]: REMOTE_USER_HEADER_TYPE = None

^ I'd prefer the code to complain loudly here – print a message saying REMOTE_USER_HEADER_TYPE has an invalid value, so the user knows something's up.

@Phyxius
Copy link
Contributor Author

Phyxius commented Dec 4, 2020

Added the warning as requested.

Tests for the custom header backend is also a good idea, I'll get to that this weekend probably. The most important test will be to make sure it doesn't accept logins when it's supposed to be disabled.

@Phyxius
Copy link
Contributor Author

Phyxius commented Dec 5, 2020

And there we go :)
Let me know if you think it needs anything else.

@cuu508
Copy link
Member

cuu508 commented Dec 7, 2020

Thanks, looking into this now! It takes a bit of time to wrap the head around how all the pieces fit together.

First a small observation: "ID" matches the user by username, "EMAIL" matches by email. For the sake of consistency, I'd rename "ID" to "USERNAME". There's an id column in the auth_user table, and it may get confusing.

Next, it took me a bit of time to understand how CustomHeaderBackend play together with RemoteUserBackend, and CustomHeaderMiddleware with RemoteUserMiddleware and all the implications. For CustomHeaderBackend it may be cleaner and more readable to subclass from BasicBackend and reimplement the needed bits. Something like:


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):
        """
        The username/email passed as ``remote_user`` is considered trusted. Return
        the ``User`` object with the given username. Create a new ``User``
        if it does not exist.

        """

        if settings.REMOTE_USER_HEADER_TYPE not in ("USERNAME", "EMAIL"):
            # header-based authentication is disabled
            return

        if not remote_user:
            # remote_user not passed, cannot use this authentication method
            return

        if settings.REMOTE_USER_HEADER_TYPE == "USERNAME":
            user, created = User.objects.get_or_create(username=remote_user)
        elif settings.REMOTE_USER_HEADER_TYPE == "EMAIL":
            user, created = User.objects.get_or_create(email=remote_user)

        if created:
            if settings.REMOTE_USER_HEADER_TYPE == "USERNAME":
                # Emails must be unique--
                user.email = "FIXME"  # not sure what to set it to!
            elif settings.REMOTE_USER_HEADER_TYPE == "EMAIL":
                user.username = str(uuid.uuid4())[:30]

            user.set_unusable_password()
            user.save()

            # Ensure a profile gets created
            Profile.objects.for_user(user)

        return user

RemoteUserBackend by default creates an user if the user doesn't already exist. I suppose that's handy feature when using external authentication, so my attempted reimplementation does so as well. But a problem I ran into is – if the user is authenticating by username, and gets created, what do we set the email address to? Everywhere else, Healthchecks assumes that all users have unique email addresses.

For your use case, are you planning to authenticate by username or by email? If it's by email, perhaps we can start off with supporting just the 'EMAIL' mode?

In settings.py, if there's a misconfiguration I think it would be fair to refuse to start:

REMOTE_USER_HEADER = os.getenv("REMOTE_USER_HEADER", "AUTH_USER")
REMOTE_USER_HEADER_TYPE = os.getenv("REMOTE_USER_HEADER_TYPE", "").upper()
if REMOTE_USER_HEADER_TYPE not in ["EMAIL", "USERNAME", ""]:
    raise ImproperlyConfigured(
        f"Unknown REMOTE_USER_HEADER_TYPE '{REMOTE_USER_HEADER_TYPE}'!"
        "Supported values: 'USERNAME', 'EMAIL'."
    )
if REMOTE_USER_HEADER_TYPE == "":
    REMOTE_USER_HEADER_TYPE = None

@Phyxius
Copy link
Contributor Author

Phyxius commented Dec 7, 2020

Yep, my use case uses the email mode. I mostly included the ID mode because it was the behavior my initial attempt (using the default backend) produced and I figured I may as well preserve access to it. I can see how not having an email available might be a problem, but I’m not sure the best way to handle it.

The best I can come up with would be to allow the admin to specify either a shell command or a Python snippet to execute to retrieve the email from the ID. An example use case might be if the ID header is an LDAP UID, to run ldapsearch to lookup their email address. I’m always wary of introducing features that function as code execution by design, but I can’t think of an alternative that remains as useful as this.

@cuu508
Copy link
Member

cuu508 commented Dec 8, 2020

Rather than looking up emails in an external system, I'd rather relax the "everyone has an unique email address" assumption. Instead, treat User.email=None as "we don't know this user's email address" and make sure everything works correctly and sensibly in that case.

But I don't want to do that right now – ;et's start with the 'EMAIL' mode only, and look into adding a ID/USERNAME mode when/if there is a specific need for it.

I can take your PR, edit the 'ID' mode out it and merge – is that OK with you?

Phyxius and others added 7 commits December 8, 2020 13:33
Add extra header type sanity check to the backend
- remove the 'ID' mode
- add CustomHeaderBackend to AUTHENTICATION_BACKENDS conditionally
- rewrite CustomHeaderBackend and CustomHeaderMiddleware to
use less inherited code
- add more test cases
@Phyxius
Copy link
Contributor Author

Phyxius commented Dec 8, 2020

Works for me :)
Thanks!

@cuu508 cuu508 merged commit 54a95a0 into healthchecks:master Dec 9, 2020
@cuu508 cuu508 mentioned this pull request Dec 9, 2020
@Phyxius Phyxius deleted the add-http-header-auth branch December 11, 2020 07:41
@mumrau
Copy link

mumrau commented Mar 11, 2021

@Phyxius Sorry to bring this up here but I couldn't find a more suitable place.

I can't get the authentication to work, the doc is quite unclear regarding the setup:

  • can I activate REMOTE_USER_HEADER on an already populated instance?
  • do I need to prefix with HTTP? example REMOTE_USER_HEADER: HTTP_My_Custom_Header

I know my setup with treafik works and the Custom-Header is propagated, thanks to multiple service working with this, and with the incredible whoami container which provides visibility over all this.

@cuu508
Copy link
Member

cuu508 commented Mar 11, 2021

@mumrau Let's assume Traefik adds a header: Custom-Header: alice@example.org

In Healthchecks, you would set REMOTE_USER_HEADER=HTTP_CUSTOM_HEADER (prefixed with "HTTP_", converted to upper case, dash converted to underscore).

With that in place, if you visit the site you should then get automatically logged in as alice@example.org.

As mentioned in the docs, make sure clients cannot pass Custom-Header themselves. If they can, they can impersonate any user.

@dharmendrakariya
Copy link

hi @cuu508 I am setting the header like this REMOTE_USER_HEADER: "HTTP_X_CUSTOM_HEADER_EMAIL" and when I send the request with this header like Key: X-Custom-Header-Email, Value: xyz@abc.com (note I am using postman to send this header) but in response I am not getting logged in.

@axelcypher
Copy link

axelcypher commented Mar 25, 2022

hi @cuu508 I am setting the header like this REMOTE_USER_HEADER: "HTTP_X_CUSTOM_HEADER_EMAIL" and when I send the request with this header like Key: X-Custom-Header-Email, Value: xyz@abc.com (note I am using postman to send this header) but in response I am not getting logged in.

Similar issue here. I use Authentik with Traefik. So my header should be REMOTE_USER_HEADER="HTTP_X_AUTHENTIK_EMAIL", but that doesn't work. I'm not getting logged in either. Did you managed to solve this?

I also use the same setup on HomeAssistant with header authentication. So i know the header gets propagated. Also tried it with static custom traefik headers.

@cuu508
Copy link
Member

cuu508 commented Mar 25, 2022

As an experiment, I just started the development server like so:

REMOTE_USER_HEADER=HTTP_X_AUTHENTIK_EMAIL ./manage.py runserver

And I then made a curl call like so:

curl -H "X-Authentik-Email:foo@example.org" http://localhost:8000

I got back a HTML response of the logged in page listing user's projects. So that seemed to work.

@dharmendrakariya, @axelcypher you could try the same experiment: make a curl call directly to the healthchecks instance, bypassing Traefik, and passing in the X-Authentik-Email header manually, and see what you get back. This will tell you if Traefik is not passing the header, or if Healthchecks is not handling it as expected.

@dharmendrakariya
Copy link

hi @cuu508 I am setting the header like this REMOTE_USER_HEADER: "HTTP_X_CUSTOM_HEADER_EMAIL" and when I send the request with this header like Key: X-Custom-Header-Email, Value: xyz@abc.com (note I am using postman to send this header) but in response I am not getting logged in.

Similar issue here. I use Authentik with Traefik. So my header should be REMOTE_USER_HEADER="HTTP_X_AUTHENTIK_EMAIL", but that doesn't work. I'm not getting logged in either. Did you managed to solve this?

I am sorry but I am out of this, been a year and honestly I don't remember what I was trying to achieve.
I guess I was testing Pomerium and wanted to test the proxy authentication.

@cuu508
Copy link
Member

cuu508 commented Mar 25, 2022

@dharmendrakariya sorry – didn't realize your comment is 1 year old when I tagged you!

@dharmendrakariya
Copy link

@cuu508 I confirm, its working as you said.

image

@axelcypher
Copy link

As an experiment, I just started the development server like so:

REMOTE_USER_HEADER=HTTP_X_AUTHENTIK_EMAIL ./manage.py runserver

And I then made a curl call like so:

curl -H "X-Authentik-Email:foo@example.org" http://localhost:8000

I got back a HTML response of the logged in page listing user's projects. So that seemed to work.

@dharmendrakariya, @axelcypher you could try the same experiment: make a curl call directly to the healthchecks instance, bypassing Traefik, and passing in the X-Authentik-Email header manually, and see what you get back. This will tell you if Traefik is not passing the header, or if Healthchecks is not handling it as expected.

Thanks for the fast reply. I should mention that i use the docker image. Tried to test it with curl, but same result. I'm not getting logged in... could that have something to do with the docker version?

@dharmendrakariya
Copy link

dharmendrakariya commented Mar 25, 2022

I confirm @cuu508 , it works behind proxy(Traefik) as well.

image

You can try the same. just FYI I am adding my traefik values

- --certificatesresolvers.le.acme.storage=/data/acme.json
- --certificatesresolvers.le.acme.caserver=https://acme-v02.api.letsencrypt.org/directory
- --entryPoints.web.forwardedHeaders.insecure

Let me know if you want my ingressRoute file. @axelcypher

@axelcypher
Copy link

axelcypher commented Mar 25, 2022

I would appreciate that @dharmendrakariya . Althrough i don't use kubernetics, it could help troubleshooting my configs.
yeah, i'm using a similar traefik setup. I've updated my docker image to the latest version and now i'm not getting a response from curl. It's so frustrating... And mostly it's the same issue. I forget to set one line in a config or make a typo and realize it hours later

### EDIT:
nevermind. it was the image i was using. created a new one and now it's working

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants