-
-
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 35 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,33 @@ | ||
| """API key authentication class for Django Ninja.""" | ||
|
|
||
| from http import HTTPStatus | ||
|
|
||
| from ninja.errors import HttpError | ||
| from ninja.security import APIKeyHeader | ||
|
|
||
| from apps.nest.models.api_key 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(HTTPStatus.UNAUTHORIZED, "Missing API key in 'X-API-Key' header") | ||
|
|
||
| if api_key := ApiKey.authenticate(raw_key=key): | ||
| return api_key | ||
|
|
||
| raise HttpError(HTTPStatus.UNAUTHORIZED, "Invalid API key") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,11 @@ | ||
| """Core User mutations.""" | ||
| """Nest app mutations.""" | ||
|
|
||
| import strawberry | ||
|
|
||
| from .api_key import ApiKeyMutations | ||
| from .user import UserMutations | ||
|
|
||
|
|
||
| @strawberry.type | ||
| class NestMutations(ApiKeyMutations, UserMutations): | ||
| """Nest mutations.""" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| """Nest API key GraphQL Mutations.""" | ||
|
|
||
| import logging | ||
| from datetime import datetime | ||
| from uuid import UUID | ||
|
|
||
| import strawberry | ||
| from django.db.utils import IntegrityError | ||
| from django.utils import timezone | ||
| from strawberry.types import Info | ||
|
|
||
| from apps.nest.graphql.nodes.api_key import ApiKeyNode | ||
| from apps.nest.graphql.permissions import IsAuthenticated | ||
| from apps.nest.models.api_key import MAX_ACTIVE_KEYS, MAX_WORD_LENGTH, ApiKey | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| @strawberry.type | ||
| class RevokeApiKeyResult: | ||
| """Payload for API key revocation result.""" | ||
|
|
||
| ok: bool | ||
| code: str | None = None | ||
| message: str | None = None | ||
|
|
||
|
|
||
| @strawberry.type | ||
| class CreateApiKeyResult: | ||
| """Result of creating an API key.""" | ||
|
|
||
| ok: bool | ||
| api_key: ApiKeyNode | None = None | ||
| raw_key: str | None = None | ||
| code: str | None = None | ||
| message: str | None = None | ||
|
|
||
|
|
||
| @strawberry.type | ||
| class ApiKeyMutations: | ||
| """GraphQL mutation class for API keys.""" | ||
|
|
||
| @strawberry.mutation(permission_classes=[IsAuthenticated]) | ||
| def create_api_key(self, info: Info, name: str, expires_at: datetime) -> CreateApiKeyResult: | ||
| """Create a new API key for the authenticated user.""" | ||
| if not name or not name.strip(): | ||
| return CreateApiKeyResult(ok=False, code="INVALID_NAME", message="Name is required") | ||
|
|
||
| if len(name.strip()) > MAX_WORD_LENGTH: | ||
| return CreateApiKeyResult(ok=False, code="INVALID_NAME", message="Name too long") | ||
|
|
||
| if expires_at <= timezone.now(): | ||
| return CreateApiKeyResult( | ||
| ok=False, code="INVALID_DATE", message="Expiry date must be in future" | ||
| ) | ||
|
|
||
| try: | ||
| if not ( | ||
| result := ApiKey.create( | ||
| expires_at=expires_at, | ||
| name=name, | ||
| user=info.context.request.user, | ||
| ) | ||
| ): | ||
| return CreateApiKeyResult( | ||
| ok=False, | ||
| code="LIMIT_REACHED", | ||
| message=f"You can have at most {MAX_ACTIVE_KEYS} active API keys.", | ||
| ) | ||
|
|
||
| instance, raw_key = result | ||
| return CreateApiKeyResult( | ||
| ok=True, | ||
| api_key=instance, | ||
| raw_key=raw_key, | ||
| code="SUCCESS", | ||
| message="API key created successfully.", | ||
| ) | ||
| except IntegrityError as err: | ||
| logger.warning("Error creating API key: %s", err) | ||
| return CreateApiKeyResult( | ||
| ok=False, | ||
| code="ERROR", | ||
| message="Something went wrong.", | ||
| ) | ||
|
|
||
| @strawberry.mutation(permission_classes=[IsAuthenticated]) | ||
| def revoke_api_key(self, info: Info, uuid: UUID) -> RevokeApiKeyResult: | ||
| """Revoke an API key for the authenticated user.""" | ||
| try: | ||
| api_key = ApiKey.objects.get( | ||
| uuid=uuid, | ||
| user=info.context.request.user, | ||
| ) | ||
| api_key.is_revoked = True | ||
| api_key.updated_at = timezone.now() | ||
| api_key.save(update_fields=["is_revoked", "updated_at"]) | ||
| except ApiKey.DoesNotExist: | ||
| logger.warning("API Key does not exist") | ||
| return RevokeApiKeyResult( | ||
| ok=False, | ||
| code="NOT_FOUND", | ||
| message="API key not found.", | ||
| ) | ||
| else: | ||
| return RevokeApiKeyResult( | ||
| ok=True, code="SUCCESS", message="API key revoked successfully." | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,15 +4,27 @@ | |
|
|
||
| import requests | ||
| import strawberry | ||
| from django.contrib.auth import login, logout | ||
| from github import Github | ||
| from strawberry.types import Info | ||
|
|
||
| from apps.github.models import User as GithubUser | ||
| from apps.nest.graphql.nodes.user import AuthUserNode | ||
| from apps.nest.graphql.permissions import IsAuthenticated | ||
| from apps.nest.models import User | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| @strawberry.type | ||
| class LogoutResult: | ||
| """Payload for logout mutation.""" | ||
|
|
||
| ok: bool | ||
| code: str | None = None | ||
| message: str | None = None | ||
|
|
||
|
|
||
| @strawberry.type | ||
| class GitHubAuthResult: | ||
| """Payload for GitHubAuth mutation.""" | ||
|
|
@@ -25,7 +37,7 @@ class UserMutations: | |
| """GraphQL mutations related to user.""" | ||
|
|
||
| @strawberry.mutation | ||
| def github_auth(self, access_token: str) -> GitHubAuthResult: | ||
| def github_auth(self, info: Info, access_token: str) -> GitHubAuthResult: | ||
| """Authenticate via GitHub OAuth2.""" | ||
| try: | ||
| github = Github(access_token) | ||
|
|
@@ -49,8 +61,26 @@ def github_auth(self, access_token: str) -> GitHubAuthResult: | |
| username=gh_user.login, | ||
| ) | ||
|
|
||
| # Log the user in and attach it to a session. | ||
| # https://docs.djangoproject.com/en/5.2/topics/auth/default/#django.contrib.auth.login | ||
| # https://docs.djangoproject.com/en/5.2/topics/http/sessions/ | ||
| login(info.context.request, auth_user) | ||
|
abhayymishraa marked this conversation as resolved.
|
||
|
|
||
| return GitHubAuthResult(auth_user=auth_user) | ||
|
|
||
| except requests.exceptions.RequestException as e: | ||
| logger.warning("GitHub authentication failed: %s", e) | ||
| return GitHubAuthResult(auth_user=None) | ||
|
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. How do we use this response on the frontend?
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. This response is returned to the hook, and the backend includes a Set-Cookie header in it. The browser automatically receives this header and sets the sessionid cookie accordingly. |
||
|
|
||
| @strawberry.mutation(permission_classes=[IsAuthenticated]) | ||
| def logout_user(self, info: Info) -> LogoutResult: | ||
| """Logout the current user.""" | ||
| # Log the user out and clear the session. | ||
| # https://docs.djangoproject.com/en/5.2/topics/auth/default/#django.contrib.auth.logout | ||
| logout(info.context.request) | ||
|
|
||
| return LogoutResult( | ||
| ok=True, | ||
| code="SUCCESS", | ||
| message="User logged out successfully.", | ||
| ) | ||
|
abhayymishraa marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| """GraphQL node for ApiKey model.""" | ||
|
|
||
| import strawberry_django | ||
|
|
||
| from apps.nest.models.api_key import ApiKey | ||
|
|
||
|
|
||
| @strawberry_django.type( | ||
| ApiKey, | ||
| fields=[ | ||
| "created_at", | ||
| "is_revoked", | ||
| "expires_at", | ||
| "name", | ||
| "uuid", | ||
| ], | ||
| ) | ||
| class ApiKeyNode: | ||
| """GraphQL node for API keys.""" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| """GraphQL permissions classes for authentication.""" | ||
|
|
||
| from typing import Any | ||
|
|
||
| from graphql import GraphQLError | ||
| from strawberry.permission import BasePermission | ||
| from strawberry.types import Info | ||
|
|
||
|
|
||
| class IsAuthenticated(BasePermission): | ||
| """Permission class to check if the user is authenticated.""" | ||
|
|
||
| message = "You must be logged in to perform this action." | ||
|
|
||
| def has_permission(self, source, info: Info, **kwargs) -> bool: | ||
| """Check if the user is authenticated.""" | ||
| return info.context.request.user.is_authenticated | ||
|
|
||
| def on_unauthorized(self) -> Any: | ||
| """Handle unauthorized access.""" | ||
| return GraphQLError(self.message, extensions={"code": "UNAUTHORIZED"}) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import strawberry | ||
|
|
||
| from apps.nest.graphql.queries.api_key import ApiKeyQueries | ||
|
|
||
|
|
||
| @strawberry.type | ||
| class NestQuery(ApiKeyQueries): | ||
| """Nest query.""" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| """GraphQL queries for API keys.""" | ||
|
|
||
| import strawberry | ||
| from strawberry.types import Info | ||
|
|
||
| from apps.nest.graphql.nodes.api_key import ApiKeyNode | ||
| from apps.nest.graphql.permissions import IsAuthenticated | ||
|
|
||
|
|
||
| @strawberry.type | ||
| class ApiKeyQueries: | ||
| """GraphQL query class for retrieving API keys.""" | ||
|
|
||
| @strawberry.field(permission_classes=[IsAuthenticated]) | ||
| def active_api_key_count(self, info: Info) -> int: | ||
| """Return count of active API keys for user.""" | ||
| return info.context.request.user.active_api_keys.count() | ||
|
|
||
| @strawberry.field(permission_classes=[IsAuthenticated]) | ||
| def api_keys(self, info: Info) -> list[ApiKeyNode]: | ||
| """Resolve API keys for the authenticated user. | ||
|
|
||
| Args: | ||
| info: GraphQL resolver context. | ||
|
|
||
| Returns: | ||
| list[ApiKeyNode]: List of API keys associated with the authenticated user. | ||
|
|
||
| """ | ||
| return info.context.request.user.active_api_keys.order_by("-created_at") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| # Generated by Django 5.2.4 on 2025-07-13 21:37 | ||
|
|
||
| import uuid | ||
|
|
||
| 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" | ||
| ), | ||
| ), | ||
| ("created_at", models.DateTimeField(auto_now_add=True)), | ||
| ("expires_at", models.DateTimeField()), | ||
| ("hash", models.CharField(max_length=64, unique=True)), | ||
| ("is_revoked", models.BooleanField(default=False)), | ||
| ("last_used_at", models.DateTimeField(blank=True, null=True)), | ||
| ("name", models.CharField(max_length=100)), | ||
| ("uuid", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), | ||
| ("updated_at", models.DateTimeField(auto_now=True)), | ||
| ( | ||
| "user", | ||
| models.ForeignKey( | ||
| on_delete=django.db.models.deletion.CASCADE, | ||
| related_name="api_keys", | ||
| to=settings.AUTH_USER_MODEL, | ||
| ), | ||
| ), | ||
| ], | ||
| options={ | ||
| "verbose_name_plural": "API keys", | ||
| "db_table": "nest_api_keys", | ||
| "ordering": ["-created_at"], | ||
| }, | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| from .api_key import ApiKey | ||
| from .user import User |
Uh oh!
There was an error while loading. Please reload this page.