Use Django Auth to Secure Any Application

Categories: Django

Django comes with most authentication and authorization concerns out of the box including sessions, cookie signing, password handling, login forms, users, groups, and permissions. Most other software does not (and should not have to) bother with all this. Here I'll be securing a self-hosted VictoriaLogs instance by putting it behind the Django admin login using Caddy's forward_auth.


The Goal

I have www.sleppytech.com which is served by a Django application. Its logs are being sent to a self-hosted VictoriaLogs instance using the technique I describe here.

Since VictoriaLogs doesn't have built-in authentication, I had it listen only to 127.0.0.1 so strangers can't browse my logs. Unfortunately it means I had to SSH tunnel into the server to see them, too. This is a little extra friction on my desktop, but a lot of extra friction when trying to view logs from my phone. I wanted a way to host the logs UI on the internet while also keeping others out.

Caddy's forward_auth

Caddy's forward_auth allows one service to authenticate for another. It's like a much-simplified OpenID or OAuth, described here in the Caddy docs. Here's how I would describe it quickly:

Caddy's forward_auth flow

  1. The client makes a request to Logs that first reaches Caddy
  2. Caddy sends a nearly identical request to Django for authorization
  3. If Django says OK, the original request is reverse proxied to Logs
  4. The response from Logs is sent to the client

If Django responds with a 2xx status code, the client is authorized to make its original request. If any other status code is returned, then the response from Django is returned to the client. This could be a 401 Unauthorized, or maybe a redirect to a login page instead.

My solution

I have Django running at www.sleppytech.com and logs served from logs.sleppytech.com. Here's the Caddy configuation:

# Caddyfile
logs.sleppytech.com {
	forward_auth * sleppytech:8080 {
		uri /forward_auth/logs
	}
	reverse_proxy victorialogs:9428
	log {
		output stdout
	}
}

This references Docker-provided internal hostnames instead of the DNS names above, but you can see what's going on.

On the Django side we need to make that endpoint exist:

# urls.py
urlpatterns = [
    # ...
    path("forward_auth/logs", views.forward_auth_logs, name="forward_auth_logs"),
]

That URL leads to this view function:

# views.py
from django.conf import settings
from django.contrib.auth.views import redirect_to_login
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.urls import reverse

def forward_auth_logs(request: HttpRequest) -> HttpResponse:
    if request.user.has_perm("forward_auth_logs"):
        return JsonResponse({"ok": True})

    next_url = request.headers.get("X-Forwarded-Uri")
    if not next_url:
        logger.warning(
            "Could not find X-Forwarded-Uri on request, headers are: %s",
            list(request.headers),
        )
        next_url = "/"

    login_url = reverse("admin:login")
    return redirect_to_login(
        f"https://logs.{settings.DOMAIN_NAME}{next_url}",
        login_url=f"https://www.{settings.DOMAIN_NAME}{login_url}",
    )

I'm referencing the non-existing forward_auth_logs permission. That means any superuser will pass the check, and any other user (or an unauthenticated AnonymousUser) will not. It doesn't matter what we return in the body for that case, so I chose a small JSON response.

The rest of the view redirects an unauthorized user to the Django admin login page. You could use your own hand-rolled login page to present to real users, but I'm the only authenticated user of this site so I'm taking the easy way by reusing the one that Django gives me out of the box.

The real icing on the cake is the next_url that will redirect me to my original requested destination (the logging UI) after I log in successfully.

That's almost the whole story except for the fact that the Django app domain (www.) does not match the logs domain (logs.). We need the client to send their Django-given session cookie to the request to logs. even when it was set by www.. So we set the session cookie's domain to be sleppytech.com which means the cookie will go to any subdomain including logs..

# settings.py
SESSION_COOKIE_DOMAIN = "sleppytech.com"

Finally, the LoginView itself does not like to redirect us to another host in a vanilla Django. We need to tell it what other hosts it's allowed to redirect users to. There's no nice way to do this, so I'm monkeypatching LoginView in an app config:

# apps.py
class AppConfig(DjangoAppConfig):
    # ...

    def ready(self):
        from django.contrib.auth.views import LoginView
        LoginView.success_url_allowed_hosts = {f"logs.{settings.DOMAIN_NAME}"}

And now I can stop SSH port forwarding just to read my application logs! Even on my mobile device I can visit the logs UI, make a pit stop to log into the Django admin, and then see what I want to see.

What else?

If Django can do this, I bet it could take the place of a Keycloak-type self-hosted and infinitely configurable OpenID identity provider. If you needed that sort of thing.