Skip to content

Commit

Permalink
add authentication, close #11
Browse files Browse the repository at this point in the history
* feature: add auth settings to aetos

* feature: enable auth checks in `export_metrics` view

* test: add e2e tests for auth checks in `export_metrics` view

* change: add vscode folders to gitignore

* fix: settings as properties in `AppSettings` class

to be able to change them during runtime, this is important for tests

* change: linting all changed files in this branch

* change: linted test cases

* change: differentiated response when auth or ip validation fails

* docs: added tiny info for usage behind reverse proxy

---------

Co-authored-by: Bokan Mohammad Assad <[email protected]>
  • Loading branch information
berthaottokarl and Bokan Mohammad Assad committed Aug 21, 2024
1 parent 853baf1 commit 50df023
Show file tree
Hide file tree
Showing 6 changed files with 259 additions and 17 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

# VSCode
.vscode/

### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
Expand Down
15 changes: 15 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ then, add the app to `settings.py`:
# ... other apps ...
]
configure aetos in `settings.py`:

> ℹ️ **Important**: When using `django-aetos` in a project behind a reverse proxy, include [`django-xff`](https://pypi.org/project/django-xff/) in your project, so that a request's `REMOTE_ADDR` header gets rewritten to the correct client ip.

.. code-block:: python
# on enabled ip allowlist with empty list, requests are denied
AETOS_ENABLE_IP_ALLOWLIST = True
AETOS_IP_ALLOWLIST = ["127.0.0.1"]
# enables authentication via bearer token
# if enabled with empty list, requests are denied
AETOS_ENABLE_AUTH = True
AETOS_AUTH_TOKENS = ["ooy9Evuth0zahka"]
and send requests to `/metrics` to Aetos in your `urls.py`:

.. code-block:: python
Expand Down
2 changes: 2 additions & 0 deletions example_project/example_project/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,5 @@
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field

DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

# WARNING: Do not set AETOS_* settings, otherwise tests fail.
162 changes: 154 additions & 8 deletions example_project/tests.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,162 @@
import pytest
from django.test import override_settings


@pytest.mark.django_db
def test_e2e(client):
resp = client.get("/metrics")
assert (
resp.content.decode()
== """# HELP books_count Total number of books
expected_output = """# HELP books_count Total number of books
# TYPE books_count counter
books_count 0
# HELP universes_count Total number of universes
# TYPE universes_count counter
universes_count 1
"""
)


@pytest.mark.django_db
def test_e2e(client):
resp = client.get("/metrics")
assert resp.content.decode() == expected_output


@override_settings(
AETOS_ENABLE_IP_ALLOWLIST=True,
AETOS_IP_ALLOWLIST=["127.0.0.1"],
AETOS_ENABLE_AUTH=True,
AETOS_AUTH_TOKENLIST=["AhGei6ohghooDae"],
)
def test_settings():
from django_aetos import app_settings

assert app_settings.ENABLE_IP_ALLOWLIST
assert app_settings.IP_ALLOWLIST == ["127.0.0.1"]
assert app_settings.ENABLE_AUTH
assert app_settings.AUTH_TOKENLIST == ["AhGei6ohghooDae"]


def test_settings_defaults():
from django_aetos import app_settings

assert not app_settings.ENABLE_IP_ALLOWLIST
assert app_settings.IP_ALLOWLIST == []
assert not app_settings.ENABLE_AUTH
assert app_settings.AUTH_TOKENLIST == []


@pytest.mark.django_db
@override_settings(AETOS_ENABLE_IP_ALLOWLIST=True, AETOS_IP_ALLOWLIST=["127.0.0.1"])
def test_enable_allowed_ips(client):
resp = client.get("/metrics")
assert resp.content.decode() == expected_output


@pytest.mark.django_db
@override_settings(AETOS_ENABLE_IP_ALLOWLIST=True, AETOS_IP_ALLOWLIST=["255.0.0.1"])
def test_enable_allowed_ips_not_allowed(client):
resp = client.get("/metrics")
assert resp.content.decode() == "IP not allowed"
assert resp.status_code == 401


@pytest.mark.django_db
@override_settings(AETOS_ENABLE_AUTH=True, AETOS_AUTH_TOKENLIST=["aquee4ro4Theeth"])
def test_enable_auth(client):
resp = client.get("/metrics", headers={"Authorization": "Bearer aquee4ro4Theeth"})
assert resp.content.decode() == expected_output


@pytest.mark.django_db
@override_settings(AETOS_ENABLE_AUTH=True, AETOS_AUTH_TOKENLIST=["aquee4ro4Theeth"])
def test_enable_auth_token_not_allowed(client):
resp = client.get("/metrics", headers={"Authorization": "Bearer wr0ngt0kenf"})
assert resp.content.decode() == "Invalid auth token"
assert resp.status_code == 401


@pytest.mark.django_db
@override_settings(
AETOS_ENABLE_IP_ALLOWLIST=True,
AETOS_IP_ALLOWLIST=["127.0.0.1"],
AETOS_ENABLE_AUTH=True,
AETOS_AUTH_TOKENLIST=["aquee4ro4Theeth"],
)
def test_enable_all(client):
resp = client.get("/metrics", headers={"Authorization": "Bearer aquee4ro4Theeth"})
assert resp.content.decode() == expected_output


@pytest.mark.django_db
@override_settings(
AETOS_ENABLE_IP_ALLOWLIST=True,
AETOS_IP_ALLOWLIST=["255.0.0.1"],
AETOS_ENABLE_AUTH=True,
AETOS_AUTH_TOKENLIST=["aquee4ro4Theeth"],
)
def test_enable_all_wrong_ip(client):
resp = client.get("/metrics", headers={"Authorization": "Bearer aquee4ro4Theeth"})
assert resp.content.decode() == "IP not allowed"
assert resp.status_code == 401


@pytest.mark.django_db
@override_settings(
AETOS_ENABLE_IP_ALLOWLIST=True,
AETOS_IP_ALLOWLIST=["127.0.0.1"],
AETOS_ENABLE_AUTH=True,
AETOS_AUTH_TOKENLIST=["aquee4ro4Theeth"],
)
def test_enable_all_wrong_token(client):
resp = client.get("/metrics", headers={"Authorization": "Bearer wr0ngt0ken"})
assert resp.content.decode() == "Invalid auth token"
assert resp.status_code == 401


@pytest.mark.django_db
@override_settings(
AETOS_ENABLE_IP_ALLOWLIST=True,
AETOS_IP_ALLOWLIST=["255.0.0.1"],
AETOS_ENABLE_AUTH=True,
AETOS_AUTH_TOKENLIST=["aquee4ro4Theeth"],
)
def test_enable_all_wrong_token_ip(client):
resp = client.get("/metrics", headers={"Authorization": "Bearer wr0ngt0ken"})
assert resp.content.decode() == "Invalid auth token and IP not allowed"
assert resp.status_code == 401


@pytest.mark.django_db
@override_settings(
AETOS_ENABLE_IP_ALLOWLIST=True, AETOS_ENABLE_AUTH=True, AETOS_AUTH_TOKENLIST=["aquee4ro4Theeth"]
)
def test_enable_all_empty_ip(client):
resp = client.get("/metrics", headers={"Authorization": "Bearer aquee4ro4Theeth"})
assert resp.content.decode() == "IP not allowed"
assert resp.status_code == 401


@pytest.mark.django_db
@override_settings(
AETOS_ENABLE_IP_ALLOWLIST=True, AETOS_IP_ALLOWLIST=["127.0.0.1"], AETOS_ENABLE_AUTH=True
)
def test_enable_all_empty_token(client):
resp = client.get("/metrics", headers={"Authorization": "Bearer aquee4ro4Theeth"})
assert resp.content.decode() == "Invalid auth token"
assert resp.status_code == 401


@pytest.mark.django_db
@override_settings(AETOS_ENABLE_IP_ALLOWLIST=True, AETOS_ENABLE_AUTH=True)
def test_enable_all_empty_token_ip(client):
resp = client.get("/metrics", headers={"Authorization": "Bearer aquee4ro4Theeth"})
assert resp.content.decode() == "Invalid auth token and IP not allowed"
assert resp.status_code == 401


@pytest.mark.django_db
@override_settings(
AETOS_ENABLE_IP_ALLOWLIST=True,
AETOS_IP_ALLOWLIST=["127.0.0.1"],
AETOS_ENABLE_AUTH=True,
AETOS_AUTH_TOKENLIST=["aquee4ro4Theeth"],
)
def test_enable_all_wrong_auth_header(client):
resp = client.get("/metrics", headers={"Authorization": "Basic aquee4ro4Theeth"})
assert resp.content.decode() == "Invalid auth token"
assert resp.status_code == 401
32 changes: 32 additions & 0 deletions src/django_aetos/app_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
class AppSettings:
def __init__(self, prefix):
self.prefix = prefix

def _setting(self, name, dflt):
from django.conf import settings

return getattr(settings, self.prefix + name, dflt)

@property
def ENABLE_IP_ALLOWLIST(self):
return self._setting("ENABLE_IP_ALLOWLIST", False)

@property
def IP_ALLOWLIST(self):
return self._setting("IP_ALLOWLIST", [])

@property
def ENABLE_AUTH(self):
return self._setting("ENABLE_AUTH", False)

@property
def AUTH_TOKENLIST(self):
return self._setting("AUTH_TOKENLIST", [])


_app_settings = AppSettings("AETOS_")


def __getattr__(name):
# See https://peps.python.org/pep-0562/
return getattr(_app_settings, name)
62 changes: 53 additions & 9 deletions src/django_aetos/views.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import re

from django.http import HttpResponse
from django.shortcuts import render
from django_aetos import app_settings

from .signals import collect_metrics


def export_metrics(request):
def collect_response():
metrics = []
known_metrics = set()

Expand All @@ -19,11 +21,53 @@ def export_metrics(request):
known_metrics.add(metric["name_without_labels"])
metrics.append(metric)

response = render(
request,
"metrics/export.txt",
context={"metrics": metrics},
content_type="text/plain",
)
response.content = re.sub(b"\n+", b"\n", response.content)
return response
return metrics


def check_ip(request):
ip_address = request.META["REMOTE_ADDR"]
allowed_ips = app_settings.IP_ALLOWLIST

if app_settings.ENABLE_IP_ALLOWLIST is True and ip_address in allowed_ips:
return True
elif app_settings.ENABLE_IP_ALLOWLIST is False:
return True
else:
return False


def check_auth(request):
try:
authorization_header = request.META["HTTP_AUTHORIZATION"]
except KeyError:
authorization_header = ""
type, sep, token = authorization_header.partition(" ")
allowed_tokens = app_settings.AUTH_TOKENLIST

if app_settings.ENABLE_AUTH is True and type == "Bearer" and token in allowed_tokens:
return True
elif app_settings.ENABLE_AUTH is False:
return True
else:
return False


def export_metrics(request):
validated_ip = check_ip(request)
validated_auth = check_auth(request)
if validated_auth and validated_ip:
response = render(
request,
"metrics/export.txt",
context={"metrics": collect_response()},
content_type="text/plain",
)
response.content = re.sub(b"\n+", b"\n", response.content)

return response
elif not validated_auth and validated_ip:
return HttpResponse("Invalid auth token", status=401)
elif validated_auth and not validated_ip:
return HttpResponse("IP not allowed", status=401)
else:
return HttpResponse("Invalid auth token and IP not allowed", status=401)

0 comments on commit 50df023

Please sign in to comment.