Skip to content

Commit 5ab81b9

Browse files
authored
feat: add generic views for API key management in aboutcode.api_auth module (#500)
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent 9504701 commit 5ab81b9

File tree

7 files changed

+259
-141
lines changed

7 files changed

+259
-141
lines changed

aboutcode/api_auth/README.md

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ $ ./manage.py migrate
3232
Declare your `APIToken` model location in the `API_TOKEN_MODEL` setting:
3333

3434
```python
35-
API_TOKEN_MODEL = "app.APIToken" # noqa: S105
35+
API_TOKEN_MODEL = "your_app.APIToken" # noqa: S105
3636
```
3737

3838
Declare the `APITokenAuthentication` authentication class as one of the
@@ -45,3 +45,44 @@ REST_FRAMEWORK = {
4545
),
4646
}
4747
```
48+
49+
### Views (optional)
50+
51+
Base views are provided for generating and revoking API keys.
52+
They handle the token operations and redirect with a success message.
53+
54+
Subclass them in your app to add authentication requirements and configure
55+
the success URL and message:
56+
57+
```python
58+
from django.contrib.auth.mixins import LoginRequiredMixin
59+
from django.urls import reverse_lazy
60+
61+
from aboutcode.api_auth.views import BaseGenerateAPIKeyView
62+
from aboutcode.api_auth.views import BaseRevokeAPIKeyView
63+
64+
65+
class GenerateAPIKeyView(LoginRequiredMixin, BaseGenerateAPIKeyView):
66+
success_url = reverse_lazy("profile")
67+
success_message = (
68+
"Copy your API key now, it will not be shown again: <pre>{plain_key}</pre>"
69+
)
70+
71+
72+
class RevokeAPIKeyView(LoginRequiredMixin, BaseRevokeAPIKeyView):
73+
success_url = reverse_lazy("profile")
74+
success_message = "API key revoked."
75+
```
76+
77+
Wire them up in your `urls.py`:
78+
79+
```python
80+
from your_app.views import GenerateAPIKeyView
81+
from your_app.views import RevokeAPIKeyView
82+
83+
urlpatterns = [
84+
...
85+
path("profile/api_key/generate/", GenerateAPIKeyView.as_view(), name="generate-api-key"),
86+
path("profile/api_key/revoke/", RevokeAPIKeyView.as_view(), name="revoke-api-key"),
87+
]
88+
```

aboutcode/api_auth/__init__.py

Lines changed: 5 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -6,126 +6,10 @@
66
# See https://aboutcode.org for more information about AboutCode FOSS projects.
77
#
88

9-
import secrets
9+
from aboutcode.api_auth.auth import APITokenAuthentication
10+
from aboutcode.api_auth.models import AbstractAPIToken
11+
from aboutcode.api_auth.models import get_api_token_model
1012

11-
from django.apps import apps as django_apps
12-
from django.conf import settings
13-
from django.contrib.auth.hashers import check_password
14-
from django.contrib.auth.hashers import make_password
15-
from django.core.exceptions import ImproperlyConfigured
16-
from django.db import models
17-
from django.utils.translation import gettext_lazy as _
13+
__version__ = "0.2.0"
1814

19-
from rest_framework.authentication import TokenAuthentication
20-
from rest_framework.exceptions import AuthenticationFailed
21-
22-
__version__ = "0.1.0"
23-
24-
25-
class AbstractAPIToken(models.Model):
26-
"""
27-
API token using a lookup prefix and PBKDF2 hash for secure verification.
28-
29-
The full key is never stored. Only a short plain-text prefix is kept for
30-
DB lookup, and a hashed version of the full key is stored for verification.
31-
The plain key is returned once at generation time and must be stored safely
32-
by the client.
33-
"""
34-
35-
PREFIX_LENGTH = 8
36-
37-
key_hash = models.CharField(
38-
max_length=128,
39-
)
40-
user = models.OneToOneField(
41-
settings.AUTH_USER_MODEL,
42-
related_name="api_token",
43-
on_delete=models.CASCADE,
44-
)
45-
prefix = models.CharField(
46-
max_length=PREFIX_LENGTH,
47-
unique=True,
48-
db_index=True,
49-
)
50-
created = models.DateTimeField(
51-
auto_now_add=True,
52-
db_index=True,
53-
)
54-
55-
class Meta:
56-
abstract = True
57-
58-
def __str__(self):
59-
return f"APIToken {self.prefix}... ({self.user})"
60-
61-
@classmethod
62-
def generate_key(cls):
63-
"""Generate a plain (not encrypted) key."""
64-
return secrets.token_hex(32)
65-
66-
@classmethod
67-
def create_token(cls, user):
68-
"""Generate a new token for the given user and return the plain key once."""
69-
plain_key = cls.generate_key()
70-
prefix = plain_key[: cls.PREFIX_LENGTH]
71-
cls.objects.create(
72-
user=user,
73-
prefix=prefix,
74-
key_hash=make_password(plain_key),
75-
)
76-
return plain_key
77-
78-
@classmethod
79-
def verify(cls, plain_key):
80-
"""Return the token instance if the plain key is valid, None otherwise."""
81-
if not plain_key:
82-
return
83-
84-
prefix = plain_key[: cls.PREFIX_LENGTH]
85-
token = cls.objects.filter(prefix=prefix).select_related("user").first()
86-
87-
if token and check_password(plain_key, token.key_hash):
88-
return token
89-
90-
@classmethod
91-
def regenerate(cls, user):
92-
"""Delete any existing token instance for the user and generate a new one."""
93-
cls.objects.filter(user=user).delete()
94-
return cls.create_token(user)
95-
96-
@classmethod
97-
def revoke(cls, user):
98-
"""Delete any existing token instance for the user."""
99-
return cls.objects.filter(user=user).delete()
100-
101-
102-
class APITokenAuthentication(TokenAuthentication):
103-
"""
104-
Token authentication using a hashed API token for secure verification.
105-
106-
Extends Django REST Framework's TokenAuthentication, replacing the plain-text lookup
107-
with a prefix-based lookup and PBKDF2 hash verification.
108-
"""
109-
110-
model = None
111-
112-
def get_model(self):
113-
if self.model is not None:
114-
return self.model
115-
116-
try:
117-
return django_apps.get_model(settings.API_TOKEN_MODEL)
118-
except (ValueError, LookupError):
119-
raise ImproperlyConfigured("API_TOKEN_MODEL must be of the form 'app_label.model_name'")
120-
121-
def authenticate_credentials(self, plain_key):
122-
model = self.get_model()
123-
token = model.verify(plain_key)
124-
125-
if token is None:
126-
raise AuthenticationFailed(_("Invalid token."))
127-
128-
if not token.user.is_active:
129-
raise AuthenticationFailed(_("User inactive or deleted."))
130-
131-
return (token.user, token)
15+
__all__ = ["APITokenAuthentication", "AbstractAPIToken", "get_api_token_model"]

aboutcode/api_auth/auth.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# DejaCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: AGPL-3.0-only
5+
# See https://github.com/aboutcode-org/dejacode for support or download.
6+
# See https://aboutcode.org for more information about AboutCode FOSS projects.
7+
#
8+
9+
from django.utils.translation import gettext_lazy as _
10+
11+
from rest_framework.authentication import TokenAuthentication
12+
from rest_framework.exceptions import AuthenticationFailed
13+
14+
from aboutcode.api_auth.models import get_api_token_model
15+
16+
17+
class APITokenAuthentication(TokenAuthentication):
18+
"""
19+
Token authentication using a hashed API token for secure verification.
20+
21+
Extends Django REST Framework's TokenAuthentication, replacing the plain-text lookup
22+
with a prefix-based lookup and PBKDF2 hash verification.
23+
"""
24+
25+
model = None
26+
27+
def get_model(self):
28+
if self.model is not None:
29+
return self.model
30+
return get_api_token_model()
31+
32+
def authenticate_credentials(self, plain_key):
33+
model = self.get_model()
34+
token = model.verify(plain_key)
35+
36+
if token is None:
37+
raise AuthenticationFailed(_("Invalid token."))
38+
39+
if not token.user.is_active:
40+
raise AuthenticationFailed(_("User inactive or deleted."))
41+
42+
return (token.user, token)

aboutcode/api_auth/models.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# DejaCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: AGPL-3.0-only
5+
# See https://github.com/aboutcode-org/dejacode for support or download.
6+
# See https://aboutcode.org for more information about AboutCode FOSS projects.
7+
#
8+
9+
import secrets
10+
11+
from django.apps import apps as django_apps
12+
from django.conf import settings
13+
from django.contrib.auth.hashers import check_password
14+
from django.contrib.auth.hashers import make_password
15+
from django.core.exceptions import ImproperlyConfigured
16+
from django.db import models
17+
18+
19+
class AbstractAPIToken(models.Model):
20+
"""
21+
API token using a lookup prefix and PBKDF2 hash for secure verification.
22+
23+
The full key is never stored. Only a short plain-text prefix is kept for
24+
DB lookup, and a hashed version of the full key is stored for verification.
25+
The plain key is returned once at generation time and must be stored safely
26+
by the client.
27+
"""
28+
29+
PREFIX_LENGTH = 8
30+
31+
key_hash = models.CharField(
32+
max_length=128,
33+
)
34+
user = models.OneToOneField(
35+
settings.AUTH_USER_MODEL,
36+
related_name="api_token",
37+
on_delete=models.CASCADE,
38+
)
39+
prefix = models.CharField(
40+
max_length=PREFIX_LENGTH,
41+
unique=True,
42+
db_index=True,
43+
)
44+
created = models.DateTimeField(
45+
auto_now_add=True,
46+
db_index=True,
47+
)
48+
49+
class Meta:
50+
abstract = True
51+
52+
def __str__(self):
53+
return f"APIToken {self.prefix}... ({self.user})"
54+
55+
@classmethod
56+
def generate_key(cls):
57+
"""Generate a plain (not encrypted) key."""
58+
return secrets.token_hex(32)
59+
60+
@classmethod
61+
def create_token(cls, user):
62+
"""Generate a new token for the given user and return the plain key once."""
63+
plain_key = cls.generate_key()
64+
prefix = plain_key[: cls.PREFIX_LENGTH]
65+
cls.objects.create(
66+
user=user,
67+
prefix=prefix,
68+
key_hash=make_password(plain_key),
69+
)
70+
return plain_key
71+
72+
@classmethod
73+
def verify(cls, plain_key):
74+
"""Return the token instance if the plain key is valid, None otherwise."""
75+
if not plain_key:
76+
return
77+
78+
prefix = plain_key[: cls.PREFIX_LENGTH]
79+
token = cls.objects.filter(prefix=prefix).select_related("user").first()
80+
81+
if token and check_password(plain_key, token.key_hash):
82+
return token
83+
84+
@classmethod
85+
def regenerate(cls, user):
86+
"""Delete any existing token instance for the user and generate a new one."""
87+
cls.objects.filter(user=user).delete()
88+
return cls.create_token(user)
89+
90+
@classmethod
91+
def revoke(cls, user):
92+
"""Delete any existing token instance for the user."""
93+
return cls.objects.filter(user=user).delete()
94+
95+
96+
def get_api_token_model():
97+
"""Return the concrete APIToken model from the API_TOKEN_MODEL setting."""
98+
try:
99+
return django_apps.get_model(settings.API_TOKEN_MODEL)
100+
except (ValueError, LookupError):
101+
raise ImproperlyConfigured("API_TOKEN_MODEL is not properly defined.")

aboutcode/api_auth/views.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# DejaCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: AGPL-3.0-only
5+
# See https://github.com/aboutcode-org/dejacode for support or download.
6+
# See https://aboutcode.org for more information about AboutCode FOSS projects.
7+
#
8+
9+
from django.contrib import messages
10+
from django.core.exceptions import ImproperlyConfigured
11+
from django.shortcuts import redirect
12+
from django.utils.html import format_html
13+
from django.views.generic import View
14+
15+
from aboutcode.api_auth.models import get_api_token_model
16+
17+
18+
class BaseAPIKeyActionView(View):
19+
"""Base view for API key management actions."""
20+
21+
success_url = None
22+
success_message = ""
23+
24+
def get_success_url(self):
25+
if not self.success_url:
26+
raise ImproperlyConfigured("No URL to redirect to. Provide a success_url.")
27+
return str(self.success_url)
28+
29+
def get_success_message(self, **kwargs):
30+
if kwargs:
31+
return format_html(self.success_message, **kwargs)
32+
return self.success_message
33+
34+
def post(self, request, *args, **kwargs):
35+
raise NotImplementedError
36+
37+
38+
class BaseGenerateAPIKeyView(BaseAPIKeyActionView):
39+
"""Generate a new API key and display it once via a success message."""
40+
41+
def post(self, request, *args, **kwargs):
42+
token_model = get_api_token_model()
43+
plain_key = token_model.regenerate(user=request.user)
44+
messages.success(request, self.get_success_message(plain_key=plain_key))
45+
return redirect(self.get_success_url())
46+
47+
48+
class BaseRevokeAPIKeyView(BaseAPIKeyActionView):
49+
"""Revoke the current user's API key."""
50+
51+
def post(self, request, *args, **kwargs):
52+
token_model = get_api_token_model()
53+
token_model.revoke(user=request.user)
54+
messages.success(request, self.get_success_message())
55+
return redirect(self.get_success_url())

api_auth-pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "flot.buildapi"
44

55
[project]
66
name = "aboutcode.api_auth"
7-
version = "0.1.0"
7+
version = "0.2.0"
88
description = ""
99
license = { text = "Apache-2.0" }
1010
readme = "aboutcode/api_auth/README.md"

0 commit comments

Comments
 (0)