diff --git a/examples/membership-card.csv b/examples/membership-card.csv index 1f4d08d..7447dcc 100644 --- a/examples/membership-card.csv +++ b/examples/membership-card.csv @@ -1,2 +1,2 @@ -name surnames email typeOfPerson membershipType organisation affiliatedSince -Pepe Gómez user1@example.org individual Member Pangea 01-01-2023 +firstName lastName email membershipType membershipId affiliatedUntil affiliatedSince typeOfPerson identityDocType identityNumber +Pepe Gómez user1@example.org individual 123456 2024-01-01T00:00:00Z 2023-01-01T00:00:00Z natural DNI 12345678A diff --git a/examples/membership-card.ods b/examples/membership-card.ods index adf3cfc..b1fe1ac 100644 Binary files a/examples/membership-card.ods and b/examples/membership-card.ods differ diff --git a/examples/membership-card.xls b/examples/membership-card.xls index 857fb28..bd10538 100644 Binary files a/examples/membership-card.xls and b/examples/membership-card.xls differ diff --git a/idhub/admin/forms.py b/idhub/admin/forms.py index 8174a32..aec6605 100644 --- a/idhub/admin/forms.py +++ b/idhub/admin/forms.py @@ -1,11 +1,13 @@ import csv import json import base64 +import copy import pandas as pd from pyhanko.sign import signers from django import forms +from django.core.cache import cache from django.utils.translation import gettext_lazy as _ from django.core.exceptions import ValidationError from utils import credtools, certs @@ -23,24 +25,39 @@ from idhub_auth.models import User class ImportForm(forms.Form): did = forms.ChoiceField(label=_("Did"), choices=[]) + eidas1 = forms.ChoiceField( + label=_("Signature with Eidas1"), + choices=[], + required=False + ) schema = forms.ChoiceField(label=_("Schema"), choices=[]) file_import = forms.FileField(label=_("File import")) def __init__(self, *args, **kwargs): self._schema = None self._did = None + self._eidas1 = None self.rows = {} self.properties = {} self.user = kwargs.pop('user', None) super().__init__(*args, **kwargs) + dids = DID.objects.filter(user=self.user) self.fields['did'].choices = [ - (x.did, x.label) for x in DID.objects.filter(user=self.user) + (x.did, x.label) for x in dids.filter(eidas1=False) ] self.fields['schema'].choices = [ (x.id, x.name()) for x in Schemas.objects.filter() ] + if dids.filter(eidas1=True).exists(): + choices = [("", "")] + choices.extend([ + (x.did, x.label) for x in dids.filter(eidas1=True) + ]) + self.fields['eidas1'].choices = choices + else: + self.fields.pop('eidas1') - def clean_did(self): + def clean(self): data = self.cleaned_data["did"] did = DID.objects.filter( user=self.user, @@ -51,6 +68,14 @@ class ImportForm(forms.Form): raise ValidationError("Did is not valid!") self._did = did.first() + + eidas1 = self.cleaned_data.get('eidas1') + if eidas1: + self._eidas1 = DID.objects.filter( + user=self.user, + eidas1=True, + did=eidas1 + ).first() return data @@ -65,7 +90,8 @@ class ImportForm(forms.Form): self._schema = schema.first() try: self.json_schema = json.loads(self._schema.data) - prop = self.json_schema['properties'] + props = [x for x in self.json_schema["allOf"] if 'properties' in x.keys()] + prop = props[0]['properties'] self.properties = prop['credentialSubject']['properties'] except Exception: raise ValidationError("Schema is not valid!") @@ -73,7 +99,10 @@ class ImportForm(forms.Form): if not self.properties: raise ValidationError("Schema is not valid!") - + # TODO we need filter "$ref" of schema for can validate a csv + self.json_schema_filtered = copy.copy(self.json_schema) + allOf = [x for x in self.json_schema["allOf"] if '$ref' not in x.keys()] + self.json_schema_filtered["allOf"] = allOf return data def clean_file_import(self): @@ -115,7 +144,9 @@ class ImportForm(forms.Form): def validate_jsonld(self, line, row): try: - credtools.validate_json(row, self.json_schema) + check = credtools.validate_json(row, self.json_schema_filtered) + if check is not True: + raise ValidationError("Not valid row") except Exception as e: msg = "line {}: {}".format(line+1, e) self.exception(msg) @@ -135,6 +166,7 @@ class ImportForm(forms.Form): csv_data=json.dumps(row), issuer_did=self._did, schema=self._schema, + eidas1_did=self._eidas1 ) def exception(self, msg): @@ -268,9 +300,13 @@ class ImportCertificateForm(forms.Form): did=self.file_name, label=self._label, eidas1=True, - user=self.user + user=self.user, + type=DID.Types.KEY ) + pw = cache.get("KEY_DIDS") + self._did.set_key_material(key_material, pw) + def save(self, commit=True): if commit: diff --git a/idhub/admin/views.py b/idhub/admin/views.py index 7f385ab..eb150e1 100644 --- a/idhub/admin/views.py +++ b/idhub/admin/views.py @@ -17,6 +17,7 @@ from django.views.generic.edit import ( UpdateView, ) from django.shortcuts import get_object_or_404, redirect +from django.core.cache import cache from django.urls import reverse_lazy from django.http import HttpResponse from django.contrib import messages @@ -640,19 +641,20 @@ class DidRegisterView(Credentials, CreateView): icon = 'bi bi-patch-check-fill' wallet = True model = DID - fields = ('label',) + fields = ('label', 'type') success_url = reverse_lazy('idhub:admin_dids') object = None def form_valid(self, form): form.instance.user = self.request.user - form.instance.set_did() + form.instance.set_did(cache.get("KEY_DIDS")) form.save() messages.success(self.request, _('DID created successfully')) Event.set_EV_ORG_DID_CREATED_BY_ADMIN(form.instance) return super().form_valid(form) + class DidEditView(Credentials, UpdateView): template_name = "idhub/admin/did_register.html" subtitle = _('Organization Identities (DID)') diff --git a/idhub/management/commands/initial_datas.py b/idhub/management/commands/initial_datas.py index 034bc68..2dd6ad6 100644 --- a/idhub/management/commands/initial_datas.py +++ b/idhub/management/commands/initial_datas.py @@ -7,6 +7,7 @@ from utils import credtools from django.conf import settings from django.core.management.base import BaseCommand, CommandError from django.contrib.auth import get_user_model +from django.core.cache import cache from decouple import config from idhub.models import DID, Schemas from oidc4vp.models import Organization @@ -36,17 +37,25 @@ class Command(BaseCommand): self.create_organizations(r[0].strip(), r[1].strip()) self.sync_credentials_organizations("pangea.org", "somconnexio.coop") self.sync_credentials_organizations("local 8000", "local 9000") - self.create_defaults_dids() self.create_schemas() def create_admin_users(self, email, password): - User.objects.create_superuser(email=email, password=password) + su = User.objects.create_superuser(email=email, password=password) + su.set_encrypted_sensitive_data(password) + su.save() + key = su.decrypt_sensitive_data(password) + key_dids = {su.id: key} + cache.set("KEY_DIDS", key_dids, None) + self.create_defaults_dids(su, key) def create_users(self, email, password): - u= User.objects.create(email=email, password=password) + u = User.objects.create(email=email, password=password) u.set_password(password) + u.set_encrypted_sensitive_data(password) u.save() + key = u.decrypt_sensitive_data(password) + self.create_defaults_dids(u, key) def create_organizations(self, name, url): @@ -61,12 +70,10 @@ class Command(BaseCommand): org1.my_client_secret = org2.client_secret org1.save() org2.save() - - def create_defaults_dids(self): - for u in User.objects.all(): - did = DID(label="Default", user=u) - did.set_did() - did.save() + def create_defaults_dids(self, u, password): + did = DID(label="Default", user=u, type=DID.Types.KEY) + did.set_did(password) + did.save() def create_schemas(self): schemas_files = os.listdir(settings.SCHEMAS_DIR) @@ -82,10 +89,18 @@ class Command(BaseCommand): try: ldata = json.loads(data) assert credtools.validate_schema(ldata) - name = ldata.get('name') - assert name + dname = ldata.get('name') + assert dname except Exception: return + name = '' + try: + for x in dname: + if settings.LANGUAGE_CODE in x['lang']: + name = x.get('value', '') + except Exception: + return + Schemas.objects.create(file_schema=file_name, data=data, type=name) def open_file(self, file_name): diff --git a/idhub/migrations/0001_initial.py b/idhub/migrations/0001_initial.py index 5aeabdb..bc9f011 100644 --- a/idhub/migrations/0001_initial.py +++ b/idhub/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.5 on 2024-01-11 10:17 +# Generated by Django 4.2.5 on 2024-01-18 11:32 from django.conf import settings from django.db import migrations, models @@ -25,11 +25,18 @@ class Migration(migrations.Migration): verbose_name='ID', ), ), + ( + 'type', + models.PositiveSmallIntegerField( + choices=[(1, 'Key'), (2, 'Web')], verbose_name='Type' + ), + ), ('created_at', models.DateTimeField(auto_now=True)), ('label', models.CharField(max_length=50, verbose_name='Label')), ('did', models.CharField(max_length=250)), ('key_material', models.TextField()), ('eidas1', models.BooleanField(default=False)), + ('didweb_document', models.TextField()), ( 'user', models.ForeignKey( @@ -151,7 +158,6 @@ class Migration(migrations.Migration): ('issued_on', models.DateTimeField(null=True)), ('data', models.TextField()), ('csv_data', models.TextField()), - ('public', models.BooleanField(default=True)), ('hash', models.CharField(max_length=260)), ( 'status', @@ -165,6 +171,14 @@ class Migration(migrations.Migration): default=1, ), ), + ( + 'eidas1_did', + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to='idhub.did', + ), + ), ( 'issuer_did', models.ForeignKey( diff --git a/idhub/models.py b/idhub/models.py index 7d48543..10e0ceb 100644 --- a/idhub/models.py +++ b/idhub/models.py @@ -1,15 +1,21 @@ import json +import ujson import pytz import hashlib import datetime +from collections import OrderedDict from django.db import models from django.conf import settings +from django.core.cache import cache from django.template.loader import get_template from django.utils.translation import gettext_lazy as _ +from nacl import secret + from utils.idhub_ssikit import ( generate_did_controller_key, keydid_from_controller_key, sign_credential, + webdid_from_controller_key, ) from idhub_auth.models import User @@ -404,6 +410,13 @@ class Event(models.Model): class DID(models.Model): + class Types(models.IntegerChoices): + KEY = 1, "Key" + WEB = 2, "Web" + type = models.PositiveSmallIntegerField( + _("Type"), + choices=Types.choices, + ) created_at = models.DateTimeField(auto_now=True) label = models.CharField(_("Label"), max_length=50) did = models.CharField(max_length=250) @@ -418,21 +431,34 @@ class DID(models.Model): related_name='dids', null=True, ) + didweb_document = models.TextField() + def get_key_material(self, password): + return self.user.decrypt_data(self.key_material, password) + + def set_key_material(self, value, password): + self.key_material = self.user.encrypt_data(value, password) + @property def is_organization_did(self): if not self.user: return True return False - def set_did(self): - self.key_material = generate_did_controller_key() - self.did = keydid_from_controller_key(self.key_material) + def set_did(self, password): + new_key_material = generate_did_controller_key() + self.set_key_material(new_key_material, password) + + if self.type == self.Types.KEY: + self.did = keydid_from_controller_key(new_key_material) + elif self.type == self.Types.WEB: + didurl, document = webdid_from_controller_key(new_key_material) + self.did = didurl + self.didweb_document = document def get_key(self): return json.loads(self.key_material) - class Schemas(models.Model): type = models.CharField(max_length=250) file_schema = models.CharField(max_length=250) @@ -445,9 +471,19 @@ class Schemas(models.Model): return {} return json.loads(self.data) - def name(self): - return self.get_schema.get('name', '') + def name(self, request=None): + names = {} + for name in self.get_schema.get('name', []): + lang = name.get('lang') + if 'ca' in lang: + lang = 'ca' + names[lang]= name.get('value') + if request and request.LANGUAGE_CODE in names.keys(): + return names[request.LANGUAGE_CODE] + + return names[settings.LANGUAGE_CODE] + def description(self): return self.get_schema.get('description', '') @@ -468,7 +504,6 @@ class VerificableCredential(models.Model): issued_on = models.DateTimeField(null=True) data = models.TextField() csv_data = models.TextField() - public = models.BooleanField(default=settings.DEFAULT_PUBLIC_CREDENTIALS) hash = models.CharField(max_length=260) status = models.PositiveSmallIntegerField( choices=Status.choices, @@ -490,21 +525,50 @@ class VerificableCredential(models.Model): on_delete=models.CASCADE, related_name='vcredentials', ) + eidas1_did = models.ForeignKey( + DID, + on_delete=models.CASCADE, + null=True + ) schema = models.ForeignKey( Schemas, on_delete=models.CASCADE, related_name='vcredentials', ) + def get_data(self, password): + if not self.data: + return "" + if self.eidas1_did: + return self.data + + return self.user.decrypt_data(self.data, password) + + def set_data(self, value, password): + self.data = self.user.encrypt_data(value, password) + def type(self): return self.schema.type def description(self): - for des in json.loads(self.render()).get('description', []): - if settings.LANGUAGE_CODE == des.get('lang'): + for des in json.loads(self.render("")).get('description', []): + if settings.LANGUAGE_CODE in des.get('lang'): return des.get('value', '') return '' + def get_type(self, lang=None): + schema = json.loads(self.schema.data) + if not schema.get('name'): + return '' + try: + for x in schema['name']: + if lang or settings.LANGUAGE_CODE in x['lang']: + return x.get('value', '') + except: + return self.schema.type + + return '' + def get_status(self): return self.Status(self.status).label @@ -512,21 +576,29 @@ class VerificableCredential(models.Model): data = json.loads(self.csv_data).items() return data - def issue(self, did): + def issue(self, did, password, domain=settings.DOMAIN.strip("/")): if self.status == self.Status.ISSUED: return self.status = self.Status.ISSUED self.subject_did = did self.issued_on = datetime.datetime.now().astimezone(pytz.utc) - self.data = sign_credential( - self.render(), - self.issuer_did.key_material + issuer_pass = cache.get("KEY_DIDS") + # issuer_pass = self.user.decrypt_data( + # cache.get("KEY_DIDS"), + # settings.SECRET_KEY, + # ) + data = sign_credential( + self.render(domain), + self.issuer_did.get_key_material(issuer_pass) ) - if self.public: + if self.eidas1_did: + self.data = data self.hash = hashlib.sha3_256(self.data.encode()).hexdigest() + else: + self.data = self.user.encrypt_data(data, password) - def get_context(self): + def get_context(self, domain): d = json.loads(self.csv_data) issuance_date = '' if self.issued_on: @@ -534,31 +606,38 @@ class VerificableCredential(models.Model): issuance_date = self.issued_on.strftime(format) cred_path = 'credentials' - if self.public: + if self.eidas1_did: cred_path = 'public/credentials' + url_id = "{}/{}/{}".format( - settings.DOMAIN.strip("/"), + domain, cred_path, self.id ) + context = { 'vc_id': url_id, 'issuer_did': self.issuer_did.did, 'subject_did': self.subject_did and self.subject_did.did or '', 'issuance_date': issuance_date, - 'first_name': self.user.first_name, - 'last_name': self.user.last_name, + 'firstName': self.user.first_name or "", + 'lastName': self.user.last_name or "", + 'email': self.user.email, + 'organisation': settings.ORGANIZATION or '', } context.update(d) + context['firstName'] = "" return context - def render(self): - context = self.get_context() + def render(self, domain): + context = self.get_context(domain) template_name = 'credentials/{}'.format( self.schema.file_schema ) tmpl = get_template(template_name) - return tmpl.render(context) + d_ordered = ujson.loads(tmpl.render(context)) + d_minimum = self.filter_dict(d_ordered) + return ujson.dumps(d_minimum) def get_issued_on(self): @@ -567,6 +646,18 @@ class VerificableCredential(models.Model): return '' + def filter_dict(self, dic): + new_dict = OrderedDict() + for key, value in dic.items(): + if isinstance(value, dict): + new_value = self.filter_dict(value) + if new_value: + new_dict[key] = new_value + elif value: + new_dict[key] = value + return new_dict + + class VCTemplate(models.Model): wkit_template_id = models.CharField(max_length=250) data = models.TextField() diff --git a/idhub/templates/credentials/membership-card.json b/idhub/templates/credentials/membership-card.json index bc315bb..5ecda0a 100644 --- a/idhub/templates/credentials/membership-card.json +++ b/idhub/templates/credentials/membership-card.json @@ -1,31 +1,8 @@ { "@context": [ "https://www.w3.org/2018/credentials/v1", - { - "individual": "https://schema.org/Person", - "Member": "https://schema.org/Member", - "startDate": "https://schema.org/startDate", - "jsonSchema": "https://schema.org/jsonSchema", - "$ref": "https://schema.org/jsonSchemaRef", - "credentialSchema": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#credentialSchema", - "organisation": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#organisation", - "membershipType": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#membershipType", - "membershipId": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#membershipId", - "typeOfPerson": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#typeOfPerson", - "identityDocType": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#identityDocType", - "identityNumber": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#identityNumber", - "name": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#name", - "description": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#description", - "value": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#value", - "lang": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#lang", - "surnames": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#surnames", - "email": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#email", - "affiliatedSince": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#affiliatedSince", - "affiliatedUntil": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/contexts/vocab#affiliatedUntil", - "issued": "https://ec.europa.eu/digital-building-blocks/wikis/display/EBSIDOC/Verifiable+Attestation#issued", - "validFrom": "https://ec.europa.eu/digital-building-blocks/wikis/display/EBSIDOC/Verifiable+Attestation#validFrom", - "validUntil": "https://ec.europa.eu/digital-building-blocks/wikis/display/EBSIDOC/Verifiable+Attestation#validUntil" - } + "https://idhub.pangea.org/credentials/base/v1", + "https://idhub.pangea.org/credentials/membership-card/v1" ], "type": [ "VerifiableCredential", @@ -35,22 +12,7 @@ "id": "{{ vc_id }}", "issuer": { "id": "{{ issuer_did }}", - "name": "Pangea", - "description": [ - { - "value": "Pangea.org is a service provider leveraging open-source technologies to provide affordable and accessible solutions for social enterprises and solidarity organisations.", - "lang": "en" - }, - { - "value": "Pangea.org és un proveïdor de serveis que aprofita les tecnologies de codi obert per oferir solucions assequibles i accessibles per a empreses socials i organitzacions solidàries.", - "lang": "ca_ES" - }, - { - "value": "Pangea.org es un proveedor de servicios que aprovecha tecnologías de código abierto para proporcionar soluciones asequibles y accesibles para empresas sociales y organizaciones solidarias.", - "lang": "es" - } - - ] + "name": "{{ organisation }}" }, "issuanceDate": "{{ issuance_date }}", "issued": "{{ issuance_date }}", @@ -86,20 +48,20 @@ ], "credentialSubject": { "id": "{{ subject_did }}", - "organisation": "Pangea", - "membershipType": "{{ membershipType }}", - "membershipId": "{{ vc_id }}", - "affiliatedSince": "{{ affiliatedSince }}", - "affiliatedUntil": "{{ affiliatedUntil }}", + "firstName": "{{ firstName }}", + "lastName": "{{ lastName }}", + "email": "{{ email }}", "typeOfPerson": "{{ typeOfPerson }}", "identityDocType": "{{ identityDocType }}", "identityNumber": "{{ identityNumber }}", - "name": "{{ first_name }}", - "surnames": "{{ last_name }}", - "email": "{{ email }}", - "credentialSchema": { - "id": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/membership-card-schema.json", - "type": "JsonSchema" - } + "organisation": "{{ organisation }}", + "membershipType": "{{ membershipType }}", + "membershipId": "{{ vc_id }}", + "affiliatedSince": "{{ affiliatedSince }}", + "affiliatedUntil": "{{ affiliatedUntil }}" + }, + "credentialSchema": { + "id": "https://idhub.pangea.org/vc_schemas/membership-card.json", + "type": "FullJsonSchemaValidator2021" } } \ No newline at end of file diff --git a/idhub/templates/idhub/admin/credentials.html b/idhub/templates/idhub/admin/credentials.html index cab6a97..caee5fc 100644 --- a/idhub/templates/idhub/admin/credentials.html +++ b/idhub/templates/idhub/admin/credentials.html @@ -23,7 +23,7 @@ {% for f in credentials.all %} - {{ f.type }} + {{ f.get_type }} {{ f.description }} {{ f.get_issued_on }} {{ f.get_status }} diff --git a/idhub/templates/idhub/user/credential.html b/idhub/templates/idhub/user/credential.html index 0a1a3cf..06936a5 100644 --- a/idhub/templates/idhub/user/credential.html +++ b/idhub/templates/idhub/user/credential.html @@ -39,7 +39,7 @@
- {% if object.public %} + {% if object.eidas1_did %}
{% trans 'Sign credential in PDF format' %}
diff --git a/idhub/templates/idhub/user/credentials.html b/idhub/templates/idhub/user/credentials.html index 6f68e56..f12ae00 100644 --- a/idhub/templates/idhub/user/credentials.html +++ b/idhub/templates/idhub/user/credentials.html @@ -22,7 +22,7 @@ {% for f in credentials.all %} - {{ f.type }} + {{ f.get_type }} {{ f.description }} {{ f.get_issued_on }} {{ f.get_status }} diff --git a/idhub/urls.py b/idhub/urls.py index 1e41a5b..648f155 100644 --- a/idhub/urls.py +++ b/idhub/urls.py @@ -17,7 +17,7 @@ Including another URLconf from django.contrib.auth import views as auth_views from django.views.generic import RedirectView from django.urls import path, reverse_lazy -from .views import LoginView +from .views import LoginView, PasswordResetConfirmView, serve_did from .admin import views as views_admin from .user import views as views_user # from .verification_portal import views as views_verification_portal @@ -45,13 +45,16 @@ urlpatterns = [ ), name='password_reset_done' ), - path('auth/reset///', - auth_views.PasswordResetConfirmView.as_view( - template_name='auth/password_reset_confirm.html', - success_url=reverse_lazy('idhub:password_reset_complete') - ), + path('auth/reset///', PasswordResetConfirmView.as_view(), name='password_reset_confirm' ), + # path('auth/reset///', + # auth_views.PasswordResetConfirmView.as_view( + # template_name='auth/password_reset_confirm.html', + # success_url=reverse_lazy('idhub:password_reset_complete') + # ), + # name='password_reset_confirm' + # ), path('auth/reset/done/', auth_views.PasswordResetCompleteView.as_view( template_name='auth/password_reset_complete.html' @@ -177,6 +180,8 @@ urlpatterns = [ path('admin/import/new', views_admin.ImportAddView.as_view(), name='admin_import_add'), + path('did-registry//did.json', serve_did) + # path('verification_portal/verify/', views_verification_portal.verify, # name="verification_portal_verify") ] diff --git a/idhub/user/forms.py b/idhub/user/forms.py index 5ac04ad..58c8ff5 100644 --- a/idhub/user/forms.py +++ b/idhub/user/forms.py @@ -22,12 +22,15 @@ class RequestCredentialForm(forms.Form): def __init__(self, *args, **kwargs): self.user = kwargs.pop('user', None) + self.lang = kwargs.pop('lang', None) + self._domain = kwargs.pop('domain', None) + self.password = kwargs.pop('password', None) super().__init__(*args, **kwargs) self.fields['did'].choices = [ (x.did, x.label) for x in DID.objects.filter(user=self.user) ] self.fields['credential'].choices = [ - (x.id, x.type()) for x in VerificableCredential.objects.filter( + (x.id, x.get_type(lang=self.lang)) for x in VerificableCredential.objects.filter( user=self.user, status=VerificableCredential.Status.ENABLED ) @@ -49,7 +52,8 @@ class RequestCredentialForm(forms.Form): did = did[0] cred = cred[0] try: - cred.issue(did) + if self.password: + cred.issue(did, self.password, domain=self._domain) except Exception: return diff --git a/idhub/user/views.py b/idhub/user/views.py index 6bdbfd0..5b97f8f 100644 --- a/idhub/user/views.py +++ b/idhub/user/views.py @@ -25,6 +25,7 @@ from django.views.generic.base import TemplateView from django.shortcuts import get_object_or_404, redirect from django.urls import reverse_lazy from django.http import HttpResponse +from django.core.cache import cache from django.contrib import messages from django.conf import settings from idhub.user.forms import ( @@ -140,7 +141,7 @@ class CredentialPdfView(MyWallet, TemplateView): self.object = get_object_or_404( VerificableCredential, pk=pk, - public=True, + eidas1_did__isnull=False, user=self.request.user ) self.url_id = "{}://{}/public/credentials/{}".format( @@ -150,7 +151,7 @@ class CredentialPdfView(MyWallet, TemplateView): ) data = self.build_certificate() - if DID.objects.filter(eidas1=True).exists(): + if self.object.eidas1_did: doc = self.insert_signature(data) else: doc = data @@ -221,10 +222,11 @@ class CredentialPdfView(MyWallet, TemplateView): return base64.b64encode(img_buffer.getvalue()).decode('utf-8') def get_pfx_data(self): - did = DID.objects.filter(eidas1=True).first() + did = self.object.eidas1_did if not did: return None, None - key_material = json.loads(did.key_material) + pw = cache.get("KEY_DIDS") + key_material = json.loads(did.get_key_material(pw)) cert = key_material.get("cert") passphrase = key_material.get("passphrase") if cert and passphrase: @@ -274,7 +276,15 @@ class CredentialJsonView(MyWallet, TemplateView): pk=pk, user=self.request.user ) - response = HttpResponse(self.object.data, content_type="application/json") + pass_enc = self.request.session.get("key_did") + data = "" + if pass_enc: + user_pass = self.request.user.decrypt_data( + pass_enc, + self.request.user.password+self.request.session._session_key + ) + data = self.object.get_data(user_pass) + response = HttpResponse(data, content_type="application/json") response['Content-Disposition'] = 'attachment; filename={}'.format("credential.json") return response @@ -286,6 +296,7 @@ class PublicCredentialJsonView(View): self.object = get_object_or_404( VerificableCredential, hash=pk, + eidas1_did__isnull=False, ) response = HttpResponse(self.object.data, content_type="application/json") response['Content-Disposition'] = 'attachment; filename={}'.format("credential.json") @@ -302,6 +313,18 @@ class CredentialsRequestView(MyWallet, FormView): def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs['user'] = self.request.user + kwargs['lang'] = self.request.LANGUAGE_CODE + domain = "{}://{}".format(self.request.scheme, self.request.get_host()) + kwargs['domain'] = domain + pass_enc = self.request.session.get("key_did") + if pass_enc: + user_pass = self.request.user.decrypt_data( + pass_enc, + self.request.user.password+self.request.session._session_key + ) + else: + pass_enc = None + kwargs['password'] = user_pass return kwargs def form_valid(self, form): @@ -366,13 +389,17 @@ class DidRegisterView(MyWallet, CreateView): icon = 'bi bi-patch-check-fill' wallet = True model = DID - fields = ('label',) + fields = ('label', 'type') success_url = reverse_lazy('idhub:user_dids') object = None def form_valid(self, form): form.instance.user = self.request.user - form.instance.set_did() + pw = self.request.user.decrypt_data( + self.request.session.get("key_did"), + self.request.user.password+self.request.session._session_key + ) + form.instance.set_did(pw) form.save() messages.success(self.request, _('DID created successfully')) diff --git a/idhub/views.py b/idhub/views.py index 5f6fb71..f513353 100644 --- a/idhub/views.py +++ b/idhub/views.py @@ -1,8 +1,14 @@ +from django.shortcuts import get_object_or_404 from django.urls import reverse_lazy +from django.conf import settings +from django.core.cache import cache from django.utils.translation import gettext_lazy as _ from django.contrib.auth import views as auth_views from django.contrib.auth import login as auth_login -from django.http import HttpResponseRedirect +from django.http import HttpResponseRedirect, HttpResponse + +from idhub.models import DID +from trustchain_idhub import settings class LoginView(auth_views.LoginView): @@ -21,8 +27,45 @@ class LoginView(auth_views.LoginView): def form_valid(self, form): user = form.get_user() + password = form.cleaned_data.get("password") + auth_login(self.request, user) + + sensitive_data_encryption_key = user.decrypt_sensitive_data(password) + if not user.is_anonymous and user.is_admin: admin_dashboard = reverse_lazy('idhub:admin_dashboard') self.extra_context['success_url'] = admin_dashboard - auth_login(self.request, user) + # encryption_key = user.encrypt_data( + # sensitive_data_encryption_key, + # settings.SECRET_KEY + # ) + # cache.set("KEY_DIDS", encryption_key, None) + cache.set("KEY_DIDS", sensitive_data_encryption_key, None) + + self.request.session["key_did"] = user.encrypt_data( + sensitive_data_encryption_key, + user.password+self.request.session._session_key + ) + return HttpResponseRedirect(self.extra_context['success_url']) + + +class PasswordResetConfirmView(auth_views.PasswordResetConfirmView): + template_name = 'auth/password_reset_confirm.html' + success_url = reverse_lazy('idhub:password_reset_complete') + + def form_valid(self, form): + password = form.cleaned_data.get("password") + user = form.get_user() + user.set_encrypted_sensitive_data(password) + user.save() + return HttpResponseRedirect(self.success_url) + + +def serve_did(request, did_id): + id_did = f'did:web:{settings.DOMAIN}:did-registry:{did_id}' + did = get_object_or_404(DID, did=id_did) + document = did.didweb_document + retval = HttpResponse(document) + retval.headers["Content-Type"] = "application/json" + return retval diff --git a/idhub_auth/forms.py b/idhub_auth/forms.py index f4279b7..d9ff2f7 100644 --- a/idhub_auth/forms.py +++ b/idhub_auth/forms.py @@ -31,4 +31,3 @@ class ProfileForm(forms.ModelForm): return last_name - diff --git a/idhub_auth/migrations/0001_initial.py b/idhub_auth/migrations/0001_initial.py index 45e8f28..840bc0b 100644 --- a/idhub_auth/migrations/0001_initial.py +++ b/idhub_auth/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.5 on 2024-01-11 10:17 +# Generated by Django 4.2.5 on 2024-01-18 11:32 from django.db import migrations, models @@ -48,6 +48,8 @@ class Migration(migrations.Migration): blank=True, max_length=255, null=True, verbose_name='Last name' ), ), + ('encrypted_sensitive_data', models.CharField(max_length=255)), + ('salt', models.CharField(max_length=255)), ], options={ 'abstract': False, diff --git a/idhub_auth/models.py b/idhub_auth/models.py index 07a7896..38224b2 100644 --- a/idhub_auth/models.py +++ b/idhub_auth/models.py @@ -1,4 +1,9 @@ +import nacl +import base64 + +from nacl import pwhash from django.db import models +from django.core.cache import cache from django.utils.translation import gettext_lazy as _ from django.contrib.auth.models import BaseUserManager, AbstractBaseUser @@ -44,6 +49,8 @@ class User(AbstractBaseUser): is_admin = models.BooleanField(default=False) first_name = models.CharField(_("First name"), max_length=255, blank=True, null=True) last_name = models.CharField(_("Last name"), max_length=255, blank=True, null=True) + encrypted_sensitive_data = models.CharField(max_length=255) + salt = models.CharField(max_length=255) objects = UserManager() @@ -86,3 +93,65 @@ class User(AbstractBaseUser): for r in s.service.rol.all(): roles.append(r.name) return ", ".join(set(roles)) + + def derive_key_from_password(self, password): + kdf = pwhash.argon2i.kdf + ops = pwhash.argon2i.OPSLIMIT_INTERACTIVE + mem = pwhash.argon2i.MEMLIMIT_INTERACTIVE + return kdf( + nacl.secret.SecretBox.KEY_SIZE, + password, + self.get_salt(), + opslimit=ops, + memlimit=mem + ) + + def decrypt_sensitive_data(self, password, data=None): + sb_key = self.derive_key_from_password(password.encode('utf-8')) + sb = nacl.secret.SecretBox(sb_key) + if not data: + data = self.get_encrypted_sensitive_data() + if not isinstance(data, bytes): + data = data.encode('utf-8') + + return sb.decrypt(data).decode('utf-8') + + def encrypt_sensitive_data(self, password, data): + sb_key = self.derive_key_from_password(password.encode('utf-8')) + sb = nacl.secret.SecretBox(sb_key) + if not isinstance(data, bytes): + data = data.encode('utf-8') + + return base64.b64encode(sb.encrypt(data)).decode('utf-8') + + def get_salt(self): + return base64.b64decode(self.salt.encode('utf-8')) + + def set_salt(self): + self.salt = base64.b64encode(nacl.utils.random(16)).decode('utf-8') + + def get_encrypted_sensitive_data(self): + return base64.b64decode(self.encrypted_sensitive_data.encode('utf-8')) + + def set_encrypted_sensitive_data(self, password): + key = base64.b64encode(nacl.utils.random(64)) + self.set_salt() + + key_crypted = self.encrypt_sensitive_data(password, key) + self.encrypted_sensitive_data = key_crypted + + def encrypt_data(self, data, password): + sb = self.get_secret_box(password) + value_enc = sb.encrypt(data.encode('utf-8')) + return base64.b64encode(value_enc).decode('utf-8') + + def decrypt_data(self, data, password): + # import pdb; pdb.set_trace() + sb = self.get_secret_box(password) + value = base64.b64decode(data.encode('utf-8')) + return sb.decrypt(value).decode('utf-8') + + def get_secret_box(self, password): + pw = base64.b64decode(password.encode('utf-8')*4) + sb_key = self.derive_key_from_password(pw) + return nacl.secret.SecretBox(sb_key) diff --git a/oidc4vp/migrations/0001_initial.py b/oidc4vp/migrations/0001_initial.py index ab5b0d5..b29aed0 100644 --- a/oidc4vp/migrations/0001_initial.py +++ b/oidc4vp/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.5 on 2024-01-11 10:17 +# Generated by Django 4.2.5 on 2024-01-18 11:32 from django.conf import settings from django.db import migrations, models diff --git a/promotion/migrations/0001_initial.py b/promotion/migrations/0001_initial.py index 2befe90..cabebc3 100644 --- a/promotion/migrations/0001_initial.py +++ b/promotion/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.5 on 2024-01-11 10:17 +# Generated by Django 4.2.5 on 2024-01-18 11:32 from django.db import migrations, models import django.db.models.deletion diff --git a/requirements.txt b/requirements.txt index 086cf44..c5f9def 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,10 +9,10 @@ pandas==2.1.1 xlrd==2.0.1 odfpy==1.4.1 requests==2.31.0 -didkit==0.3.2 jinja2==3.1.2 jsonref==1.1.0 pyld==2.0.3 +pynacl==1.5.0 more-itertools==10.1.0 dj-database-url==2.1.0 PyPDF2 @@ -25,3 +25,5 @@ qrcode uharfbuzz==0.38.0 fontTools==4.47.0 weasyprint==60.2 +ujson==5.9.0 +didkit-0.3.2-cp311-cp311-manylinux_2_34_x86_64.whl diff --git a/schemas/membership-card.json b/schemas/membership-card.json index 0d5ff9a..20a5f9b 100644 --- a/schemas/membership-card.json +++ b/schemas/membership-card.json @@ -1,65 +1,94 @@ { - "$id": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/membership-card-schema.json", + "$id": "https://idhub.pangea.org/vc_schemas/membership-card.json", "$schema": "https://json-schema.org/draft/2020-12/schema", - "name": "MembershipCard", - "description": "MembershipCard credential using JsonSchema", - "type": "object", - "properties": { - "credentialSubject": { - "type": "object", - "properties": { - "organisation": { - "type": "string" - }, - "membershipType": { - "type": "string" - }, - "affiliatedSince": { - "type": "string", - "format": "date-time" - }, - "affiliatedUntil": { - "type": "string", - "format": "date-time" - }, - "typeOfPerson": { - "type": "string", - "enum": [ - "individual", - "org" - ] - }, - "identityDocType": { - "type": "string", - "enum": [ - "DNI", - "NIF", - "NIE", - "PASSPORT" - ] - }, - "identityNumber": { - "type": "string" - }, - "name": { - "type": "string" - }, - "surnames": { - "type": "string" - }, - "email": { - "type": "string", - "format": "email" - } - }, - "required": [ - "organisation", - "affiliatedSince", - "typeOfPerson", - "name", - "surnames", - "email" - ] + "title": "Membership Card", + "description": "The membership card specifies an individual's subscription or enrollment in specific services or benefits issued by an organization.", + "name": [ + { + "value": "Membership Card", + "lang": "en" + }, + { + "value": "Carnet de soci/a", + "lang": "ca_ES" + }, + { + "value": "Carnet de socio/a", + "lang": "es" } - } + ], + "type": "object", + "allOf": [ + { + "$ref": "https://idhub.pangea.org/vc_schemas/ebsi/attestation.json" + }, + { + "properties": { + "credentialSubject": { + "description": "Defines additional properties on credentialSubject", + "type": "object", + "properties": { + "id": { + "description": "Defines a unique identifier of the credential subject", + "type": "string" + }, + "organisation": { + "description": "Organisation the credential subject is affiliated with", + "type": "string" + }, + "membershipType": { + "description": "Type of membership", + "type": "string" + }, + "membershipId": { + "description": "Membership identifier", + "type": "string" + }, + "affiliatedSince": { + "type": "string", + "format": "date-time" + }, + "affiliatedUntil": { + "type": "string", + "format": "date-time" + }, + "typeOfPerson": { + "type": "string", + "enum": [ + "natural", + "legal" + ] + }, + "identityDocType": { + "description": "Type of the Identity Document of the credential subject", + "type": "string" + }, + "identityNumber": { + "description": "Number of the Identity Document of the credential subject", + "type": "string" + }, + "firstName": { + "description": "Name of the natural person or name of the legal person (organisation)", + "type": "string" + }, + "lastName": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + } + }, + "required": [ + "id", + "organisation", + "affiliatedSince", + "typeOfPerson", + "firstName", + "email" + ] + } + } + } + ] } \ No newline at end of file diff --git a/trustchain_idhub/settings.py b/trustchain_idhub/settings.py index 8230d11..7c450b7 100644 --- a/trustchain_idhub/settings.py +++ b/trustchain_idhub/settings.py @@ -149,6 +149,7 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] +SESSION_ENGINE = 'django.contrib.sessions.backends.cache' # Internationalization # https://docs.djangoproject.com/en/4.2/topics/i18n/ @@ -222,4 +223,4 @@ LOGGING = { } } -DEFAULT_PUBLIC_CREDENTIALS = True +ORGANIZATION = config('ORGANIZATION', 'Pangea') diff --git a/utils/idhub_ssikit/__init__.py b/utils/idhub_ssikit/__init__.py index cc3e9b4..3521eba 100644 --- a/utils/idhub_ssikit/__init__.py +++ b/utils/idhub_ssikit/__init__.py @@ -6,6 +6,8 @@ import jinja2 from django.template.backends.django import Template from django.template.loader import get_template +from trustchain_idhub import settings + def generate_did_controller_key(): return didkit.generate_ed25519_key() @@ -15,6 +17,30 @@ def keydid_from_controller_key(key): return didkit.key_to_did("key", key) +async def resolve_keydid(keydid): + return await didkit.resolve_did(keydid, "{}") + + +def webdid_from_controller_key(key): + """ + Se siguen los pasos para generar un webdid a partir de un keydid. + Documentado en la docu de spruceid. + """ + keydid = keydid_from_controller_key(key) # "did:key:<...>" + pubkeyid = keydid.rsplit(":")[-1] # <...> + document = json.loads(asyncio.run(resolve_keydid(keydid))) # Documento DID en terminos "key" + webdid_url = f"did:web:{settings.DOMAIN}:did-registry:{pubkeyid}" # nueva URL: "did:web:idhub.pangea.org:<...>" + webdid_url_owner = webdid_url + "#owner" + # Reemplazamos los campos del documento DID necesarios: + document["id"] = webdid_url + document["verificationMethod"][0]["id"] = webdid_url_owner + document["verificationMethod"][0]["controller"] = webdid_url + document["authentication"][0] = webdid_url_owner + document["assertionMethod"][0] = webdid_url_owner + document_fixed_serialized = json.dumps(document) + return webdid_url, document_fixed_serialized + + def generate_generic_vc_id(): # TODO agree on a system for Verifiable Credential IDs return "https://pangea.org/credentials/42"