-
-
Notifications
You must be signed in to change notification settings - Fork 628
Feat: API Key management #1706
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat: API Key management #1706
Changes from 4 commits
763c9ba
1d9057a
7e6b3e3
d9c2bff
313a5b8
b924390
9fcadfc
48e833a
a198ab7
3bf4372
df70515
6cf3b1e
76cec8c
dd4d008
51e2eff
25c21a4
1e7ad38
741ae28
e789235
7da7fd4
ca17944
1c29f1f
9827b66
b3993ea
9d8e8e8
3f3320d
e762b79
2745867
ff29320
ba68a51
92cebe4
c7e0fe8
0877643
783d68a
7d25a2b
d95b713
fe653e9
27ed8e1
0dfb49c
0c49f2d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| """API key authentication class for Django Ninja.""" | ||
|
|
||
| from ninja.errors import HttpError | ||
| from ninja.security import APIKeyHeader | ||
|
|
||
| from apps.nest.models.apikey import APIKey | ||
|
|
||
|
|
||
| class APIKeyAuth(APIKeyHeader): | ||
| """Custom API key authentication class for Ninja.""" | ||
|
|
||
| param_name = "X-API-Key" | ||
|
|
||
| def authenticate(self, request, key: str) -> APIKey: | ||
| """Authenticate the API key from the request header. | ||
|
|
||
| Args: | ||
| request: The HTTP request object. | ||
| key: The API key string from the request header. | ||
|
|
||
| Returns: | ||
| APIKey: The APIKey object if the key is valid, otherwise None. | ||
|
|
||
| """ | ||
| if not key: | ||
| raise HttpError(401, "Missing API key in 'X-API-Key' header") | ||
| key_hash = APIKey.generate_hash_key(key) | ||
|
|
||
| try: | ||
| api_key = APIKey.objects.get(key_hash=key_hash) | ||
|
abhayymishraa marked this conversation as resolved.
Outdated
|
||
| if api_key.is_valid(): | ||
| return api_key | ||
| except APIKey.DoesNotExist as err: | ||
| raise HttpError(401, "Invalid or expired API key") from err | ||
|
|
||
| raise HttpError(401, "API key revoked or expired") | ||
|
abhayymishraa marked this conversation as resolved.
Outdated
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,11 @@ | ||
| """Core User mutations.""" | ||
|
|
||
| import strawberry | ||
|
|
||
| from .apikey import APIKeyMutations | ||
| from .user import UserMutations | ||
|
|
||
|
|
||
| @strawberry.type | ||
| class NestMutations(APIKeyMutations, UserMutations): | ||
| """Nest mutations.""" |
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| """Nest API key GraphQL Mutations.""" | ||
|
|
||
| from datetime import datetime | ||
|
|
||
| import strawberry | ||
|
|
||
| from apps.nest.graphql.nodes.apikey import APIKeyNode | ||
| from apps.nest.graphql.utils import get_authenticated_user | ||
| from apps.nest.models import APIKey | ||
|
|
||
|
|
||
| @strawberry.type | ||
| class CreateAPIKeyResult: | ||
| """Result of creating an API key.""" | ||
|
|
||
| api_key: APIKeyNode | ||
| raw_key: str | ||
|
|
||
|
|
||
| @strawberry.type | ||
| class APIKeyMutations: | ||
| """GraphQL mutation class for API keys.""" | ||
|
|
||
| @strawberry.mutation | ||
| def create_api_key( | ||
| self, info, name: str, expires_at: datetime | None = None | ||
| ) -> CreateAPIKeyResult: | ||
| """Create a new API key for the authenticated user.""" | ||
| request = info.context.request | ||
| user = get_authenticated_user(request) | ||
| try: | ||
| instance, raw_key = APIKey.create(user=user, name=name, expires_at=expires_at) | ||
| return CreateAPIKeyResult(api_key=instance, raw_key=raw_key) | ||
| except Exception as err: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This exception is too wide, you probably need just |
||
| raise Exception("API key name already exists") from err | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You suppressed |
||
|
|
||
| @strawberry.mutation | ||
| def revoke_api_key(self, info, key_id: int) -> bool: | ||
| """Revoke an API key for the authenticated user.""" | ||
| request = info.context.request | ||
| user = get_authenticated_user(request) | ||
| try: | ||
| api_key = APIKey.objects.get(id=key_id, user=user) | ||
| api_key.revoked = True | ||
| api_key.save(update_fields=["revoked"]) | ||
| except APIKey.DoesNotExist: | ||
| return False | ||
| else: | ||
| return True | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| """GraphQL node for APIKey model.""" | ||
|
|
||
| import strawberry_django | ||
|
|
||
| from apps.nest.models import APIKey | ||
|
|
||
|
|
||
| @strawberry_django.type( | ||
| APIKey, fields=["id", "name", "revoked", "created_at", "expires_at", "key_suffix"] | ||
| ) | ||
| class APIKeyNode: | ||
| """GraphQL node for API keys.""" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import strawberry | ||
|
|
||
| from apps.nest.graphql.queries.apikey import APIKeyQueries | ||
|
|
||
|
|
||
| @strawberry.type | ||
| class NestQuery(APIKeyQueries): | ||
| """Nest query.""" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| """GraphQL query for API keys.""" | ||
|
|
||
| import strawberry | ||
|
|
||
| from apps.nest.graphql.nodes.apikey import APIKeyNode | ||
| from apps.nest.graphql.utils import get_authenticated_user | ||
| from apps.nest.models import APIKey | ||
|
|
||
|
|
||
| @strawberry.type | ||
| class APIKeyQueries: | ||
| """GraphQL query class for retrieving API keys.""" | ||
|
|
||
| @strawberry.field | ||
| def api_keys(self, info, *, include_revoked: bool = False) -> list[APIKeyNode]: | ||
| """Resolve API keys for the authenticated user. | ||
|
|
||
| Args: | ||
| info: GraphQL resolver context. | ||
| include_revoked: If True, include revoked API keys in the result. | ||
|
|
||
| Returns: | ||
| list[APIKeyNode]: List of API keys associated with the authenticated user. | ||
|
|
||
| """ | ||
| request = info.context.request | ||
| user = get_authenticated_user(request) | ||
| keys = APIKey.objects.filter(user=user).order_by("-created_at") | ||
| return keys.filter(revoked=include_revoked) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| """Nest GraphQL utils.""" | ||
|
|
||
| from github import Github | ||
|
|
||
| from apps.github.models import User as GithubUser | ||
| from apps.nest.models import User as NestUser | ||
|
|
||
|
|
||
| def get_authenticated_user(request) -> NestUser: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure what's your idea about this method. It looks like we'll need to do a GitHub API call in order to verify OWASP Nest internal user?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Umm i solved it w/ django session based approach |
||
| """Get authenticated user from request.""" | ||
| auth_header = request.headers.get("Authorization") | ||
| if not auth_header or not auth_header.startswith("Bearer "): | ||
| raise Exception("Missing or malformed Authorization header") | ||
|
|
||
| access_token = auth_header.removeprefix("Bearer ").strip() | ||
| try: | ||
| github = Github(access_token) | ||
| gh_user = github.get_user() | ||
| except Exception as err: | ||
| raise Exception("GitHub token is invalid or expired") from err | ||
|
|
||
| try: | ||
| github_user = GithubUser.objects.get(login=gh_user.login) | ||
| except GithubUser.DoesNotExist as err: | ||
| raise Exception("GitHub user not found in local database") from err | ||
|
|
||
| try: | ||
| user = NestUser.objects.get(github_user=github_user) | ||
| except NestUser.DoesNotExist as err: | ||
| raise Exception("Local user not linked to this GitHub account") from err | ||
|
|
||
| return user | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| # Generated by Django 5.2.3 on 2025-07-03 10:57 | ||
|
|
||
| import django.db.models.deletion | ||
| from django.conf import settings | ||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
| dependencies = [ | ||
| ("nest", "0001_initial"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.CreateModel( | ||
| name="APIKey", | ||
| fields=[ | ||
| ( | ||
| "id", | ||
| models.BigAutoField( | ||
| auto_created=True, primary_key=True, serialize=False, verbose_name="ID" | ||
| ), | ||
| ), | ||
| ("key_hash", models.CharField(db_index=True, max_length=64, unique=True)), | ||
| ("key_suffix", models.CharField(blank=True, max_length=4)), | ||
| ("name", models.CharField(max_length=100)), | ||
| ("revoked", models.BooleanField(default=False)), | ||
| ("created_at", models.DateTimeField(auto_now_add=True)), | ||
| ("expires_at", models.DateTimeField(blank=True, null=True)), | ||
| ( | ||
| "user", | ||
| models.ForeignKey( | ||
| on_delete=django.db.models.deletion.CASCADE, | ||
| related_name="api_keys", | ||
| to=settings.AUTH_USER_MODEL, | ||
| ), | ||
| ), | ||
| ], | ||
| options={ | ||
| "verbose_name": "API Key", | ||
| "verbose_name_plural": "API Keys", | ||
| "ordering": ["-created_at"], | ||
| }, | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| from .apikey import APIKey | ||
| from .user import User |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,66 @@ | ||||||
| """Nest app API key model.""" | ||||||
|
|
||||||
| import hashlib | ||||||
| import secrets | ||||||
|
|
||||||
| from django.conf import settings | ||||||
| from django.db import models | ||||||
| from django.utils import timezone | ||||||
|
|
||||||
|
|
||||||
| class APIKey(models.Model): | ||||||
| """API key model.""" | ||||||
|
|
||||||
| key_hash = models.CharField(max_length=64, unique=True, db_index=True) | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You already have an index for
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It has |
||||||
| key_suffix = models.CharField(max_length=4, blank=True) | ||||||
| name = models.CharField(max_length=100, null=False, blank=False) | ||||||
| revoked = models.BooleanField(default=False) | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Or better replace w/
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i followed this |
||||||
| user = models.ForeignKey( | ||||||
| settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="api_keys", db_index=True | ||||||
| ) | ||||||
| created_at = models.DateTimeField(auto_now_add=True) | ||||||
| expires_at = models.DateTimeField(null=True, blank=True) | ||||||
|
|
||||||
| class Meta: | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You must keep the |
||||||
| ordering = ["-created_at"] | ||||||
| verbose_name = "API Key" | ||||||
| verbose_name_plural = "API Keys" | ||||||
|
|
||||||
| @staticmethod | ||||||
| def generate_raw_key(): | ||||||
| """Generate a secure random API key.""" | ||||||
| return secrets.token_urlsafe(32) | ||||||
|
|
||||||
| @staticmethod | ||||||
| def generate_hash_key(raw_key: str) -> str: | ||||||
| """Generate a SHA-256 hash of the raw API key.""" | ||||||
| return hashlib.sha256(raw_key.encode()).hexdigest() | ||||||
|
|
||||||
| @classmethod | ||||||
| def create(cls, user, name, expires_at=None): | ||||||
| """Create a new API key instance.""" | ||||||
| raw_key = cls.generate_raw_key() | ||||||
| key_hash = cls.generate_hash_key(raw_key) | ||||||
| instance = cls.objects.create( | ||||||
| key_hash=key_hash, key_suffix=raw_key[-4:], name=name, user=user, expires_at=expires_at | ||||||
| ) | ||||||
| return instance, raw_key | ||||||
|
|
||||||
| @classmethod | ||||||
| def authenticate(cls, raw_key: str): | ||||||
| """Authenticate an API key using the raw key.""" | ||||||
| key_hash = cls.generate_hash_key(raw_key) | ||||||
| try: | ||||||
| api_key = cls.objects.get(key_hash=key_hash) | ||||||
| if api_key.is_valid(): | ||||||
| return api_key | ||||||
| except cls.DoesNotExist: | ||||||
| return None | ||||||
|
|
||||||
| def is_valid(self): | ||||||
| """Check if the API key is valid.""" | ||||||
| return not self.revoked and (not self.expires_at or timezone.now() < self.expires_at) | ||||||
|
|
||||||
| def __str__(self): | ||||||
| """Human-readable representation of the API key.""" | ||||||
| return f"{self.name} ({'revoked' if self.revoked else 'active'})" | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -127,6 +127,7 @@ ignore = [ | |
| "COM812", | ||
| "D407", | ||
| "DJ012", | ||
| "EM101", | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please stop suppressing warnings on the entire backend level. The tool is telling you're doing it wrong and you reply "shut up" instead of improving your code.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's just for the checks for now i was going to fix it after the initial review |
||
| "ERA001", | ||
| "FBT002", | ||
| "FIX002", | ||
|
|
@@ -140,6 +141,8 @@ ignore = [ | |
| "RUF012", | ||
| "SLF001", | ||
| "TD003", | ||
| "TRY002", | ||
| "TRY003", | ||
| ] | ||
| select = ["ALL"] | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.