Skip to content

Commit

Permalink
Basic token authentication with Django Rest Framework (#89)
Browse files Browse the repository at this point in the history
* First stab at token authentication

* Update docs

* Formalize Admin role

* Formalize ReadOnly role

* white

* Create groups in db migrations

* Update docs

* Move create_default_groups out of migration file

* Make warning blurb about running migrations

* Add group constants

* black
  • Loading branch information
WillNilges authored Dec 22, 2023
1 parent 091dcd0 commit 419d051
Show file tree
Hide file tree
Showing 5 changed files with 119 additions and 14 deletions.
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ python src/manage.py makemigrations
python src/manage.py migrate
```

> [!WARNING]
> If you are modifying the migrations and want to squash, be aware that there
> are hand-written migrations in `0016_create_default_groups.py` that will need
> to be dealt with manually. Recommended practice is to leave this migration
> separate and change the dependency to the new squashed file.
You'll probably want an admin account
```
python src/manage.py createsuperuser
Expand Down Expand Up @@ -118,6 +124,29 @@ should be available at `127.0.0.1:8080`:
curl http://127.0.0.1:8080/api/v1
```

### Auth Tokens

We have very simple permission levels.

- **Unauthenticated**: A user using a route without authenticating
- **Installer**: Can view all fields, provision NNs, and edit installs
- **Admin**: Full access

We use Django Rest Framework's basic Auth Token implementation. To add a token,
you need a user, which can be created at `/admin/auth/user/`.

To determine what permissions the user has, add them to one of the pre-existing groups.

(Superuser and Staff are DRF-specific and should be restricted to people maintaining
the instance)

Auth tokens can be created at `/admin/authtoken/tokenproxy/`.

To use them, you can include them as an HTTP header like so:
```
curl -X GET http://127.0.0.1:8000/api/v1/members/ -H 'Authorization: Token <auth_token>'
```

## Unit Tests

We use django's testing framework, based on `unittest`
Expand Down
21 changes: 21 additions & 0 deletions src/meshapi/migrations/0016_create_default_groups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 4.2.5 on 2023-12-22 01:20

from django.contrib.auth.models import Group
from django.db import migrations


# Make sure that the basic groups are created.
def create_default_groups(apps, schema_editor):
Group.objects.get_or_create(name="Installer")
Group.objects.get_or_create(name="Admin")
Group.objects.get_or_create(name="ReadOnly")


class Migration(migrations.Migration):
dependencies = [
("meshapi", "0001_squashed_0015_alter_building_bin"),
]

operations = [
migrations.RunPython(create_default_groups),
]
48 changes: 35 additions & 13 deletions src/meshapi/permissions.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
from django.contrib.auth import PermissionDenied
from rest_framework import permissions

INSTALLER_GROUP = "Installer"
ADMIN_GROUP = "Admin"
READONLY_GROUP = "ReadOnly"


def is_installer(user):
return user.groups.filter(name="Installer").exists()
return user.groups.filter(name=INSTALLER_GROUP).exists()


def is_admin(user):
return user.groups.filter(name=ADMIN_GROUP).exists()


def is_readonly(user):
return user.groups.filter(name=READONLY_GROUP).exists()


perm_denied_generic_msg = "You do not have access to this resource."
Expand All @@ -15,7 +27,7 @@ def has_permission(self, request, view):
if request.method == "GET":
return True
else:
if not request.user.is_superuser:
if not request.user.is_superuser or is_admin(request.user):
raise PermissionDenied(perm_denied_generic_msg)
return True

Expand All @@ -27,11 +39,11 @@ def has_permission(self, request, view):
if request.method == "GET":
return True
elif request.method == "PATCH":
if not (request.user.is_superuser or is_installer(request.user)):
if not (request.user.is_superuser or is_admin(request.user) or is_installer(request.user)):
raise PermissionDenied(perm_denied_generic_msg)
return True
else:
if not request.user.is_superuser:
if not request.user.is_superuser or is_admin(request.user):
raise PermissionDenied(perm_denied_generic_msg)
return True

Expand All @@ -40,11 +52,16 @@ def has_permission(self, request, view):
class MemberListCreatePermissions(permissions.BasePermission):
def has_permission(self, request, view):
if request.method == "GET":
if not (request.user.is_superuser or is_installer(request.user)):
if not (
request.user.is_superuser
or is_admin(request.user)
or is_installer(request.user)
or is_readonly(request.user)
):
raise PermissionDenied(perm_denied_generic_msg)
return True
else:
if not request.user.is_superuser:
if not request.user.is_superuser or is_admin(request.user):
raise PermissionDenied(perm_denied_generic_msg)
return True

Expand All @@ -53,11 +70,16 @@ def has_permission(self, request, view):
class MemberRetrieveUpdateDestroyPermissions(permissions.BasePermission):
def has_permission(self, request, view):
if request.method == "GET":
if not (request.user.is_superuser or is_installer(request.user)):
if not (
request.user.is_superuser
or is_admin(request.user)
or is_installer(request.user)
or is_readonly(request.user)
):
raise PermissionDenied(perm_denied_generic_msg)
return True
else:
if not request.user.is_superuser:
if not request.user.is_superuser or is_admin(request.user):
raise PermissionDenied(perm_denied_generic_msg)
return True

Expand All @@ -68,7 +90,7 @@ def has_permission(self, request, view):
if request.method == "GET":
return True
else:
if not (request.user.is_superuser or is_installer(request.user)):
if not (request.user.is_superuser or is_admin(request.user) or is_installer(request.user)):
raise PermissionDenied(perm_denied_generic_msg)
return True

Expand All @@ -80,11 +102,11 @@ def has_permission(self, request, view):
if request.method == "GET":
return True
elif request.method == "PATCH":
if not (request.user.is_superuser or is_installer(request.user)):
if not (request.user.is_superuser or is_admin(request.user) or is_installer(request.user)):
raise PermissionDenied(perm_denied_generic_msg)
return True
else:
if not request.user.is_superuser:
if not request.user.is_superuser or is_admin(request.user):
raise PermissionDenied(perm_denied_generic_msg)
return True

Expand All @@ -95,7 +117,7 @@ def has_permission(self, request, view):
if request.method == "GET":
return True
else:
if not request.user.is_superuser:
if not request.user.is_superuser or is_admin(request.user):
raise PermissionDenied(perm_denied_generic_msg)
return True

Expand All @@ -106,6 +128,6 @@ def has_permission(self, request, view):
if request.method == "GET":
return True
else:
if not request.user.is_superuser:
if not request.user.is_superuser or is_admin(request.user):
raise PermissionDenied(perm_denied_generic_msg)
return True
25 changes: 25 additions & 0 deletions src/meshapi/tests/test_views_get.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.test import TestCase, Client
from django.contrib.auth.models import User, Group
from rest_framework.authtoken.models import Token


class TestViewsGetUnauthenticated(TestCase):
Expand Down Expand Up @@ -82,3 +83,27 @@ def test_views_get_admin(self):
response.status_code,
f"status code incorrect for {route}. Should be {code}, but got {response.status_code}",
)

def test_views_get_admin_token(self):
t = Client()
token = Token.objects.create(user=self.admin_user)

routes = [
("/api/v1/", 200),
("/api/v1", 301),
("/api/v1/buildings/", 200),
("/api/v1/members/", 200),
("/api/v1/installs/", 200),
("/api/v1/requests/", 200),
]

for route, code in routes:
response = t.get(
route,
HTTP_AUTHORIZATION=f"Token {token.key}",
)
self.assertEqual(
code,
response.status_code,
f"status code incorrect for {route}. Should be {code}, but got {response.status_code}",
)
10 changes: 9 additions & 1 deletion src/meshdb/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,12 @@

DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

REST_FRAMEWORK = {"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", "PAGE_SIZE": 100}
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 100,
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.BasicAuthentication",
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication",
],
}

0 comments on commit 419d051

Please sign in to comment.