Skip to content

Commit 951e7ce

Browse files
abhayymishraakasyaarkid15r
authored
Feat: API Key management (#1706)
* API Key management backend optimization * fix lint * Added code rabbit suggestions * Initial frontend * fix check * Added model * Added new logic for authentication * code rabbit suggestion * fix checks * backend test fix * frontend test fix * added tests fe * cleanup * fixes * Added skeleton for laoding and better gql responses * fixed cases * fixed the hook * fix code * added testcases * Update code * fixed warnings * Added comments on auth * Updated code * updated types and comments on the backend * Update code (w/o test fixes) * updated code new suggestions * fix fe tests * Added the remaining suggestions * code rabbit optimizations * code rabbit optimizations -2 * bug * Update code --------- Co-authored-by: Kate Golovanova <[email protected]> Co-authored-by: Arkadii Yakovets <[email protected]>
1 parent 2ccc875 commit 951e7ce

File tree

39 files changed

+1906
-52
lines changed

39 files changed

+1906
-52
lines changed

backend/apps/core/api/ninja.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""API key authentication class for Django Ninja."""
2+
3+
from http import HTTPStatus
4+
5+
from ninja.errors import HttpError
6+
from ninja.security import APIKeyHeader
7+
8+
from apps.nest.models.api_key import ApiKey
9+
10+
11+
class ApiKeyAuth(APIKeyHeader):
12+
"""Custom API key authentication class for Ninja."""
13+
14+
param_name = "X-API-Key"
15+
16+
def authenticate(self, request, key: str) -> ApiKey:
17+
"""Authenticate the API key from the request header.
18+
19+
Args:
20+
request: The HTTP request object.
21+
key: The API key string from the request header.
22+
23+
Returns:
24+
APIKey: The APIKey object if the key is valid, otherwise None.
25+
26+
"""
27+
if not key:
28+
raise HttpError(HTTPStatus.UNAUTHORIZED, "Missing API key in 'X-API-Key' header")
29+
30+
if api_key := ApiKey.authenticate(raw_key=key):
31+
return api_key
32+
33+
raise HttpError(HTTPStatus.UNAUTHORIZED, "Invalid API key")

backend/apps/nest/admin.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from django.contrib import admin
44

5+
from apps.nest.models.api_key import ApiKey
56
from apps.nest.models.user import User
67

78

@@ -10,4 +11,25 @@ class UserAdmin(admin.ModelAdmin):
1011
search_fields = ("email", "username")
1112

1213

14+
class ApiKeyAdmin(admin.ModelAdmin):
15+
autocomplete_fields = ("user",)
16+
list_display = (
17+
"name",
18+
"user",
19+
"uuid",
20+
"is_revoked",
21+
"expires_at",
22+
"created_at",
23+
"last_used_at",
24+
)
25+
list_filter = ("is_revoked",)
26+
ordering = ("-created_at",)
27+
search_fields = (
28+
"name",
29+
"uuid",
30+
"user__username",
31+
)
32+
33+
34+
admin.site.register(ApiKey, ApiKeyAdmin)
1335
admin.site.register(User, UserAdmin)

backend/apps/nest/graphql/__init__.py

Whitespace-only changes.
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1-
"""Core User mutations."""
1+
"""Nest app mutations."""
22

3+
import strawberry
4+
5+
from .api_key import ApiKeyMutations
36
from .user import UserMutations
7+
8+
9+
@strawberry.type
10+
class NestMutations(ApiKeyMutations, UserMutations):
11+
"""Nest mutations."""
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Nest API key GraphQL Mutations."""
2+
3+
import logging
4+
from datetime import datetime
5+
from uuid import UUID
6+
7+
import strawberry
8+
from django.db.utils import IntegrityError
9+
from django.utils import timezone
10+
from strawberry.types import Info
11+
12+
from apps.nest.graphql.nodes.api_key import ApiKeyNode
13+
from apps.nest.graphql.permissions import IsAuthenticated
14+
from apps.nest.models.api_key import MAX_ACTIVE_KEYS, MAX_WORD_LENGTH, ApiKey
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
@strawberry.type
20+
class RevokeApiKeyResult:
21+
"""Payload for API key revocation result."""
22+
23+
ok: bool
24+
code: str | None = None
25+
message: str | None = None
26+
27+
28+
@strawberry.type
29+
class CreateApiKeyResult:
30+
"""Result of creating an API key."""
31+
32+
ok: bool
33+
api_key: ApiKeyNode | None = None
34+
raw_key: str | None = None
35+
code: str | None = None
36+
message: str | None = None
37+
38+
39+
@strawberry.type
40+
class ApiKeyMutations:
41+
"""GraphQL mutation class for API keys."""
42+
43+
@strawberry.mutation(permission_classes=[IsAuthenticated])
44+
def create_api_key(self, info: Info, name: str, expires_at: datetime) -> CreateApiKeyResult:
45+
"""Create a new API key for the authenticated user."""
46+
if not name or not name.strip():
47+
return CreateApiKeyResult(ok=False, code="INVALID_NAME", message="Name is required")
48+
49+
if len(name.strip()) > MAX_WORD_LENGTH:
50+
return CreateApiKeyResult(ok=False, code="INVALID_NAME", message="Name too long")
51+
52+
if expires_at <= timezone.now():
53+
return CreateApiKeyResult(
54+
ok=False, code="INVALID_DATE", message="Expiry date must be in future"
55+
)
56+
57+
try:
58+
if not (
59+
result := ApiKey.create(
60+
expires_at=expires_at,
61+
name=name,
62+
user=info.context.request.user,
63+
)
64+
):
65+
return CreateApiKeyResult(
66+
ok=False,
67+
code="LIMIT_REACHED",
68+
message=f"You can have at most {MAX_ACTIVE_KEYS} active API keys.",
69+
)
70+
71+
instance, raw_key = result
72+
return CreateApiKeyResult(
73+
ok=True,
74+
api_key=instance,
75+
raw_key=raw_key,
76+
code="SUCCESS",
77+
message="API key created successfully.",
78+
)
79+
except IntegrityError as err:
80+
logger.warning("Error creating API key: %s", err)
81+
return CreateApiKeyResult(
82+
ok=False,
83+
code="ERROR",
84+
message="Something went wrong.",
85+
)
86+
87+
@strawberry.mutation(permission_classes=[IsAuthenticated])
88+
def revoke_api_key(self, info: Info, uuid: UUID) -> RevokeApiKeyResult:
89+
"""Revoke an API key for the authenticated user."""
90+
try:
91+
api_key = ApiKey.objects.get(
92+
uuid=uuid,
93+
user=info.context.request.user,
94+
)
95+
api_key.is_revoked = True
96+
api_key.save(update_fields=("is_revoked", "updated_at"))
97+
except ApiKey.DoesNotExist:
98+
logger.warning("API Key does not exist")
99+
return RevokeApiKeyResult(
100+
ok=False,
101+
code="NOT_FOUND",
102+
message="API key not found.",
103+
)
104+
else:
105+
return RevokeApiKeyResult(
106+
ok=True, code="SUCCESS", message="API key revoked successfully."
107+
)

backend/apps/nest/graphql/mutations/user.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,27 @@
44

55
import requests
66
import strawberry
7+
from django.contrib.auth import login, logout
78
from github import Github
9+
from strawberry.types import Info
810

911
from apps.github.models import User as GithubUser
1012
from apps.nest.graphql.nodes.user import AuthUserNode
13+
from apps.nest.graphql.permissions import IsAuthenticated
1114
from apps.nest.models import User
1215

1316
logger = logging.getLogger(__name__)
1417

1518

19+
@strawberry.type
20+
class LogoutResult:
21+
"""Payload for logout mutation."""
22+
23+
ok: bool
24+
code: str | None = None
25+
message: str | None = None
26+
27+
1628
@strawberry.type
1729
class GitHubAuthResult:
1830
"""Payload for GitHubAuth mutation."""
@@ -25,7 +37,7 @@ class UserMutations:
2537
"""GraphQL mutations related to user."""
2638

2739
@strawberry.mutation
28-
def github_auth(self, access_token: str) -> GitHubAuthResult:
40+
def github_auth(self, info: Info, access_token: str) -> GitHubAuthResult:
2941
"""Authenticate via GitHub OAuth2."""
3042
try:
3143
github = Github(access_token)
@@ -49,8 +61,26 @@ def github_auth(self, access_token: str) -> GitHubAuthResult:
4961
username=gh_user.login,
5062
)
5163

64+
# Log the user in and attach it to a session.
65+
# https://docs.djangoproject.com/en/5.2/topics/auth/default/#django.contrib.auth.login
66+
# https://docs.djangoproject.com/en/5.2/topics/http/sessions/
67+
login(info.context.request, auth_user)
68+
5269
return GitHubAuthResult(auth_user=auth_user)
5370

5471
except requests.exceptions.RequestException as e:
5572
logger.warning("GitHub authentication failed: %s", e)
5673
return GitHubAuthResult(auth_user=None)
74+
75+
@strawberry.mutation(permission_classes=[IsAuthenticated])
76+
def logout_user(self, info: Info) -> LogoutResult:
77+
"""Logout the current user."""
78+
# Log the user out and clear the session.
79+
# https://docs.djangoproject.com/en/5.2/topics/auth/default/#django.contrib.auth.logout
80+
logout(info.context.request)
81+
82+
return LogoutResult(
83+
ok=True,
84+
code="SUCCESS",
85+
message="User logged out successfully.",
86+
)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""GraphQL node for ApiKey model."""
2+
3+
import strawberry_django
4+
5+
from apps.nest.models.api_key import ApiKey
6+
7+
8+
@strawberry_django.type(
9+
ApiKey,
10+
fields=[
11+
"created_at",
12+
"is_revoked",
13+
"expires_at",
14+
"name",
15+
"uuid",
16+
],
17+
)
18+
class ApiKeyNode:
19+
"""GraphQL node for API keys."""
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""GraphQL permissions classes for authentication."""
2+
3+
from typing import Any
4+
5+
from graphql import GraphQLError
6+
from strawberry.permission import BasePermission
7+
from strawberry.types import Info
8+
9+
10+
class IsAuthenticated(BasePermission):
11+
"""Permission class to check if the user is authenticated."""
12+
13+
message = "You must be logged in to perform this action."
14+
15+
def has_permission(self, source, info: Info, **kwargs) -> bool:
16+
"""Check if the user is authenticated."""
17+
return info.context.request.user.is_authenticated
18+
19+
def on_unauthorized(self) -> Any:
20+
"""Handle unauthorized access."""
21+
return GraphQLError(self.message, extensions={"code": "UNAUTHORIZED"})
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import strawberry
2+
3+
from apps.nest.graphql.queries.api_key import ApiKeyQueries
4+
5+
6+
@strawberry.type
7+
class NestQuery(ApiKeyQueries):
8+
"""Nest query."""
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""GraphQL queries for API keys."""
2+
3+
import strawberry
4+
from strawberry.types import Info
5+
6+
from apps.nest.graphql.nodes.api_key import ApiKeyNode
7+
from apps.nest.graphql.permissions import IsAuthenticated
8+
9+
10+
@strawberry.type
11+
class ApiKeyQueries:
12+
"""GraphQL query class for retrieving API keys."""
13+
14+
@strawberry.field(permission_classes=[IsAuthenticated])
15+
def active_api_key_count(self, info: Info) -> int:
16+
"""Return count of active API keys for user."""
17+
return info.context.request.user.active_api_keys.count()
18+
19+
@strawberry.field(permission_classes=[IsAuthenticated])
20+
def api_keys(self, info: Info) -> list[ApiKeyNode]:
21+
"""Resolve API keys for the authenticated user.
22+
23+
Args:
24+
info: GraphQL resolver context.
25+
26+
Returns:
27+
list[ApiKeyNode]: List of API keys associated with the authenticated user.
28+
29+
"""
30+
return info.context.request.user.active_api_keys.order_by("-created_at")

0 commit comments

Comments
 (0)