From d53650bacecd1d4b3da197e4eb904ef37710be61 Mon Sep 17 00:00:00 2001 From: Mike Chagnon Date: Tue, 14 Jan 2025 11:27:53 -0800 Subject: [PATCH 1/2] add api account support --- ifcbdb/assets/js/bin.js | 7 +- .../dashboard/migrations/0038_apiaccount.py | 22 +++++ ifcbdb/dashboard/models.py | 7 ++ ifcbdb/dashboard/urls.py | 3 + ifcbdb/dashboard/views.py | 32 +++++-- ifcbdb/secure/forms.py | 14 ++- ifcbdb/secure/urls.py | 4 + ifcbdb/secure/views.py | 58 +++++++++++- ifcbdb/templates/dashboard/bin.html | 3 + .../secure/api-account-management.html | 88 +++++++++++++++++++ ifcbdb/templates/secure/edit-api-account.html | 52 +++++++++++ ifcbdb/templates/secure/index.html | 1 + 12 files changed, 280 insertions(+), 11 deletions(-) create mode 100644 ifcbdb/dashboard/migrations/0038_apiaccount.py create mode 100644 ifcbdb/templates/secure/api-account-management.html create mode 100644 ifcbdb/templates/secure/edit-api-account.html diff --git a/ifcbdb/assets/js/bin.js b/ifcbdb/assets/js/bin.js index 30167a24..e7c55e6c 100644 --- a/ifcbdb/assets/js/bin.js +++ b/ifcbdb/assets/js/bin.js @@ -242,7 +242,12 @@ function updateBinDownloadLinks(data) { $("#download-adc").attr("href", infix + _bin + ".adc"); $("#download-hdr").attr("href", infix + _bin + ".hdr"); $("#download-roi").attr("href", infix + _bin + ".roi"); - $("#download-zip").attr("href", infix + _bin + ".zip"); + $("#download-zip-form").attr("action", infix + _bin + ".zip"); + $("#download-zip").click(function(e) { + e.preventDefault(); + $("#download-zip-form").submit(); + }); + $("#download-blobs").attr("href", infix + _bin + "_blob.zip"); $("#download-features").attr("href", infix + _bin + "_features.csv"); $("#download-class-scores").attr("href", infix + _bin + "_class_scores.csv"); diff --git a/ifcbdb/dashboard/migrations/0038_apiaccount.py b/ifcbdb/dashboard/migrations/0038_apiaccount.py new file mode 100644 index 00000000..aee2c5d6 --- /dev/null +++ b/ifcbdb/dashboard/migrations/0038_apiaccount.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.17 on 2025-01-13 19:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0037_appsettings'), + ] + + operations = [ + migrations.CreateModel( + name='ApiAccount', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('api_key', models.CharField(max_length=128)), + ('is_active', models.BooleanField(default=True)), + ], + ), + ] diff --git a/ifcbdb/dashboard/models.py b/ifcbdb/dashboard/models.py index 7dbe51ce..f63aa2a6 100644 --- a/ifcbdb/dashboard/models.py +++ b/ifcbdb/dashboard/models.py @@ -917,3 +917,10 @@ class AppSettings(models.Model): default_latitude = models.FloatField(blank=False, null=False, default=DEFAULT_LATITUDE) default_longitude = models.FloatField(blank=False, null=False, default=DEFAULT_LONGITUDE) default_zoom_level = models.IntegerField(blank=False, null=False, default=DEFAULT_ZOOM_LEVEL) + +# api + +class ApiAccount(models.Model): + name = models.CharField(max_length=50) + api_key = models.CharField(max_length=128) + is_active = models.BooleanField(default=True, blank=False, null=False) diff --git a/ifcbdb/dashboard/urls.py b/ifcbdb/dashboard/urls.py index 8a056bc7..418e2bc0 100644 --- a/ifcbdb/dashboard/urls.py +++ b/ifcbdb/dashboard/urls.py @@ -58,6 +58,9 @@ def to_url(self, value): path('data/_.png', views.image_png, name='image_png'), path('data/_.jpg', views.image_jpg, name='image_jpg'), + # zip access (requires API key) + path('download/.zip', views.download_zip, name='download_zip'), + ################################################################## # Legacy URLs # Urls below must remain in this specific order (by granularity) diff --git a/ifcbdb/dashboard/views.py b/ifcbdb/dashboard/views.py index d5e9218a..641924e3 100644 --- a/ifcbdb/dashboard/views.py +++ b/ifcbdb/dashboard/views.py @@ -11,9 +11,9 @@ from django.shortcuts import render, get_object_or_404, reverse from django.http import \ HttpResponse, FileResponse, Http404, HttpResponseBadRequest, JsonResponse, \ - HttpResponseRedirect, HttpResponseNotFound, StreamingHttpResponse + HttpResponseRedirect, HttpResponseNotFound, StreamingHttpResponse, HttpResponseForbidden from django.views.decorators.cache import cache_control -from django.views.decorators.http import require_POST +from django.views.decorators.http import require_POST, require_GET from django.core.cache import cache from celery.result import AsyncResult @@ -21,7 +21,7 @@ from ifcb.data.imageio import format_image from ifcb.data.adc import schema_names -from .models import Dataset, Bin, Instrument, Timeline, bin_query, Tag, Comment, normalize_tag_name +from .models import Dataset, Bin, Instrument, Timeline, bin_query, Tag, Comment, normalize_tag_name, ApiAccount from .forms import DatasetSearchForm from common.utilities import * @@ -717,15 +717,35 @@ def class_scores_csv(request, dataset_name, bin_id): resp['Content-Disposition'] = 'attachment; filename={}'.format(filename) return resp -def zip(request, bin_id, **kw): +from django.views.decorators.csrf import csrf_protect + +@require_POST +def zip(request, bin_id, **kwargs): + return _build_zip(request, bin_id, **kwargs) + +@require_GET +def download_zip(request, bin_id, **kwargs): + api_key = request.headers.get("ApiKey") + + api_account = ApiAccount.objects.filter(api_key=api_key).first() + if not api_account: + return HttpResponseForbidden() + + return _build_zip(request, bin_id, **kwargs) + +def _build_zip(request, bin_id, **kwargs): b = get_object_or_404(Bin, pid=bin_id) - if 'dataset_name' in kw: - bin_in_dataset_or_404(b, kw['dataset_name']) + + if 'dataset_name' in kwargs: + bin_in_dataset_or_404(b, kwargs['dataset_name']) + try: zip_buf = b.zip() except KeyError: raise Http404("raw data not found") + filename = '{}.zip'.format(bin_id) + return FileResponse(zip_buf, as_attachment=True, filename=filename, content_type='application/zip') diff --git a/ifcbdb/secure/forms.py b/ifcbdb/secure/forms.py index dbce32c7..42a79736 100644 --- a/ifcbdb/secure/forms.py +++ b/ifcbdb/secure/forms.py @@ -1,7 +1,7 @@ import re, os from django import forms -from dashboard.models import Dataset, Instrument, DataDirectory, AppSettings, \ +from dashboard.models import Dataset, Instrument, DataDirectory, AppSettings, ApiAccount, \ DEFAULT_LATITUDE, DEFAULT_LONGITUDE, DEFAULT_ZOOM_LEVEL @@ -178,6 +178,18 @@ class Meta: "timeout": forms.TextInput(attrs={"class": "form-control form-control-sm", "placeholder": "Timeout"}), } +class ApiAccountForm(forms.ModelForm): + + class Meta: + model = ApiAccount + fields = ["id", "name", "api_key", "is_active"] + + widgets = { + "name": forms.TextInput(attrs={"class": "form-control form-control-sm", "placeholder": "Name"}), + "api_key": forms.TextInput(attrs={"class": "form-control form-control-sm", "placeholder": "Api Key"}), + "is_active": forms.CheckboxInput(attrs={"class": "custom-control-input"}) + } + class MetadataUploadForm(forms.Form): file = forms.FileField(label="Choose file", widget=forms.ClearableFileInput(attrs={"class": "custom-file-input"})) diff --git a/ifcbdb/secure/urls.py b/ifcbdb/secure/urls.py index 18b19271..0701afc0 100644 --- a/ifcbdb/secure/urls.py +++ b/ifcbdb/secure/urls.py @@ -12,6 +12,8 @@ path('edit-dataset/', views.edit_dataset, name='edit-dataset'), path('instrument-management', views.instrument_management, name='instrument-management'), path('edit-instrument/', views.edit_instrument, name='edit-instrument'), + path('api-account-management', views.api_account_management, name='api-account-management'), + path('edit-api-account/', views.edit_api_account, name='edit-api-account'), path('upload-metadata', views.upload_metadata, name='upload-metadata'), path('directory-management/', views.directory_management, name='directory-management'), path('edit-directory//', views.edit_directory, name='edit-directory'), @@ -20,8 +22,10 @@ # Paths used for AJAX requests specifically for returning data formatted for DataTables path('api/dt/datasets', views.dt_datasets, name='datasets_dt'), path('api/dt/instruments', views.dt_instruments, name='instruments_dt'), + path('api/dt/api-accounts', views.dt_api_accounts, name='api_accounts_dt'), path('api/dt/directories/', views.dt_directories, name='directories_dt'), path('api/delete-directory//', views.delete_directory, name='delete-directory'), + path('api/delete-api-account/', views.delete_api_account, name='delete-api-account'), path('api/add-tag/', views.add_tag, name='add_tag'), path('api/remove-tag/', views.remove_tag, name='remove_tag'), path('api/add-comment/', views.add_comment, name='add_comment'), diff --git a/ifcbdb/secure/views.py b/ifcbdb/secure/views.py index c6a13493..d9c42104 100644 --- a/ifcbdb/secure/views.py +++ b/ifcbdb/secure/views.py @@ -6,8 +6,8 @@ import pandas as pd -from dashboard.models import Dataset, Instrument, DataDirectory, Tag, TagEvent, Bin, Comment, AppSettings -from .forms import DatasetForm, InstrumentForm, DirectoryForm, MetadataUploadForm, AppSettingsForm +from dashboard.models import Dataset, Instrument, DataDirectory, Tag, TagEvent, Bin, Comment, AppSettings, ApiAccount +from .forms import DatasetForm, InstrumentForm, DirectoryForm, MetadataUploadForm, AppSettingsForm, ApiAccountForm from django.core.cache import cache from celery.result import AsyncResult @@ -132,7 +132,6 @@ def delete_directory(request, dataset_id, id): directory.delete() return JsonResponse({}) - def dt_instruments(request): if not request.user.is_authenticated: return HttpResponseForbidden() @@ -172,6 +171,59 @@ def edit_instrument(request, id): "form": form, }) +@login_required +def api_account_management(request): + return render(request, 'secure/api-account-management.html', { + + }) + +def dt_api_accounts(request): + if not request.user.is_authenticated: + return HttpResponseForbidden() + + api_accounts = ApiAccount.objects.all() + + return JsonResponse({ + "data": [ + { + "id": item.id, + "name": item.name, + "is_active": item.is_active, + } + for item in api_accounts + ] + }) + +@login_required +def edit_api_account(request, id): + if int(id) > 0: + api_account = get_object_or_404(ApiAccount, pk=id) + else: + api_account = ApiAccount() + + if request.POST: + form = ApiAccountForm(request.POST, instance=api_account) + if form.is_valid(): + form.save() + + return redirect(reverse("secure:api-account-management")) + else: + form = ApiAccountForm(instance=api_account) + + return render(request, "secure/edit-api-account.html", { + "api_account": api_account, + "form": form, + }) + +@require_POST +def delete_api_account(request, id): + if not request.user.is_authenticated: + return HttpResponseForbidden() + + api_account = get_object_or_404(ApiAccount, pk=id) + api_account.delete() + + return JsonResponse({}) @login_required def app_settings(request): diff --git a/ifcbdb/templates/dashboard/bin.html b/ifcbdb/templates/dashboard/bin.html index 0dedb4ed..ad10c77d 100644 --- a/ifcbdb/templates/dashboard/bin.html +++ b/ifcbdb/templates/dashboard/bin.html @@ -552,6 +552,9 @@
  • HDR
  • ROI
  • ZIP
  • +
    + {% csrf_token %} +
    diff --git a/ifcbdb/templates/secure/api-account-management.html b/ifcbdb/templates/secure/api-account-management.html new file mode 100644 index 00000000..09ead983 --- /dev/null +++ b/ifcbdb/templates/secure/api-account-management.html @@ -0,0 +1,88 @@ +{% extends 'base.html' %} {% block content %} +
    +
    + Api Account Management +
    + +
    +
    +
    +
    + + + + + + + + +
    NumberNickname
    +
    +
    + + + +{% endblock %} {% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/ifcbdb/templates/secure/edit-api-account.html b/ifcbdb/templates/secure/edit-api-account.html new file mode 100644 index 00000000..be023afa --- /dev/null +++ b/ifcbdb/templates/secure/edit-api-account.html @@ -0,0 +1,52 @@ +{% extends 'base.html' %} + +{% block content %} + +
    +
    + {% if api_account.id > 0 %}Edit{% else %}Add New{% endif %} Api Account +
    +
    +
    + +{% if form.non_field_errors %} +
    + {% for err in form.non_field_errors %} +

    {{ err }}

    + {% endfor %} +
    +{% endif %} + +
    + {% csrf_token %} + {{ form.id }} + +
    +
    + + {{ form.name }} + {% if form.name.errors %}{{ form.name.errors.as_text }}{% endif %} +
    +
    +
    +
    + + {{ form.api_key }} + {% if form.api_key.errors %}{{ form.api_key.errors.as_text }}{% endif %} +
    +
    +
    +
    +
    + {{ form.is_active }} + +
    +
    +
    +
    + Cancel + +
    +
    + +{% endblock %} \ No newline at end of file diff --git a/ifcbdb/templates/secure/index.html b/ifcbdb/templates/secure/index.html index 4401b8e9..1c2ac951 100644 --- a/ifcbdb/templates/secure/index.html +++ b/ifcbdb/templates/secure/index.html @@ -15,6 +15,7 @@
    • Dataset Management
    • Instrument Management
    • +
    • Api Account Management
    • Upload Metadata
    • App Settings
    • {% if request.user.is_superuser %} From 6fef0027ffbb87bb6cff5d041d928057eef95121 Mon Sep 17 00:00:00 2001 From: Mike Chagnon Date: Thu, 16 Jan 2025 19:24:05 -0800 Subject: [PATCH 2/2] hash api keys --- ifcbdb/dashboard/views.py | 11 ++++- ifcbdb/secure/forms.py | 10 +++- ifcbdb/secure/views.py | 21 +++++++- ifcbdb/templates/secure/edit-api-account.html | 49 ++++++++++++++++++- 4 files changed, 83 insertions(+), 8 deletions(-) diff --git a/ifcbdb/dashboard/views.py b/ifcbdb/dashboard/views.py index 641924e3..7d27edb6 100644 --- a/ifcbdb/dashboard/views.py +++ b/ifcbdb/dashboard/views.py @@ -1,5 +1,6 @@ import json import re +import hashlib from io import BytesIO import numpy as np @@ -725,9 +726,15 @@ def zip(request, bin_id, **kwargs): @require_GET def download_zip(request, bin_id, **kwargs): - api_key = request.headers.get("ApiKey") + # Check the header first for an api key, and inot present, check the query string + api_key = request.headers.get("ApiKey") or request.GET.get("ApiKey") + if not api_key: + return HttpResponseForbidden() + + + hash = hashlib.sha512(api_key.encode("utf8")).hexdigest() - api_account = ApiAccount.objects.filter(api_key=api_key).first() + api_account = ApiAccount.objects.filter(api_key=hash, is_active=True).first() if not api_account: return HttpResponseForbidden() diff --git a/ifcbdb/secure/forms.py b/ifcbdb/secure/forms.py index 42a79736..c0319e50 100644 --- a/ifcbdb/secure/forms.py +++ b/ifcbdb/secure/forms.py @@ -1,4 +1,4 @@ -import re, os +import re, os, uuid from django import forms from dashboard.models import Dataset, Instrument, DataDirectory, AppSettings, ApiAccount, \ @@ -179,6 +179,12 @@ class Meta: } class ApiAccountForm(forms.ModelForm): + replace_api_key = forms.BooleanField(widget=forms.HiddenInput(), required=False) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields["api_key"].required = False class Meta: model = ApiAccount @@ -187,7 +193,7 @@ class Meta: widgets = { "name": forms.TextInput(attrs={"class": "form-control form-control-sm", "placeholder": "Name"}), "api_key": forms.TextInput(attrs={"class": "form-control form-control-sm", "placeholder": "Api Key"}), - "is_active": forms.CheckboxInput(attrs={"class": "custom-control-input"}) + "is_active": forms.CheckboxInput(attrs={"class": "custom-control-input"}), } diff --git a/ifcbdb/secure/views.py b/ifcbdb/secure/views.py index d9c42104..2f1612b0 100644 --- a/ifcbdb/secure/views.py +++ b/ifcbdb/secure/views.py @@ -1,3 +1,4 @@ +import uuid, hashlib from django.contrib.auth.decorators import login_required from django import forms from django.views.decorators.http import require_POST, require_GET @@ -199,15 +200,31 @@ def edit_api_account(request, id): if int(id) > 0: api_account = get_object_or_404(ApiAccount, pk=id) else: - api_account = ApiAccount() + api_account = ApiAccount(api_key=str(uuid.uuid4()).lower()) if request.POST: + existing_api_key = api_account.api_key + form = ApiAccountForm(request.POST, instance=api_account) if form.is_valid(): - form.save() + replace_api_key = form.cleaned_data.get("replace_api_key") + + instance = form.save(commit=False) + + if int(id) == 0 or replace_api_key: + api_key = form.cleaned_data.get("api_key").encode("utf8") + instance.api_key = hashlib.sha512(api_key).hexdigest() + else: + instance.api_key = existing_api_key + + instance.save() return redirect(reverse("secure:api-account-management")) else: + # Clear out the api key so it doesn't render back on the form + if int(id) > 0: + api_account.api_key = "" + form = ApiAccountForm(instance=api_account) return render(request, "secure/edit-api-account.html", { diff --git a/ifcbdb/templates/secure/edit-api-account.html b/ifcbdb/templates/secure/edit-api-account.html index be023afa..7b3e1ee8 100644 --- a/ifcbdb/templates/secure/edit-api-account.html +++ b/ifcbdb/templates/secure/edit-api-account.html @@ -31,8 +31,21 @@
      - {{ form.api_key }} - {% if form.api_key.errors %}{{ form.api_key.errors.as_text }}{% endif %} +
      + This will be the only time this API key can be viewed. Be sure to copy the value before saving. +
      +
      + {{ form.api_key }} + {{ form.replace_api_key }} + {% if form.api_key.errors %}{{ form.api_key.errors.as_text }}{% endif %} +
      +
      + {% if form.instance.id %} + + {% endif %} + + +
      @@ -49,4 +62,36 @@
      +{% endblock %} + +{% block scripts %} + {% endblock %} \ No newline at end of file