Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion aboutcode/api_auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ $ ./manage.py migrate
Declare your `APIToken` model location in the `API_TOKEN_MODEL` setting:

```python
API_TOKEN_MODEL = "app.APIToken" # noqa: S105
API_TOKEN_MODEL = "your_app.APIToken" # noqa: S105
```

Declare the `APITokenAuthentication` authentication class as one of the
Expand All @@ -45,3 +45,44 @@ REST_FRAMEWORK = {
),
}
```

### Views (optional)

Base views are provided for generating and revoking API keys.
They handle the token operations and redirect with a success message.

Subclass them in your app to add authentication requirements and configure
the success URL and message:

```python
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy

from aboutcode.api_auth.views import BaseGenerateAPIKeyView
from aboutcode.api_auth.views import BaseRevokeAPIKeyView


class GenerateAPIKeyView(LoginRequiredMixin, BaseGenerateAPIKeyView):
success_url = reverse_lazy("profile")
success_message = (
"Copy your API key now, it will not be shown again: <pre>{plain_key}</pre>"
)


class RevokeAPIKeyView(LoginRequiredMixin, BaseRevokeAPIKeyView):
success_url = reverse_lazy("profile")
success_message = "API key revoked."
```

Wire them up in your `urls.py`:

```python
from your_app.views import GenerateAPIKeyView
from your_app.views import RevokeAPIKeyView

urlpatterns = [
...
path("profile/api_key/generate/", GenerateAPIKeyView.as_view(), name="generate-api-key"),
path("profile/api_key/revoke/", RevokeAPIKeyView.as_view(), name="revoke-api-key"),
]
```
126 changes: 5 additions & 121 deletions aboutcode/api_auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,126 +6,10 @@
# See https://aboutcode.org for more information about AboutCode FOSS projects.
#

import secrets
from aboutcode.api_auth.auth import APITokenAuthentication
from aboutcode.api_auth.models import AbstractAPIToken
from aboutcode.api_auth.models import get_api_token_model

from django.apps import apps as django_apps
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.contrib.auth.hashers import make_password
from django.core.exceptions import ImproperlyConfigured
from django.db import models
from django.utils.translation import gettext_lazy as _
__version__ = "0.2.0"

from rest_framework.authentication import TokenAuthentication
from rest_framework.exceptions import AuthenticationFailed

__version__ = "0.1.0"


class AbstractAPIToken(models.Model):
"""
API token using a lookup prefix and PBKDF2 hash for secure verification.

The full key is never stored. Only a short plain-text prefix is kept for
DB lookup, and a hashed version of the full key is stored for verification.
The plain key is returned once at generation time and must be stored safely
by the client.
"""

PREFIX_LENGTH = 8

key_hash = models.CharField(
max_length=128,
)
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
related_name="api_token",
on_delete=models.CASCADE,
)
prefix = models.CharField(
max_length=PREFIX_LENGTH,
unique=True,
db_index=True,
)
created = models.DateTimeField(
auto_now_add=True,
db_index=True,
)

class Meta:
abstract = True

def __str__(self):
return f"APIToken {self.prefix}... ({self.user})"

@classmethod
def generate_key(cls):
"""Generate a plain (not encrypted) key."""
return secrets.token_hex(32)

@classmethod
def create_token(cls, user):
"""Generate a new token for the given user and return the plain key once."""
plain_key = cls.generate_key()
prefix = plain_key[: cls.PREFIX_LENGTH]
cls.objects.create(
user=user,
prefix=prefix,
key_hash=make_password(plain_key),
)
return plain_key

@classmethod
def verify(cls, plain_key):
"""Return the token instance if the plain key is valid, None otherwise."""
if not plain_key:
return

prefix = plain_key[: cls.PREFIX_LENGTH]
token = cls.objects.filter(prefix=prefix).select_related("user").first()

if token and check_password(plain_key, token.key_hash):
return token

@classmethod
def regenerate(cls, user):
"""Delete any existing token instance for the user and generate a new one."""
cls.objects.filter(user=user).delete()
return cls.create_token(user)

@classmethod
def revoke(cls, user):
"""Delete any existing token instance for the user."""
return cls.objects.filter(user=user).delete()


class APITokenAuthentication(TokenAuthentication):
"""
Token authentication using a hashed API token for secure verification.

Extends Django REST Framework's TokenAuthentication, replacing the plain-text lookup
with a prefix-based lookup and PBKDF2 hash verification.
"""

model = None

def get_model(self):
if self.model is not None:
return self.model

try:
return django_apps.get_model(settings.API_TOKEN_MODEL)
except (ValueError, LookupError):
raise ImproperlyConfigured("API_TOKEN_MODEL must be of the form 'app_label.model_name'")

def authenticate_credentials(self, plain_key):
model = self.get_model()
token = model.verify(plain_key)

if token is None:
raise AuthenticationFailed(_("Invalid token."))

if not token.user.is_active:
raise AuthenticationFailed(_("User inactive or deleted."))

return (token.user, token)
__all__ = ["APITokenAuthentication", "AbstractAPIToken", "get_api_token_model"]
42 changes: 42 additions & 0 deletions aboutcode/api_auth/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#
# Copyright (c) nexB Inc. and others. All rights reserved.
# DejaCode is a trademark of nexB Inc.
# SPDX-License-Identifier: AGPL-3.0-only
# See https://github.com/aboutcode-org/dejacode for support or download.
# See https://aboutcode.org for more information about AboutCode FOSS projects.
#

from django.utils.translation import gettext_lazy as _

from rest_framework.authentication import TokenAuthentication
from rest_framework.exceptions import AuthenticationFailed

from aboutcode.api_auth.models import get_api_token_model


class APITokenAuthentication(TokenAuthentication):
"""
Token authentication using a hashed API token for secure verification.

Extends Django REST Framework's TokenAuthentication, replacing the plain-text lookup
with a prefix-based lookup and PBKDF2 hash verification.
"""

model = None

def get_model(self):
if self.model is not None:
return self.model
return get_api_token_model()

def authenticate_credentials(self, plain_key):
model = self.get_model()
token = model.verify(plain_key)

if token is None:
raise AuthenticationFailed(_("Invalid token."))

if not token.user.is_active:
raise AuthenticationFailed(_("User inactive or deleted."))

return (token.user, token)
101 changes: 101 additions & 0 deletions aboutcode/api_auth/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#
# Copyright (c) nexB Inc. and others. All rights reserved.
# DejaCode is a trademark of nexB Inc.
# SPDX-License-Identifier: AGPL-3.0-only
# See https://github.com/aboutcode-org/dejacode for support or download.
# See https://aboutcode.org for more information about AboutCode FOSS projects.
#

import secrets

from django.apps import apps as django_apps
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.contrib.auth.hashers import make_password
from django.core.exceptions import ImproperlyConfigured
from django.db import models


class AbstractAPIToken(models.Model):
"""
API token using a lookup prefix and PBKDF2 hash for secure verification.

The full key is never stored. Only a short plain-text prefix is kept for
DB lookup, and a hashed version of the full key is stored for verification.
The plain key is returned once at generation time and must be stored safely
by the client.
"""

PREFIX_LENGTH = 8

key_hash = models.CharField(
max_length=128,
)
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
related_name="api_token",
on_delete=models.CASCADE,
)
prefix = models.CharField(
max_length=PREFIX_LENGTH,
unique=True,
db_index=True,
)
created = models.DateTimeField(
auto_now_add=True,
db_index=True,
)

class Meta:
abstract = True

def __str__(self):
return f"APIToken {self.prefix}... ({self.user})"

@classmethod
def generate_key(cls):
"""Generate a plain (not encrypted) key."""
return secrets.token_hex(32)

@classmethod
def create_token(cls, user):
"""Generate a new token for the given user and return the plain key once."""
plain_key = cls.generate_key()
prefix = plain_key[: cls.PREFIX_LENGTH]
cls.objects.create(
user=user,
prefix=prefix,
key_hash=make_password(plain_key),
)
return plain_key

@classmethod
def verify(cls, plain_key):
"""Return the token instance if the plain key is valid, None otherwise."""
if not plain_key:
return

prefix = plain_key[: cls.PREFIX_LENGTH]
token = cls.objects.filter(prefix=prefix).select_related("user").first()

if token and check_password(plain_key, token.key_hash):
return token

@classmethod
def regenerate(cls, user):
"""Delete any existing token instance for the user and generate a new one."""
cls.objects.filter(user=user).delete()
return cls.create_token(user)

@classmethod
def revoke(cls, user):
"""Delete any existing token instance for the user."""
return cls.objects.filter(user=user).delete()


def get_api_token_model():
"""Return the concrete APIToken model from the API_TOKEN_MODEL setting."""
try:
return django_apps.get_model(settings.API_TOKEN_MODEL)
except (ValueError, LookupError):
raise ImproperlyConfigured("API_TOKEN_MODEL is not properly defined.")
55 changes: 55 additions & 0 deletions aboutcode/api_auth/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#
# Copyright (c) nexB Inc. and others. All rights reserved.
# DejaCode is a trademark of nexB Inc.
# SPDX-License-Identifier: AGPL-3.0-only
# See https://github.com/aboutcode-org/dejacode for support or download.
# See https://aboutcode.org for more information about AboutCode FOSS projects.
#

from django.contrib import messages
from django.core.exceptions import ImproperlyConfigured
from django.shortcuts import redirect
from django.utils.html import format_html
from django.views.generic import View

from aboutcode.api_auth.models import get_api_token_model


class BaseAPIKeyActionView(View):
"""Base view for API key management actions."""

success_url = None
success_message = ""

def get_success_url(self):
if not self.success_url:
raise ImproperlyConfigured("No URL to redirect to. Provide a success_url.")
return str(self.success_url)

def get_success_message(self, **kwargs):
if kwargs:
return format_html(self.success_message, **kwargs)
return self.success_message

def post(self, request, *args, **kwargs):
raise NotImplementedError


class BaseGenerateAPIKeyView(BaseAPIKeyActionView):
"""Generate a new API key and display it once via a success message."""

def post(self, request, *args, **kwargs):
token_model = get_api_token_model()
plain_key = token_model.regenerate(user=request.user)
messages.success(request, self.get_success_message(plain_key=plain_key))
return redirect(self.get_success_url())


class BaseRevokeAPIKeyView(BaseAPIKeyActionView):
"""Revoke the current user's API key."""

def post(self, request, *args, **kwargs):
token_model = get_api_token_model()
token_model.revoke(user=request.user)
messages.success(request, self.get_success_message())
return redirect(self.get_success_url())
2 changes: 1 addition & 1 deletion api_auth-pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "flot.buildapi"

[project]
name = "aboutcode.api_auth"
version = "0.1.0"
version = "0.2.0"
description = ""
license = { text = "Apache-2.0" }
readme = "aboutcode/api_auth/README.md"
Expand Down
Loading