From d66f9c85a42c68743b2c4612a4cc6465690945d3 Mon Sep 17 00:00:00 2001 From: wicky <130177258+wicky-zipstack@users.noreply.github.com> Date: Tue, 7 Apr 2026 07:09:53 +0530 Subject: [PATCH 1/5] fix: standardize API token format across all creation paths Three places were creating API tokens with inconsistent formats: - user.py: used uuid4().hex (no vtk_ prefix, no label, no signature) - views.py: stored raw frontend token (no format enforcement) - api_tokens/views.py: generated vtk_ but never persisted to DB All now use generate_api_key() for consistent vtk_ prefix, with label "Default", proper signature, and configurable expiry via API_KEY_EXPIRY_DAYS. --- .../backend/core/routers/api_tokens/views.py | 19 ++++++++++++++++++- backend/backend/core/user.py | 10 +++++++--- backend/backend/core/views.py | 11 ++++++++++- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/backend/backend/core/routers/api_tokens/views.py b/backend/backend/core/routers/api_tokens/views.py index b2bb9e8..8f3ec36 100644 --- a/backend/backend/core/routers/api_tokens/views.py +++ b/backend/backend/core/routers/api_tokens/views.py @@ -182,8 +182,25 @@ def regenerate_api_key(request: Request, key_id: str) -> Response: @api_view([HTTPMethods.POST]) @handle_http_request def generate_token(request: Request) -> Response: - """Legacy token generation endpoint.""" + """Legacy token generation endpoint. + + Now creates a proper APIToken record with vtk_ prefix, label, and expiry + to maintain consistency with the api-keys/create endpoint. + """ + # Delete any existing default token for this user + APIToken.objects.filter(user=request.user, label="Default").delete() + api_key = generate_api_key() + sig = generate_signature(api_key) + + APIToken.objects.create( + user=request.user, + token=api_key, + signature=sig, + label="Default", + expires_at=now() + timedelta(days=django_settings.API_KEY_EXPIRY_DAYS), + ) + return Response({ "message": "Token generated successfully.", "token": api_key, diff --git a/backend/backend/core/user.py b/backend/backend/core/user.py index 4c4c060..e2e2bc3 100644 --- a/backend/backend/core/user.py +++ b/backend/backend/core/user.py @@ -1,8 +1,8 @@ import logging -import uuid from datetime import timedelta from typing import Any, Optional +from django.conf import settings as django_settings from django.db import IntegrityError from django.db import transaction from django.utils.timezone import now @@ -10,6 +10,7 @@ from backend.core.models.api_tokens import APIToken from backend.core.models.organization_model import Organization from backend.core.models.user_model import User +from backend.core.services.api_key_service import generate_api_key, generate_signature Logger = logging.getLogger(__name__) @@ -87,11 +88,14 @@ def get_or_create_valid_token(self, user: User, organization: Organization): token.delete() token = None if token is None: + api_key = generate_api_key() token = APIToken.objects.create( user=user, organization=organization, - token=str(uuid.uuid4().hex), - expires_at=now() + timedelta(days=90), + token=api_key, + signature=generate_signature(api_key), + label="Default", + expires_at=now() + timedelta(days=django_settings.API_KEY_EXPIRY_DAYS), ) logging.info(f"A new api token for user: {user} and tenant: {organization} is created") except Exception as e: diff --git a/backend/backend/core/views.py b/backend/backend/core/views.py index 888a71e..815bb45 100644 --- a/backend/backend/core/views.py +++ b/backend/backend/core/views.py @@ -16,6 +16,8 @@ from backend.utils.tenant_context import get_current_tenant from backend.core.models.api_tokens import APIToken +from backend.core.services.api_key_service import generate_api_key, generate_signature +from django.conf import settings as django_settings from django.utils.timezone import now from datetime import timedelta @@ -61,7 +63,14 @@ def update_user_token(request, user): if existing_token: existing_token.delete() - APIToken.objects.create(user=user, token=new_token, expires_at= now() + timedelta(days=90)) + api_key = generate_api_key() + APIToken.objects.create( + user=user, + token=api_key, + signature=generate_signature(api_key), + label="Default", + expires_at=now() + timedelta(days=django_settings.API_KEY_EXPIRY_DAYS), + ) else: if existing_token: existing_token.delete() From 68474fcfe9d1fd03087ba4a152ccaba9e9d5b780 Mon Sep 17 00:00:00 2001 From: wicky <130177258+wicky-zipstack@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:55:10 +0530 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20address=20Greptile=20review=20?= =?UTF-8?q?=E2=80=94=20upsert=20pattern,=20audit=20logs,=20rename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1: Replace delete-all-by-label with upsert in generate_token to avoid destroying user-created keys named "Default" - P2: Add log_api_key_event() to generate_token and update_user_token - P2: Rename new_token to token_value with comment clarifying it is only used as an "unchanged" gate, not stored --- .../backend/core/routers/api_tokens/views.py | 36 ++++++++++++++----- backend/backend/core/views.py | 15 +++++--- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/backend/backend/core/routers/api_tokens/views.py b/backend/backend/core/routers/api_tokens/views.py index 8f3ec36..e50df94 100644 --- a/backend/backend/core/routers/api_tokens/views.py +++ b/backend/backend/core/routers/api_tokens/views.py @@ -186,19 +186,37 @@ def generate_token(request: Request) -> Response: Now creates a proper APIToken record with vtk_ prefix, label, and expiry to maintain consistency with the api-keys/create endpoint. + Uses upsert: updates existing auto-generated token or creates a new one. """ - # Delete any existing default token for this user - APIToken.objects.filter(user=request.user, label="Default").delete() - api_key = generate_api_key() sig = generate_signature(api_key) + new_expiry = now() + timedelta(days=django_settings.API_KEY_EXPIRY_DAYS) + + # Upsert: update existing auto-generated token if present, else create + existing = APIToken.objects.filter( + user=request.user, label="Default" + ).order_by("-created_at").first() + + if existing: + existing.token = api_key + existing.signature = sig + existing.is_disabled = False + existing.expires_at = new_expiry + existing.save(update_fields=["token", "signature", "is_disabled", "expires_at"]) + token = existing + else: + token = APIToken.objects.create( + user=request.user, + token=api_key, + signature=sig, + label="Default", + expires_at=new_expiry, + ) - APIToken.objects.create( - user=request.user, - token=api_key, - signature=sig, - label="Default", - expires_at=now() + timedelta(days=django_settings.API_KEY_EXPIRY_DAYS), + logger.info(f"Legacy token generated: id={token.id}, user={request.user.email}") + log_api_key_event( + request, action="create", key_id=token.id, + key_label="Default", key_masked=token.masked_token, ) return Response({ diff --git a/backend/backend/core/views.py b/backend/backend/core/views.py index 815bb45..2e3bfea 100644 --- a/backend/backend/core/views.py +++ b/backend/backend/core/views.py @@ -16,6 +16,7 @@ from backend.utils.tenant_context import get_current_tenant from backend.core.models.api_tokens import APIToken +from backend.core.services.api_key_audit import log_api_key_event from backend.core.services.api_key_service import generate_api_key, generate_signature from django.conf import settings as django_settings from django.utils.timezone import now @@ -54,23 +55,29 @@ def update_user_profile(request: Request) -> Response: def update_user_token(request, user): - new_token = request.data.get("token") + # token_value is sent back by the frontend — used only to detect "unchanged" + token_value = request.data.get("token") existing_token: APIToken = APIToken.objects.filter(user=user).first() - if new_token: - if existing_token and existing_token.token == new_token: + if token_value: + # Skip regeneration if the token hasn't changed + if existing_token and existing_token.token == token_value: return if existing_token: existing_token.delete() api_key = generate_api_key() - APIToken.objects.create( + token = APIToken.objects.create( user=user, token=api_key, signature=generate_signature(api_key), label="Default", expires_at=now() + timedelta(days=django_settings.API_KEY_EXPIRY_DAYS), ) + log_api_key_event( + request, action="create", key_id=token.id, + key_label="Default", key_masked=token.masked_token, + ) else: if existing_token: existing_token.delete() From 446b7c434db370cce0b51c30dd372a2daf13c6bf Mon Sep 17 00:00:00 2001 From: wicky <130177258+wicky-zipstack@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:02:03 +0530 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20address=20Greptile=20round=202=20?= =?UTF-8?q?=E2=80=94=20scope=20filter,=20audit=20action,=20token=5Fhash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1: Scope update_user_token filter to label="Default" to avoid deleting user-managed keys - P2: Use "regenerate" audit action when updating existing token in generate_token, "create" only for new tokens - P2: Refresh token_hash (SHA-256) on upsert update and regenerate to keep DB hash consistent with new token value --- backend/backend/core/routers/api_tokens/views.py | 11 ++++++++--- backend/backend/core/views.py | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/backend/backend/core/routers/api_tokens/views.py b/backend/backend/core/routers/api_tokens/views.py index e50df94..87da560 100644 --- a/backend/backend/core/routers/api_tokens/views.py +++ b/backend/backend/core/routers/api_tokens/views.py @@ -1,3 +1,4 @@ +import hashlib import logging from datetime import timedelta @@ -161,10 +162,11 @@ def regenerate_api_key(request: Request, key_id: str) -> Response: new_sig = generate_signature(new_key) token.token = new_key + token.token_hash = hashlib.sha256(new_key.encode()).hexdigest() token.signature = new_sig token.is_disabled = False token.expires_at = now() + timedelta(days=django_settings.API_KEY_EXPIRY_DAYS) - token.save(update_fields=["token", "signature", "is_disabled", "expires_at"]) + token.save(update_fields=["token", "token_hash", "signature", "is_disabled", "expires_at"]) logger.info(f"API key regenerated: id={token.id}, label={token.label}, user={request.user.email}") log_api_key_event( @@ -199,11 +201,13 @@ def generate_token(request: Request) -> Response: if existing: existing.token = api_key + existing.token_hash = hashlib.sha256(api_key.encode()).hexdigest() existing.signature = sig existing.is_disabled = False existing.expires_at = new_expiry - existing.save(update_fields=["token", "signature", "is_disabled", "expires_at"]) + existing.save(update_fields=["token", "token_hash", "signature", "is_disabled", "expires_at"]) token = existing + audit_action = "regenerate" else: token = APIToken.objects.create( user=request.user, @@ -212,10 +216,11 @@ def generate_token(request: Request) -> Response: label="Default", expires_at=new_expiry, ) + audit_action = "create" logger.info(f"Legacy token generated: id={token.id}, user={request.user.email}") log_api_key_event( - request, action="create", key_id=token.id, + request, action=audit_action, key_id=token.id, key_label="Default", key_masked=token.masked_token, ) diff --git a/backend/backend/core/views.py b/backend/backend/core/views.py index 2e3bfea..029d27b 100644 --- a/backend/backend/core/views.py +++ b/backend/backend/core/views.py @@ -57,7 +57,7 @@ def update_user_profile(request: Request) -> Response: def update_user_token(request, user): # token_value is sent back by the frontend — used only to detect "unchanged" token_value = request.data.get("token") - existing_token: APIToken = APIToken.objects.filter(user=user).first() + existing_token: APIToken = APIToken.objects.filter(user=user, label="Default").first() if token_value: # Skip regeneration if the token hasn't changed From 2dfe2eb7335323ebe596583af10a52e32edbe011 Mon Sep 17 00:00:00 2001 From: wicky <130177258+wicky-zipstack@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:06:16 +0530 Subject: [PATCH 4/5] fix: return new token in profile update response When update_user_token generates a fresh vtk_ key, return it in the profile update response so the frontend displays the correct value. --- backend/backend/core/views.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/backend/backend/core/views.py b/backend/backend/core/views.py index 029d27b..a946f53 100644 --- a/backend/backend/core/views.py +++ b/backend/backend/core/views.py @@ -44,14 +44,14 @@ def update_user_profile(request: Request) -> Response: user_service = UserService() user = user_service.get_user_by_email(request.user.email) user = user_service.update_user_display_names(user, request.data["first_name"], request.data["last_name"]) - update_user_token(request, user) - return Response( - data={ - 'first_name': user.first_name, - 'last_name': user.last_name, - }, - status=status.HTTP_200_OK - ) + updated_token = update_user_token(request, user) + data = { + 'first_name': user.first_name, + 'last_name': user.last_name, + } + if updated_token: + data['token'] = updated_token + return Response(data=data, status=status.HTTP_200_OK) def update_user_token(request, user): @@ -78,9 +78,11 @@ def update_user_token(request, user): request, action="create", key_id=token.id, key_label="Default", key_masked=token.masked_token, ) + return api_key else: if existing_token: existing_token.delete() + return None @api_view([HTTPMethods.GET]) From d746024275356905dec801ecf6102ec9ac1e1ff7 Mon Sep 17 00:00:00 2001 From: wicky <130177258+wicky-zipstack@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:18:19 +0530 Subject: [PATCH 5/5] fix: address CodeQL SHA-256 finding and audit action consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move token_hash computation to APIToken.save() (always recalculate, not just on first save) — removes explicit hashlib.sha256 from views - CodeQL flagged SHA-256 as weak for passwords — false positive since we hash API tokens for fast lookup, not passwords - Fix audit action in update_user_token: "regenerate" when replacing existing token, "create" only for first-time creation --- backend/backend/core/models/api_tokens.py | 2 +- backend/backend/core/routers/api_tokens/views.py | 9 ++++----- backend/backend/core/views.py | 4 +++- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/backend/backend/core/models/api_tokens.py b/backend/backend/core/models/api_tokens.py index 369ffed..a73bbe4 100644 --- a/backend/backend/core/models/api_tokens.py +++ b/backend/backend/core/models/api_tokens.py @@ -23,7 +23,7 @@ class APIToken(DefaultOrganizationMixin, BaseModel): last_used_at = models.DateTimeField(null=True, blank=True) def save(self, *args, **kwargs): - if self.token and not self.token_hash: + if self.token: self.token_hash = hashlib.sha256(self.token.encode()).hexdigest() super().save(*args, **kwargs) diff --git a/backend/backend/core/routers/api_tokens/views.py b/backend/backend/core/routers/api_tokens/views.py index 87da560..be19d0f 100644 --- a/backend/backend/core/routers/api_tokens/views.py +++ b/backend/backend/core/routers/api_tokens/views.py @@ -1,4 +1,3 @@ -import hashlib import logging from datetime import timedelta @@ -162,11 +161,11 @@ def regenerate_api_key(request: Request, key_id: str) -> Response: new_sig = generate_signature(new_key) token.token = new_key - token.token_hash = hashlib.sha256(new_key.encode()).hexdigest() token.signature = new_sig token.is_disabled = False token.expires_at = now() + timedelta(days=django_settings.API_KEY_EXPIRY_DAYS) - token.save(update_fields=["token", "token_hash", "signature", "is_disabled", "expires_at"]) + # save() recalculates token_hash automatically + token.save() logger.info(f"API key regenerated: id={token.id}, label={token.label}, user={request.user.email}") log_api_key_event( @@ -201,11 +200,11 @@ def generate_token(request: Request) -> Response: if existing: existing.token = api_key - existing.token_hash = hashlib.sha256(api_key.encode()).hexdigest() existing.signature = sig existing.is_disabled = False existing.expires_at = new_expiry - existing.save(update_fields=["token", "token_hash", "signature", "is_disabled", "expires_at"]) + # save() recalculates token_hash automatically + existing.save() token = existing audit_action = "regenerate" else: diff --git a/backend/backend/core/views.py b/backend/backend/core/views.py index a946f53..d687482 100644 --- a/backend/backend/core/views.py +++ b/backend/backend/core/views.py @@ -63,6 +63,7 @@ def update_user_token(request, user): # Skip regeneration if the token hasn't changed if existing_token and existing_token.token == token_value: return + had_existing = existing_token is not None if existing_token: existing_token.delete() @@ -75,7 +76,8 @@ def update_user_token(request, user): expires_at=now() + timedelta(days=django_settings.API_KEY_EXPIRY_DAYS), ) log_api_key_event( - request, action="create", key_id=token.id, + request, action="regenerate" if had_existing else "create", + key_id=token.id, key_label="Default", key_masked=token.masked_token, ) return api_key