Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
763c9ba
API Key management backend optimization
abhayymishraa Jul 3, 2025
1d9057a
fix lint
abhayymishraa Jul 3, 2025
7e6b3e3
Added code rabbit suggestions
abhayymishraa Jul 3, 2025
d9c2bff
Initial frontend
abhayymishraa Jul 3, 2025
313a5b8
fix check
abhayymishraa Jul 3, 2025
b924390
Added model
abhayymishraa Jul 4, 2025
9fcadfc
Merge branch 'main' into feat/api_management
abhayymishraa Jul 4, 2025
48e833a
Merge branch 'main' into feat/api_management
abhayymishraa Jul 5, 2025
a198ab7
Added new logic for authentication
abhayymishraa Jul 10, 2025
3bf4372
code rabbit suggestion
abhayymishraa Jul 11, 2025
df70515
fix checks
abhayymishraa Jul 11, 2025
6cf3b1e
backend test fix
abhayymishraa Jul 11, 2025
76cec8c
frontend test fix
abhayymishraa Jul 11, 2025
dd4d008
added tests fe
abhayymishraa Jul 11, 2025
51e2eff
Merge branch 'main' into feat/api_management
abhayymishraa Jul 11, 2025
25c21a4
cleanup
abhayymishraa Jul 11, 2025
1e7ad38
fixes
abhayymishraa Jul 11, 2025
741ae28
Added skeleton for laoding and better gql responses
abhayymishraa Jul 11, 2025
e789235
fixed cases
abhayymishraa Jul 11, 2025
7da7fd4
Merge branch 'main' into feat/api_management
abhayymishraa Jul 11, 2025
ca17944
fixed the hook
abhayymishraa Jul 12, 2025
1c29f1f
fix code
abhayymishraa Jul 12, 2025
9827b66
added testcases
abhayymishraa Jul 12, 2025
b3993ea
Merge branch 'main' into feat/api_management
kasya Jul 12, 2025
9d8e8e8
Update code
arkid15r Jul 12, 2025
3f3320d
fixed warnings
abhayymishraa Jul 13, 2025
e762b79
Added comments on auth
abhayymishraa Jul 13, 2025
2745867
Updated code
abhayymishraa Jul 13, 2025
ff29320
updated types and comments on the backend
abhayymishraa Jul 13, 2025
ba68a51
Merge branch 'main' into feat/api_management
abhayymishraa Jul 13, 2025
92cebe4
Update code (w/o test fixes)
arkid15r Jul 13, 2025
c7e0fe8
updated code new suggestions
abhayymishraa Jul 13, 2025
0877643
fix fe tests
abhayymishraa Jul 13, 2025
783d68a
Added the remaining suggestions
abhayymishraa Jul 14, 2025
7d25a2b
Merge branch 'main' into feat/api_management
abhayymishraa Jul 14, 2025
d95b713
code rabbit optimizations
abhayymishraa Jul 14, 2025
fe653e9
code rabbit optimizations -2
abhayymishraa Jul 14, 2025
27ed8e1
bug
abhayymishraa Jul 14, 2025
0dfb49c
Merge branch 'main' into feat/api_management
abhayymishraa Jul 14, 2025
0c49f2d
Update code
arkid15r Jul 15, 2025
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
36 changes: 36 additions & 0 deletions backend/apps/core/api/ninja.py
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):
Comment thread
arkid15r marked this conversation as resolved.
Outdated
"""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)
Comment thread
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")
Comment thread
abhayymishraa marked this conversation as resolved.
Outdated
10 changes: 10 additions & 0 deletions backend/apps/nest/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from django.contrib import admin

from apps.nest.models.apikey import APIKey
from apps.nest.models.user import User


Expand All @@ -10,4 +11,13 @@ class UserAdmin(admin.ModelAdmin):
search_fields = ("email", "username")


class APIKeyAdmin(admin.ModelAdmin):
list_display = ("name", "user", "key_suffix", "revoked", "expires_at", "created_at")
search_fields = ("name", "user__username", "key_suffix")
list_filter = ("revoked", "expires_at", "created_at")
ordering = ("-created_at",)


admin.site.register(APIKey, APIKeyAdmin)

admin.site.register(User, UserAdmin)
Empty file.
8 changes: 8 additions & 0 deletions backend/apps/nest/graphql/mutations/__init__.py
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."""
49 changes: 49 additions & 0 deletions backend/apps/nest/graphql/mutations/apikey.py
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

api_key here and below

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:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This exception is too wide, you probably need just IntegrityError or similar.

raise Exception("API key name already exists") from err
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You suppressed ruff instead of introducing a custom exception here.


@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
12 changes: 12 additions & 0 deletions backend/apps/nest/graphql/nodes/apikey.py
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."""
8 changes: 8 additions & 0 deletions backend/apps/nest/graphql/queries/__init__.py
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."""
29 changes: 29 additions & 0 deletions backend/apps/nest/graphql/queries/apikey.py
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)
32 changes: 32 additions & 0 deletions backend/apps/nest/graphql/utils.py
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:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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
44 changes: 44 additions & 0 deletions backend/apps/nest/migrations/0002_apikey.py
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"],
},
),
]
1 change: 1 addition & 0 deletions backend/apps/nest/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from .apikey import APIKey
from .user import User
66 changes: 66 additions & 0 deletions backend/apps/nest/models/apikey.py
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)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You already have an index for unique=True

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It has key in the model name -- remove key_ from names

key_suffix = models.CharField(max_length=4, blank=True)
name = models.CharField(max_length=100, null=False, blank=False)
revoked = models.BooleanField(default=False)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
revoked = models.BooleanField(default=False)
is_revoked = models.BooleanField(default=False)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or better replace w/ revoked_at DateTime field

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i followed this
https://github.com/florimondmanca/djangorestframework-api-key/blob/master/src/rest_framework_api_key/models.py
they follow a similar approach, so whatever you suggest i can further work on that!!

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:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You must keep the Meta place and DB table name consistent w/ other models. It has to be nest_api_keys

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'})"
3 changes: 3 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ ignore = [
"COM812",
"D407",
"DJ012",
"EM101",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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",
Expand All @@ -140,6 +141,8 @@ ignore = [
"RUF012",
"SLF001",
"TD003",
"TRY002",
"TRY003",
]
select = ["ALL"]

Expand Down
2 changes: 2 additions & 0 deletions backend/settings/api_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
from ninja import NinjaAPI
from ninja.throttling import AnonRateThrottle, AuthRateThrottle

from apps.core.api.ninja import APIKeyAuth
from apps.github.api.v1.urls import router as github_router
from apps.owasp.api.v1.urls import router as owasp_router

api = NinjaAPI(
description="API for OWASP related entities",
title="OWASP Nest API",
version="1.0.0",
auth=APIKeyAuth(),
throttle=[
AnonRateThrottle("1/s"),
AuthRateThrottle("10/s"),
Expand Down
8 changes: 5 additions & 3 deletions backend/settings/graphql.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@
import strawberry

from apps.github.graphql.queries import GithubQuery
from apps.nest.graphql.mutations.user import UserMutations
from apps.nest.graphql.mutations import NestMutations
from apps.nest.graphql.queries import NestQuery
from apps.owasp.graphql.queries import OwaspQuery


class Mutation(UserMutations):
@strawberry.type
class Mutation(NestMutations):
"""Schema mutations."""


@strawberry.type
class Query(GithubQuery, OwaspQuery):
class Query(GithubQuery, NestQuery, OwaspQuery):
"""Schema queries."""


Expand Down
3 changes: 1 addition & 2 deletions frontend/src/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,8 @@ const authOptions = {
})
if (!data?.githubAuth?.authUser) throw new Error('User sync failed')
return true
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
throw new Error('GitHub authentication failed')
throw new Error('GitHub authentication failed' + error.message)
}
}
return true
Expand Down
Loading
Loading