resolve conflicts

This commit is contained in:
Cayo Puigdefabregas 2024-01-04 18:00:24 +01:00
commit 87776751a4
61 changed files with 7018 additions and 744 deletions

View file

@ -1,2 +1,5 @@
"ExO";"https://verify.exo.cat"
"Somos Connexión";"https://verify.somosconexion.coop"
"pangea.org";"https://idhub1.demo.pangea.org/oidc4vp/"
"somconnexio.coop";"https://idhub2.demo.pangea.org/oidc4vp/"
"exo.cat";"https://verify.exo.cat"
"local 9000";"http://localhost:9000/oidc4vp/"
"local 8000";"http://localhost:8000/oidc4vp/"

1 ExO pangea.org https://verify.exo.cat https://idhub1.demo.pangea.org/oidc4vp/
2 Somos Connexión somconnexio.coop https://verify.somosconexion.coop https://idhub2.demo.pangea.org/oidc4vp/
3 exo.cat https://verify.exo.cat
4 local 9000 http://localhost:9000/oidc4vp/
5 local 8000 http://localhost:8000/oidc4vp/

View file

@ -19,9 +19,9 @@ from idhub_auth.models import User
class ImportForm(forms.Form):
did = forms.ChoiceField(choices=[])
schema = forms.ChoiceField(choices=[])
file_import = forms.FileField()
did = forms.ChoiceField(label=_("Did"), choices=[])
schema = forms.ChoiceField(label=_("Schema"), choices=[])
file_import = forms.FileField(label=_("File import"))
def __init__(self, *args, **kwargs):
self._schema = None
@ -139,7 +139,7 @@ class ImportForm(forms.Form):
class SchemaForm(forms.Form):
file_template = forms.FileField()
file_template = forms.FileField(label=_("File template"))
class MembershipForm(forms.ModelForm):

View file

@ -1,4 +1,5 @@
import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from idhub.models import Rol, Event
from idhub_auth.models import User
@ -18,10 +19,6 @@ class RolesTable(tables.Table):
class DashboardTable(tables.Table):
type = tables.Column(verbose_name="Event")
message = tables.Column(verbose_name="Description")
created = tables.Column(verbose_name="Date")
class Meta:
model = Event
template_name = "idhub/custom_table.html"

View file

@ -629,7 +629,7 @@ class DidsView(Credentials):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update({
'dids': DID.objects,
'dids': DID.objects.filter(user=self.request.user),
})
return context
@ -772,11 +772,14 @@ class SchemasNewView(SchemasMix):
return
try:
data = f.read().decode('utf-8')
assert credtools.validate_schema(json.loads(data))
ldata = json.loads(data)
assert credtools.validate_schema(ldata)
name = ldata.get('name')
assert name
except Exception:
messages.error(self.request, _('This is not a valid schema!'))
return
schema = Schemas.objects.create(file_schema=file_name, data=data)
schema = Schemas.objects.create(file_schema=file_name, data=data, type=name)
schema.save()
return schema
@ -817,11 +820,14 @@ class SchemasImportAddView(SchemasMix):
def create_schema(self, file_name):
data = self.open_file(file_name)
try:
json.loads(data)
ldata = json.loads(data)
assert credtools.validate_schema(ldata)
name = ldata.get('name')
assert name
except Exception:
messages.error(self.request, _('This is not a valid schema!'))
return
schema = Schemas.objects.create(file_schema=file_name, data=data)
schema = Schemas.objects.create(file_schema=file_name, data=data, type=name)
schema.save()
return schema

View file

@ -1,3 +1,5 @@
import logging
from django.conf import settings
from django.template import loader
from django.core.mail import EmailMultiAlternatives
@ -7,6 +9,9 @@ from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
logger = logging.getLogger(__name__)
class NotifyActivateUserByEmail:
def get_email_context(self, user):
"""
@ -49,10 +54,11 @@ class NotifyActivateUserByEmail:
email_message.attach_alternative(html_email, 'text/html')
try:
if settings.DEVELOPMENT:
print(to_email)
print(body)
logger.warning(to_email)
logger.warning(body)
return
email_message.send()
except Exception:
except Exception as err:
logger.error(err)
return

View file

@ -1,11 +1,16 @@
import os
import csv
import json
from pathlib import Path
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 decouple import config
from idhub.models import Organization
from idhub.models import DID, Schemas
from oidc4vp.models import Organization
from promotion.models import Promotion
User = get_user_model()
@ -29,6 +34,10 @@ class Command(BaseCommand):
f = csv.reader(csvfile, delimiter=';', quotechar='"')
for r in f:
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):
su = User.objects.create_superuser(email=email, password=password)
@ -44,4 +53,48 @@ class Command(BaseCommand):
def create_organizations(self, name, url):
Organization.objects.create(name=name, url=url)
Organization.objects.create(name=name, response_uri=url)
def sync_credentials_organizations(self, test1, test2):
org1 = Organization.objects.get(name=test1)
org2 = Organization.objects.get(name=test2)
org2.my_client_id = org1.client_id
org2.my_client_secret = org1.client_secret
org1.my_client_id = org2.client_id
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_schemas(self):
schemas_files = os.listdir(settings.SCHEMAS_DIR)
schemas = [x for x in schemas_files
if not Schemas.objects.filter(file_schema=x).exists()]
for x in schemas_files:
if Schemas.objects.filter(file_schema=x).exists():
continue
self._create_schemas(x)
def _create_schemas(self, file_name):
data = self.open_file(file_name)
try:
ldata = json.loads(data)
assert credtools.validate_schema(ldata)
name = ldata.get('name')
assert name
except Exception:
return
Schemas.objects.create(file_schema=file_name, data=data, type=name)
def open_file(self, file_name):
data = ''
filename = Path(settings.SCHEMAS_DIR).joinpath(file_name)
with filename.open() as schema_file:
data = schema_file.read()
return data

View file

@ -1,4 +1,4 @@
# Generated by Django 4.2.5 on 2024-01-04 15:12
# Generated by Django 4.2.5 on 2024-01-04 16:59
from django.conf import settings
from django.db import migrations, models
@ -26,7 +26,7 @@ class Migration(migrations.Migration):
),
),
('created_at', models.DateTimeField(auto_now=True)),
('label', models.CharField(max_length=50)),
('label', models.CharField(max_length=50, verbose_name='Label')),
('did', models.CharField(max_length=250)),
('_key_material', models.BinaryField(max_length=250)),
(
@ -57,27 +57,6 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='Organization',
fields=[
(
'id',
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID',
),
),
('name', models.CharField(max_length=250)),
(
'url',
models.CharField(
help_text='Url where to send the presentation', max_length=250
),
),
],
),
migrations.CreateModel(
name='Rol',
fields=[
@ -111,6 +90,7 @@ class Migration(migrations.Migration):
verbose_name='ID',
),
),
('type', models.CharField(max_length=250)),
('file_schema', models.CharField(max_length=250)),
('data', models.TextField()),
('created_at', models.DateTimeField(auto_now=True)),
@ -168,8 +148,7 @@ class Migration(migrations.Migration):
('verified', models.BooleanField()),
('created_on', models.DateTimeField(auto_now=True)),
('issued_on', models.DateTimeField(null=True)),
('subject_did', models.CharField(max_length=250)),
('_data', models.BinaryField()),
('data', models.TextField()),
('csv_data', models.TextField()),
(
'status',
@ -199,6 +178,15 @@ class Migration(migrations.Migration):
to='idhub.schemas',
),
),
(
'subject_did',
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='subject_credentials',
to='idhub.did',
),
),
(
'user',
models.ForeignKey(
@ -268,8 +256,11 @@ class Migration(migrations.Migration):
verbose_name='ID',
),
),
('created', models.DateTimeField(auto_now=True)),
('message', models.CharField(max_length=350)),
('created', models.DateTimeField(auto_now=True, verbose_name='Date')),
(
'message',
models.CharField(max_length=350, verbose_name='Description'),
),
(
'type',
models.PositiveSmallIntegerField(
@ -307,7 +298,8 @@ class Migration(migrations.Migration):
(28, 'Organisational DID deleted by admin'),
(29, 'User deactivated'),
(30, 'User activated'),
]
],
verbose_name='Event',
),
),
(
@ -339,6 +331,7 @@ class Migration(migrations.Migration):
on_delete=django.db.models.deletion.CASCADE,
related_name='users',
to='idhub.service',
verbose_name='Service',
),
),
(

View file

@ -1,6 +1,5 @@
import json
import pytz
import requests
import datetime
from django.db import models
from django.conf import settings
@ -50,9 +49,10 @@ class Event(models.Model):
EV_USR_DEACTIVATED_BY_ADMIN = 29, "User deactivated"
EV_USR_ACTIVATED_BY_ADMIN = 30, "User activated"
created = models.DateTimeField(auto_now=True)
message = models.CharField(max_length=350)
created = models.DateTimeField(_("Date"), auto_now=True)
message = models.CharField(_("Description"), max_length=350)
type = models.PositiveSmallIntegerField(
_("Event"),
choices=Types.choices,
)
user = models.ForeignKey(
@ -407,7 +407,7 @@ class Event(models.Model):
class DID(models.Model):
created_at = models.DateTimeField(auto_now=True)
label = models.CharField(max_length=50)
label = models.CharField(_("Label"), max_length=50)
did = models.CharField(max_length=250)
# In JWK format. Must be stored as-is and passed whole to library functions.
# Example key material:
@ -459,6 +459,7 @@ class DID(models.Model):
class Schemas(models.Model):
type = models.CharField(max_length=250)
file_schema = models.CharField(max_length=250)
data = models.TextField()
created_at = models.DateTimeField(auto_now=True)
@ -490,11 +491,7 @@ class VerificableCredential(models.Model):
verified = models.BooleanField()
created_on = models.DateTimeField(auto_now=True)
issued_on = models.DateTimeField(null=True)
subject_did = models.CharField(max_length=250)
# CHANGED: `data` to `_data`, datatype from TextField to BinaryField and the rendered VC is now stored encrypted.
# TODO: verify that BinaryField can hold arbitrary amounts of data (max_length = ???)
data = None
_data = models.BinaryField()
data = models.TextField()
csv_data = models.TextField()
status = models.PositiveSmallIntegerField(
choices=Status.choices,
@ -505,6 +502,12 @@ class VerificableCredential(models.Model):
on_delete=models.CASCADE,
related_name='vcredentials',
)
subject_did = models.ForeignKey(
DID,
on_delete=models.CASCADE,
related_name='subject_credentials',
null=True
)
issuer_did = models.ForeignKey(
DID,
on_delete=models.CASCADE,
@ -517,36 +520,16 @@ class VerificableCredential(models.Model):
)
def get_data(self):
key_dids = cache.get("KEY_DIDS", {})
if not key_dids.get(user.id):
raise Exception("Ojo! Se intenta acceder a datos cifrados sin tener la clave.")
sb = secret.SecretBox(key_dids[user.id])
return sb.decrypt(self._data)
return self.user.decrypt_data(self.data)
def set_data(self, value):
key_dids = cache.get("KEY_DIDS", {})
if not key_dids.get(user.id):
raise Exception("Ojo! Se intenta acceder a datos cifrados sin tener la clave.")
sb = secret.SecretBox(key_dids[user.id])
self._data = sb.encrypt(value)
@property
def get_schema(self):
if not self.data:
return {}
return json.loads(self.data)
self.data = self.user.encrypt_data(value)
def type(self):
if self.data:
return self.get_schema.get('type')[-1]
return self.schema.name()
return self.schema.type
def description(self):
if not self.data:
return self.schema.description()
for des in self.get_schema.get('description', []):
for des in json.loads(self.render()).get('description', []):
if settings.LANGUAGE_CODE == des.get('lang'):
return des.get('value', '')
return ''
@ -572,13 +555,15 @@ class VerificableCredential(models.Model):
def get_context(self):
d = json.loads(self.csv_data)
issuance_date = ''
if self.issued_on:
format = "%Y-%m-%dT%H:%M:%SZ"
issuance_date = self.issued_on.strftime(format)
context = {
'vc_id': self.id,
'issuer_did': self.issuer_did.did,
'subject_did': self.subject_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,
@ -677,24 +662,10 @@ class UserRol(models.Model):
)
service = models.ForeignKey(
Service,
verbose_name=_("Service"),
on_delete=models.CASCADE,
related_name='users',
)
class Meta:
unique_together = ('user', 'service',)
class Organization(models.Model):
name = models.CharField(max_length=250)
url = models.CharField(
help_text=_("Url where to send the presentation"),
max_length=250
)
def __str__(self):
return self.name
def send(self, cred):
return
requests.post(self.url, data=cred.data)

View file

@ -0,0 +1,30 @@
{
"@context": [
"https://www.w3.org/2018/credentials/v1",
{
"name": "https://schema.org/name",
"email": "https://schema.org/email",
"membershipType": "https://schema.org/memberOf",
"individual": "https://schema.org/Person",
"organization": "https://schema.org/Organization",
"Member": "https://schema.org/Member",
"startDate": "https://schema.org/startDate",
"jsonSchema": "https://schema.org/jsonSchema",
"street_address": "https://schema.org/streetAddress",
"connectivity_option_list": "https://schema.org/connectivityOptionList",
"$ref": "https://schema.org/jsonSchemaRef"
}
],
"id": "{{ vc_id }}",
"type": ["VerifiableCredential", "HomeConnectivitySurveyCredential"],
"issuer": "{{ issuer_did }}",
"issuanceDate": "{{ issuance_date }}",
"credentialSubject": {
"id": "{{ subject_did }}",
"street_address": "{{ street_address }}",
"connectivity_option_list": "{{ connectivity_option_list }}",
"jsonSchema": {
"$ref": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/UNDEF.json"
}
}
}

View file

@ -0,0 +1,33 @@
{
"@context": [
"https://www.w3.org/2018/credentials/v1",
{
"name": "https://schema.org/name",
"email": "https://schema.org/email",
"membershipType": "https://schema.org/memberOf",
"individual": "https://schema.org/Person",
"organization": "https://schema.org/Organization",
"Member": "https://schema.org/Member",
"startDate": "https://schema.org/startDate",
"jsonSchema": "https://schema.org/jsonSchema",
"destination_country": "https://schema.org/destinationCountry",
"offboarding_date": "https://schema.org/offboardingDate",
"$ref": "https://schema.org/jsonSchemaRef"
}
],
"id": "{{ vc_id }}",
"type": ["VerifiableCredential", "MigrantRescueCredential"],
"issuer": "{{ issuer_did }}",
"issuanceDate": "{{ issuance_date }}",
"credentialSubject": {
"id": "{{ subject_did }}",
"name": "{{ name }}",
"country_of_origin": "{{ country_of_origin }}",
"rescue_date": "{{ rescue_date }}",
"destination_country": "{{ destination_country }}",
"offboarding_date": "{{ offboarding_date }}",
"jsonSchema": {
"$ref": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/UNDEF.json"
}
}
}

View file

@ -0,0 +1,30 @@
{
"@context": [
"https://www.w3.org/2018/credentials/v1",
{
"name": "https://schema.org/name",
"email": "https://schema.org/email",
"membershipType": "https://schema.org/memberOf",
"individual": "https://schema.org/Person",
"organization": "https://schema.org/Organization",
"Member": "https://schema.org/Member",
"startDate": "https://schema.org/startDate",
"jsonSchema": "https://schema.org/jsonSchema",
"street_address": "https://schema.org/streetAddress",
"financial_vulnerability_score": "https://schema.org/financialVulnerabilityScore",
"$ref": "https://schema.org/jsonSchemaRef"
}
],
"id": "{{ vc_id }}",
"type": ["VerifiableCredential", "FinancialSituationCredential"],
"issuer": "{{ issuer_did }}",
"issuanceDate": "{{ issuance_date }}",
"credentialSubject": {
"id": "{{ subject_did }}",
"street_address": "{{ street_address }}",
"financial_vulnerability_score": "{{ financial_vulnerability_score }}",
"jsonSchema": {
"$ref": "https://gitea.pangea.org/trustchain-oc1-orchestral/schemas/UNDEF.json"
}
}
}

View file

@ -24,7 +24,7 @@
<td>{{ rol.name }}</td>
<td>{{ rol.description|default:""}}</td>
<td><a href="{% url 'idhub:admin_rol_edit' rol.id %}" title="{% trans 'Edit' %}"><i class="bi bi-pencil-square"></i></a></td>
<td><a href="{% url 'idhub:admin_rol_del' rol.id %}" title="{% trans 'Delete' %}"><i class="bi bi-trash"></i></a></td>
<td><a class="text-danger" href="{% url 'idhub:admin_rol_del' rol.id %}" title="{% trans 'Delete' %}"><i class="bi bi-trash"></i></a></td>
</tr>
{% endfor %}
</tbody>

View file

@ -26,7 +26,7 @@
<td>{{ service.description }}</td>
<td>{{ service.get_roles }}</td>
<td><a href="{% url 'idhub:admin_service_edit' service.id %}" title="{% trans 'Edit' %}"><i class="bi bi-pencil-square"></i></a></td>
<td><a href="{% url 'idhub:admin_service_del' service.id %}" title="{% trans 'Delete' %}"><i class="bi bi-trash"></i></a></td>
<td><a class="text-danger" href="{% url 'idhub:admin_service_del' service.id %}" title="{% trans 'Delete' %}"><i class="bi bi-trash"></i></a></td>
</tr>
{% endfor %}
</tbody>

View file

@ -21,7 +21,7 @@
<div class="card-body">
<div class="row border-bottom">
<div class="col-3">
First Name:
{% trans "First name" %}:
</div>
<div class="col-9 text-secondary">
{{ object.first_name|default:'' }}
@ -29,7 +29,7 @@
</div>
<div class="row border-bottom mt-3">
<div class="col-3">
Last Name:
{% trans "Last name" %}:
</div>
<div class="col-9 text-secondary">
{{ object.last_name|default:'' }}
@ -37,7 +37,7 @@
</div>
<div class="row mt-3">
<div class="col-3">
Email:
{% trans "Email address" %}:
</div>
<div class="col-9 text-secondary">
{{ object.email }}

View file

@ -52,7 +52,7 @@
<td>{{ membership.start_date|default:'' }}</td>
<td>{{ membership.end_date|default:'' }}</td>
<td><a href="{% url 'idhub:admin_people_membership_edit' membership.id %}" title="{% trans 'Edit' %}"><i class="bi bi-pencil-square"></i></a></td>
<td><a href="{% url 'idhub:admin_people_membership_del' membership.id %}" title="{% trans 'Delete' %}"><i class="bi bi-trash"></i></a></td>
<td><a class="text-danger" href="{% url 'idhub:admin_people_membership_del' membership.id %}" title="{% trans 'Delete' %}"><i class="bi bi-trash"></i></a></td>
</tr>
{% endfor %}
</tbody>
@ -84,7 +84,7 @@
<td>{{ rol.service.description }}</td>
<td>{{ rol.service.domain }}</td>
<td><a href="{% url 'idhub:admin_people_rol_edit' rol.id %}" title="{% trans 'Edit' %}"><i class="bi bi-pencil-square"></i></a></td>
<td><a href="{% url 'idhub:admin_people_rol_del' rol.id %}" title="{% trans 'Delete' %}"><i class="bi bi-trash"></i></a></td>
<td><a class="text-danger" href="{% url 'idhub:admin_people_rol_del' rol.id %}" title="{% trans 'Delete' %}"><i class="bi bi-trash"></i></a></td>
</tr>
{% endfor %}
</tbody>

View file

@ -115,7 +115,7 @@
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if path == 'user_credentials_presentation' %}active2{% endif %}" href="{% url 'idhub:user_credentials_presentation' %}">
<a class="nav-link {% if path in 'user_demand_authorization, authorize' %}active2{% endif %}" href="{% url 'idhub:user_demand_authorization' %}">
{% trans 'Present a credential' %}
</a>
</li>
@ -139,7 +139,7 @@
<h1 class="h2">{{ title }}</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<input class="form-control form-control-grey " type="text" placeholder="Search" aria-label="Search">
<input class="form-control form-control-grey " type="text" placeholder="{% trans 'Search' %}" aria-label="Search">
</div>
</div>
</div>

View file

@ -171,7 +171,7 @@
<h1 class="h2">{{ title }}</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<input class="form-control form-control-grey " type="text" placeholder="Search" aria-label="Search">
<input class="form-control form-control-grey " type="text" placeholder="{% trans 'Search' %}" aria-label="Search">
</div>
</div>
</div>

View file

@ -20,6 +20,7 @@ from django.urls import path, reverse_lazy
from .views import LoginView, PasswordResetConfirmView
from .admin import views as views_admin
from .user import views as views_user
# from .verification_portal import views as views_verification_portal
app_name = 'idhub'
@ -87,9 +88,9 @@ urlpatterns = [
path('user/credentials/request/',
views_user.CredentialsRequestView.as_view(),
name='user_credentials_request'),
path('user/credentials_presentation/',
views_user.CredentialsPresentationView.as_view(),
name='user_credentials_presentation'),
path('user/credentials_presentation/demand',
views_user.DemandAuthorizationView.as_view(),
name='user_demand_authorization'),
# Admin
path('admin/dashboard/', views_admin.DashboardView.as_view(),
@ -174,4 +175,7 @@ urlpatterns = [
name='admin_import'),
path('admin/import/new', views_admin.ImportAddView.as_view(),
name='admin_import_add'),
# path('verification_portal/verify/', views_verification_portal.verify,
# name="verification_portal_verify")
]

View file

@ -1,7 +1,11 @@
import requests
from django import forms
from idhub_auth.models import User
from idhub.models import DID, VerificableCredential, Organization
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from idhub_auth.models import User
from idhub.models import DID, VerificableCredential
from oidc4vp.models import Organization
class ProfileForm(forms.ModelForm):
@ -13,8 +17,8 @@ class ProfileForm(forms.ModelForm):
class RequestCredentialForm(forms.Form):
did = forms.ChoiceField(choices=[])
credential = forms.ChoiceField(choices=[])
did = forms.ChoiceField(label=_("Did"), choices=[])
credential = forms.ChoiceField(label=_("Credential"), choices=[])
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user', None)
@ -42,7 +46,7 @@ class RequestCredentialForm(forms.Form):
if not all([cred.exists(), did.exists()]):
return
did = did[0].did
did = did[0]
cred = cred[0]
try:
cred.issue(did)
@ -56,42 +60,30 @@ class RequestCredentialForm(forms.Form):
return
class CredentialPresentationForm(forms.Form):
organization = forms.ChoiceField(choices=[])
credential = forms.ChoiceField(choices=[])
class DemandAuthorizationForm(forms.Form):
organization = forms.ChoiceField(label=_("Organization"), choices=[])
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
self.fields['organization'].choices = [
(x.id, x.name) for x in Organization.objects.filter()
]
self.fields['credential'].choices = [
(x.id, x.type()) for x in VerificableCredential.objects.filter(
user=self.user,
status=VerificableCredential.Status.ISSUED
)
if x.response_uri != settings.RESPONSE_URI
]
def save(self, commit=True):
self.org = Organization.objects.filter(
id=self.data['organization']
)
self.cred = VerificableCredential.objects.filter(
user=self.user,
id=self.data['credential'],
status=VerificableCredential.Status.ISSUED
)
if not all([self.org.exists(), self.cred.exists()]):
if not self.org.exists():
return
self.org = self.org[0]
self.cred = self.cred[0]
if commit:
self.org.send(self.cred)
return self.cred
url = self.org.demand_authorization()
if url.status_code == 200:
return url.json().get('redirect_uri')
return

View file

@ -12,7 +12,11 @@ from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy
from django.http import HttpResponse
from django.contrib import messages
from idhub.user.forms import ProfileForm, RequestCredentialForm, CredentialPresentationForm
from idhub.user.forms import (
ProfileForm,
RequestCredentialForm,
DemandAuthorizationForm
)
from idhub.mixins import UserView
from idhub.models import DID, VerificableCredential, Event
@ -76,8 +80,11 @@ class CredentialsView(MyWallet, TemplateView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
creds = VerificableCredential.objects.filter(
user=self.request.user
)
context.update({
'credentials': VerificableCredential.objects,
'credentials': creds,
})
return context
@ -136,17 +143,20 @@ class CredentialsRequestView(MyWallet, FormView):
messages.success(self.request, _("The credential was issued successfully!"))
Event.set_EV_CREDENTIAL_ISSUED_FOR_USER(cred)
Event.set_EV_CREDENTIAL_ISSUED(cred)
url = self.request.session.pop('next_url', None)
if url:
return redirect(url)
else:
messages.error(self.request, _("The credential does not exist!"))
return super().form_valid(form)
class CredentialsPresentationView(MyWallet, FormView):
class DemandAuthorizationView(MyWallet, FormView):
template_name = "idhub/user/credentials_presentation.html"
subtitle = _('Credential presentation')
icon = 'bi bi-patch-check-fill'
form_class = CredentialPresentationForm
success_url = reverse_lazy('idhub:user_credentials')
form_class = DemandAuthorizationForm
success_url = reverse_lazy('idhub:user_demand_authorization')
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
@ -154,11 +164,17 @@ class CredentialsPresentationView(MyWallet, FormView):
return kwargs
def form_valid(self, form):
cred = form.save()
if cred:
Event.set_EV_CREDENTIAL_PRESENTED_BY_USER(cred, form.org)
Event.set_EV_CREDENTIAL_PRESENTED(cred, form.org)
messages.success(self.request, _("The credential was presented successfully!"))
try:
authorization = form.save()
except Exception:
txt = _("Problems connecting with {url}").format(
url=form.org.response_uri
)
messages.error(self.request, txt)
return super().form_valid(form)
if authorization:
return redirect(authorization)
else:
messages.error(self.request, _("Error sending credential!"))
return super().form_valid(form)

View file

View file

@ -0,0 +1,24 @@
from django.db import models
class VPVerifyRequest(models.Model):
"""
`nonce` is an opaque random string used to lookup verification requests. URL-safe.
Example: "UPBQ3JE2DGJYHP5CPSCRIGTHRTCYXMQPNQ"
`expected_credentials` is a JSON list of credential types that must be present in this VP.
Example: ["FinancialSituationCredential", "HomeConnectivitySurveyCredential"]
`expected_contents` is a JSON object that places optional constraints on the contents of the
returned VP.
Example: [{"FinancialSituationCredential": {"financial_vulnerability_score": "7"}}]
`action` is (for now) a JSON object describing the next steps to take if this verification
is successful. For example "send mail to <destination> with <subject> and <body>"
Example: {"action": "send_mail", "params": {"to": "orders@somconnexio.coop", "subject": "New client", "body": ...}
`response` is a URL that the user's wallet will redirect the user to.
`submitted_on` is used (by a cronjob) to purge old entries that didn't complete verification
"""
nonce = models.CharField(max_length=50)
expected_credentials = models.CharField(max_length=255)
expected_contents = models.TextField()
action = models.TextField()
response_or_redirect = models.CharField(max_length=255)
submitted_on = models.DateTimeField(auto_now=True)

View file

@ -0,0 +1,49 @@
import json
from django.core.mail import send_mail
from django.http import HttpResponse, HttpResponseRedirect
from utils.idhub_ssikit import verify_presentation
from .models import VPVerifyRequest
from django.shortcuts import get_object_or_404
from more_itertools import flatten, unique_everseen
def verify(request):
assert request.method == "POST"
# TODO: incorporate request.POST["presentation_submission"] as schema definition
(presentation_valid, _) = verify_presentation(request.POST["vp_token"])
if not presentation_valid:
raise Exception("Failed to verify signature on the given Verifiable Presentation.")
vp = json.loads(request.POST["vp_token"])
nonce = vp["nonce"]
# "vr" = verification_request
vr = get_object_or_404(VPVerifyRequest, nonce=nonce) # TODO: return meaningful error, not 404
# Get a list of all included verifiable credential types
included_credential_types = unique_everseen(flatten([
vc["type"] for vc in vp["verifiableCredential"]
]))
# Check that it matches what we requested
for requested_vc_type in json.loads(vr.expected_credentials):
if requested_vc_type not in included_credential_types:
raise Exception("You're missing some credentials we requested!") # TODO: return meaningful error
# Perform whatever action we have to do
action = json.loads(vr.action)
if action["action"] == "send_mail":
subject = action["params"]["subject"]
to_email = action["params"]["to"]
from_email = "noreply@verifier-portal"
body = request.POST["vp-token"]
send_mail(
subject,
body,
from_email,
[to_email]
)
elif action["action"] == "something-else":
pass
else:
raise Exception("Unknown action!")
# OK! Your verifiable presentation was successfully presented.
return HttpResponseRedirect(vr.response_or_redirect)

View file

@ -16,7 +16,7 @@ class ProfileForm(forms.ModelForm):
def clean_first_name(self):
first_name = super().clean()['first_name']
match = r'^[a-zA-ZäöüßÄÖÜáéíóúàèìòùÀÈÌÒÙÁÉÍÓÚßñÑçÇ\s\'-]+'
if not re.match(match, first_name):
if not re.fullmatch(match, first_name):
txt = _("The string must contain only characters and spaces")
raise forms.ValidationError(txt)
@ -25,7 +25,7 @@ class ProfileForm(forms.ModelForm):
def clean_last_name(self):
last_name = super().clean()['last_name']
match = r'^[a-zA-ZäöüßÄÖÜáéíóúàèìòùÀÈÌÒÙÁÉÍÓÚßñÑçÇ\s\'-]+'
if not re.match(match, last_name):
if not re.fullmatch(match, last_name):
txt = _("The string must contain only characters and spaces")
raise forms.ValidationError(txt)

View file

@ -1,4 +1,4 @@
# Generated by Django 4.2.5 on 2024-01-04 15:12
# Generated by Django 4.2.5 on 2024-01-04 16:59
from django.db import migrations, models
@ -31,13 +31,23 @@ class Migration(migrations.Migration):
(
'email',
models.EmailField(
max_length=255, unique=True, verbose_name='email address'
max_length=255, unique=True, verbose_name='Email address'
),
),
('is_active', models.BooleanField(default=True)),
('is_admin', models.BooleanField(default=False)),
('first_name', models.CharField(blank=True, max_length=255, null=True)),
('last_name', models.CharField(blank=True, max_length=255, null=True)),
(
'first_name',
models.CharField(
blank=True, max_length=255, null=True, verbose_name='First name'
),
),
(
'last_name',
models.CharField(
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)),
],

View file

@ -4,6 +4,7 @@ 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
@ -40,14 +41,14 @@ class UserManager(BaseUserManager):
class User(AbstractBaseUser):
email = models.EmailField(
verbose_name="email address",
_('Email address'),
max_length=255,
unique=True,
)
is_active = models.BooleanField(default=True)
is_admin = models.BooleanField(default=False)
first_name = models.CharField(max_length=255, blank=True, null=True)
last_name = models.CharField(max_length=255, blank=True, null=True)
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)
@ -144,4 +145,21 @@ class User(AbstractBaseUser):
key_crypted = self.encrypt_sensitive_data(password, key)
self.encrypted_sensitive_data = key_crypted
def encrypt_data(self, data):
sb = self.get_secret_box()
value = base64.b64encode(data.encode('utf-8'))
return sb.encrypt(data)
def decrypt_data(self, data):
sb = self.get_secret_box()
value = base64.b64decode(data.encode('utf-8'))
return sb.decrypt(data)
def get_secret_box(self):
key_dids = cache.get("KEY_DIDS", {})
if not key_dids.get(self.id):
err = "An attempt is made to access encrypted "
err += "data without having the key."
raise Exception(_(err))
return secret.SecretBox(key_dids[self.id])

Binary file not shown.

Binary file not shown.

0
oidc4vp/__init__.py Normal file
View file

3
oidc4vp/admin.py Normal file
View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
oidc4vp/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class Oidc4VpConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'oidc4vp'

81
oidc4vp/forms.py Normal file
View file

@ -0,0 +1,81 @@
import json
import requests
from django import forms
from django.conf import settings
from django.template.loader import get_template
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
from utils.idhub_ssikit import create_verifiable_presentation
from oidc4vp.models import Organization
from idhub.models import VerificableCredential
class AuthorizeForm(forms.Form):
def __init__(self, *args, **kwargs):
self.data = kwargs.get('data', {}).copy()
self.user = kwargs.pop('user', None)
self.org = kwargs.pop('org', None)
self.code = kwargs.pop('code', None)
self.presentation_definition = kwargs.pop('presentation_definition', [])
reg = r'({})'.format('|'.join(self.presentation_definition))
self.all_credentials = self.user.vcredentials.filter(
schema__type__iregex=reg,
)
self.credentials = self.all_credentials.filter(
status=VerificableCredential.Status.ISSUED.value
)
super().__init__(*args, **kwargs)
for vp in self.presentation_definition:
vp = vp.lower()
choices = [
(str(x.id), x.schema.type.lower()) for x in self.credentials.filter(
schema__type__iexact=vp)
]
self.fields[vp.lower()] = forms.ChoiceField(
widget=forms.RadioSelect,
choices=choices
)
def clean(self):
data = super().clean()
self.list_credentials = []
for c in self.credentials:
if str(c.id) == data.get(c.schema.type.lower()):
if c.status is not c.Status.ISSUED.value or not c.data:
txt = _('There are some problems with this credentials')
raise ValidationError(txt)
self.list_credentials.append(c)
if not self.code:
txt = _("There isn't code in request")
raise ValidationError(txt)
return data
def save(self, commit=True):
if not self.list_credentials:
return
self.get_verificable_presentation()
if commit:
return self.org.send(self.vp, self.code)
return
def get_verificable_presentation(self):
did = self.list_credentials[0].subject_did
vp_template = get_template('credentials/verifiable_presentation.json')
vc_list = json.dumps([json.loads(x.data) for x in self.list_credentials])
context = {
"holder_did": did.did,
"verifiable_credential_list": vc_list
}
unsigned_vp = vp_template.render(context)
self.vp = create_verifiable_presentation(did.key_material, unsigned_vp)

View file

@ -0,0 +1,137 @@
# Generated by Django 4.2.5 on 2023-12-11 08:35
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import oidc4vp.models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Authorization',
fields=[
(
'id',
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID',
),
),
(
'code',
models.CharField(default=oidc4vp.models.set_code, max_length=24),
),
('code_used', models.BooleanField()),
('created', models.DateTimeField(auto_now=True)),
('presentation_definition', models.CharField(max_length=250)),
],
),
migrations.CreateModel(
name='Organization',
fields=[
(
'id',
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID',
),
),
('name', models.CharField(max_length=250)),
(
'client_id',
models.CharField(
default=oidc4vp.models.set_client_id, max_length=24, unique=True
),
),
(
'client_secret',
models.CharField(
default=oidc4vp.models.set_client_secret, max_length=48
),
),
('my_client_id', models.CharField(max_length=24)),
('my_client_secret', models.CharField(max_length=48)),
(
'response_uri',
models.URLField(
help_text='Url where to send the verificable presentation',
max_length=250,
),
),
],
),
migrations.CreateModel(
name='OAuth2VPToken',
fields=[
(
'id',
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID',
),
),
('created', models.DateTimeField(auto_now=True)),
('result_verify', models.CharField(max_length=255)),
('vp_token', models.TextField()),
(
'authorization',
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='oauth2vptoken',
to='oidc4vp.authorization',
),
),
(
'organization',
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='vp_tokens',
to='oidc4vp.organization',
),
),
(
'user',
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='vp_tokens',
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AddField(
model_name='authorization',
name='organization',
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='authorizations',
to='oidc4vp.organization',
),
),
migrations.AddField(
model_name='authorization',
name='user',
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
]

View file

218
oidc4vp/models.py Normal file
View file

@ -0,0 +1,218 @@
import json
import requests
import secrets
from django.conf import settings
from django.http import QueryDict
from django.utils.translation import gettext_lazy as _
from django.shortcuts import get_object_or_404
from idhub_auth.models import User
from django.db import models
from utils.idhub_ssikit import verify_presentation
SALT_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
def gen_salt(length: int) -> str:
"""Generate a random string of SALT_CHARS with specified ``length``."""
if length <= 0:
raise ValueError("Salt length must be positive")
return "".join(secrets.choice(SALT_CHARS) for _ in range(length))
def set_client_id():
return gen_salt(24)
def set_client_secret():
return gen_salt(48)
def set_code():
return gen_salt(24)
class Organization(models.Model):
"""
This class represent a member of one net trust or federated host.
Client_id and client_secret are the credentials of this organization
get a connection to my. (receive a request)
My_client_id and my_client_secret are my credentials than to use if I
want to connect to this organization. (send a request)
For use the packages requests we need use my_client_id
For use in the get or post method of a View, then we need use client_id
and secret_id
"""
name = models.CharField(max_length=250)
client_id = models.CharField(
max_length=24,
default=set_client_id,
unique=True
)
client_secret = models.CharField(
max_length=48,
default=set_client_secret
)
my_client_id = models.CharField(
max_length=24,
)
my_client_secret = models.CharField(
max_length=48,
)
response_uri = models.URLField(
help_text=_("Url where to send the verificable presentation"),
max_length=250
)
def send(self, vp, code):
"""
Send the verificable presentation to Verifier
"""
url = "{url}/verify".format(
url=self.response_uri.strip("/"),
)
auth = (self.my_client_id, self.my_client_secret)
data = {"vp_token": vp}
if code:
data["code"] = code
return requests.post(url, data=data, auth=auth)
def demand_authorization(self):
"""
Send the a request for start a process of Verifier
"""
url = "{url}/verify?demand_uri={redirect_uri}".format(
url=self.response_uri.strip("/"),
redirect_uri=settings.RESPONSE_URI
)
auth = (self.my_client_id, self.my_client_secret)
return requests.get(url, auth=auth)
def __str__(self):
return self.name
###################
# Verifier clases #
###################
class Authorization(models.Model):
"""
This class represent a query through browser the client to the wallet.
The Verifier need to do a redirection to the user to Wallet.
The code we use as a soft foreing key between Authorization and OAuth2VPToken.
"""
code = models.CharField(max_length=24, default=set_code)
code_used = models.BooleanField(default=False)
created = models.DateTimeField(auto_now=True)
presentation_definition = models.CharField(max_length=250)
organization = models.ForeignKey(
Organization,
on_delete=models.CASCADE,
related_name='authorizations',
null=True,
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
null=True,
)
def authorize(self, path=None):
data = {
"response_type": "vp_token",
"response_mode": "direct_post",
"client_id": self.organization.my_client_id,
"presentation_definition": self.presentation_definition,
"code": self.code,
"nonce": gen_salt(5),
}
query_dict = QueryDict('', mutable=True)
query_dict.update(data)
response_uri = self.organization.response_uri.strip("/")
if path:
response_uri = "{}/{}".format(response_uri, path.strip("/"))
url = '{response_uri}/authorize?{params}'.format(
response_uri=response_uri,
params=query_dict.urlencode()
)
return url
class OAuth2VPToken(models.Model):
"""
This class represent the response of Wallet to Verifier
and the result of verify.
"""
created = models.DateTimeField(auto_now=True)
result_verify = models.CharField(max_length=255)
vp_token = models.TextField()
organization = models.ForeignKey(
Organization,
on_delete=models.CASCADE,
related_name='vp_tokens',
null=True,
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='vp_tokens',
null=True,
)
authorization = models.ForeignKey(
Authorization,
on_delete=models.SET_NULL,
related_name='vp_tokens',
null=True,
)
def __init__(self, *args, **kwargs):
code = kwargs.pop("code", None)
super().__init__(*args, **kwargs)
self.authorization = Authorization.objects.filter(code=code).first()
def verifing(self):
self.result_verify = verify_presentation(self.vp_token)
def get_response_verify(self):
response = {
"verify": ',',
"redirect_uri": "",
"response": "",
}
verification = json.loads(self.result_verify)
if verification.get('errors') or verification.get('warnings'):
response["verify"] = "Error, Verification Failed"
return response
response["verify"] = "Ok, Verification correct"
response["redirect_uri"] = self.get_redirect_url()
return response
def get_redirect_url(self):
data = {
"code": self.authorization.code,
}
query_dict = QueryDict('', mutable=True)
query_dict.update(data)
response_uri = settings.ALLOW_CODE_URI
url = '{response_uri}?{params}'.format(
response_uri=response_uri,
params=query_dict.urlencode()
)
return url
def get_user_info(self):
tk = json.loads(self.vp_token)
self.user_info = tk.get(
"verifiableCredential", [{}]
)[-1].get("credentialSubject")

View file

@ -0,0 +1,11 @@
{
"@context": [
"https://www.w3.org/2018/credentials/v1"
],
"id": "http://example.org/presentations/3731",
"type": [
"VerifiablePresentation"
],
"holder": "{{ holder_did }}",
"verifiableCredential": {{ verifiable_credential_list|safe }}
}

View file

@ -0,0 +1,96 @@
{% extends "idhub/base.html" %}
{% load i18n %}
{% block content %}
<h3>
<i class="{{ icon }}"></i>
{{ subtitle }}
</h3>
{% load django_bootstrap5 %}
<form role="form" method="post">
{% csrf_token %}
{% if form.errors %}
<div class="alert alert-danger alert-icon alert-icon-border alert-dismissible" role="alert">
<div class="icon"><span class="mdi mdi-close-circle-o"></span></div>
<div class="message">
{% for field, error in form.errors.items %}
{{ error }}<br />
{% endfor %}
<button class="btn-close" type="button" data-dismiss="alert" aria-label="Close"></button>
</div>
</div>
{% endif %}
{% if form.credentials.all %}
{% for presentation in form.presentation_definition %}
<div class="row mt-5">
<div class="col">
<h3>{{ presentation|capfirst }}</3>
</div>
</div>
<div class="row mt-2">
<div class="col">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col"></th>
<th scope="col"><button type="button" class="btn btn-grey border border-dark">{% trans 'Type' %}</button></th>
<th scope="col"><button type="button" class="btn btn-grey border border-dark">{% trans 'Details' %}</button></th>
<th scope="col"><button type="button" class="btn btn-grey border border-dark">{% trans 'Issued' %}</button></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{% for f in form.credentials.all %}
{% if f.schema.type.lower == presentation.lower %}
<tr style="font-size:15px;">
<td><input class="form-check-input" type="radio" value="{{ f.id }}" name="{{ presentation.lower }}"></td>
<td>{{ f.type }}</td>
<td>{{ f.description }}</td>
<td>{{ f.get_issued_on }}</td>
<td><a href="{% url 'idhub:user_credential' f.id %}" class="text-primary" title="{% trans 'View' %}"><i class="bi bi-eye"></i></a></td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endfor %}
<div class="form-actions-no-box mt-3">
<input class="form-check-input" type="checkbox" value="" name="consent" required="required" /> {% trans 'I read and understood the' %}
<a href="javascript:void()" data-bs-toggle="modal" data-bs-target="#legality-consent">{% trans 'data sharing notice' %}</a>
</div>
<div class="form-actions-no-box mt-5">
<a class="btn btn-grey" href="{% url 'idhub:user_demand_authorization' %}">{% trans "Cancel" %}</a>
<input class="btn btn-green-user" type="submit" name="submit" value="{% trans 'Present' %}" />
</div>
{% else %}
<div class="row mt-5">
<div class="col">
<h3>{% trans 'There are not credentials for present' %}</h3>
</div>
</div>
{% endif %}
</form>
<!-- Modal -->
<div class="modal" id="legality-consent" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="exampleModalLabel">{% trans 'Data sharing notice' %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{% trans 'Are you sure that you want delete this user?' %}
</div>
<div class="modal-footer"></div>
</div>
</div>
</div>
{% endblock %}

3
oidc4vp/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

16
oidc4vp/urls.py Normal file
View file

@ -0,0 +1,16 @@
from django.urls import path, reverse_lazy
from oidc4vp import views
app_name = 'oidc4vp'
urlpatterns = [
path('verify', views.VerifyView.as_view(),
name="verify"),
path('authorize', views.AuthorizeView.as_view(),
name="authorize"),
path('allow_code', views.AllowCodeView.as_view(),
name="allow_code"),
]

165
oidc4vp/views.py Normal file
View file

@ -0,0 +1,165 @@
import json
import base64
from django.conf import settings
from django.views.generic.edit import View, FormView
from django.http import HttpResponse, Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.urls import reverse_lazy
from django.contrib import messages
from oidc4vp.models import Authorization, Organization, OAuth2VPToken
from idhub.mixins import UserView
from oidc4vp.forms import AuthorizeForm
from utils.idhub_ssikit import verify_presentation
class AuthorizeView(UserView, FormView):
title = _("My wallet")
section = "MyWallet"
template_name = "credentials_presentation.html"
subtitle = _('Credential presentation')
icon = 'bi bi-patch-check-fill'
form_class = AuthorizeForm
success_url = reverse_lazy('idhub:user_demand_authorization')
def get(self, request, *args, **kwargs):
response = super().get(request, *args, **kwargs)
if self.request.session.get('next_url'):
return redirect(reverse_lazy('idhub:user_credentials_request'))
return response
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['user'] = self.request.user
try:
vps = json.loads(self.request.GET.get('presentation_definition'))
except:
vps = []
kwargs['presentation_definition'] = vps
kwargs["org"] = self.get_org()
kwargs["code"] = self.request.GET.get('code')
return kwargs
def get_form(self, form_class=None):
form = super().get_form(form_class=form_class)
if form.all_credentials.exists() and not form.credentials.exists():
self.request.session['next_url'] = self.request.get_full_path()
return form
def form_valid(self, form):
authorization = form.save()
if not authorization or authorization.status_code != 200:
messages.error(self.request, _("Error sending credential!"))
return super().form_valid(form)
try:
authorization = authorization.json()
except:
messages.error(self.request, _("Error sending credential!"))
return super().form_valid(form)
verify = authorization.get('verify')
result, msg = verify.split(",")
if 'error' in result.lower():
messages.error(self.request, msg)
if 'ok' in result.lower():
messages.success(self.request, msg)
if authorization.get('redirect_uri'):
return redirect(authorization.get('redirect_uri'))
elif authorization.get('response'):
txt = authorization.get('response')
messages.success(self.request, txt)
return super().form_valid(form)
def get_org(self):
client_id = self.request.GET.get("client_id")
if not client_id:
raise Http404("Organization not found!")
org = get_object_or_404(
Organization,
client_id=client_id,
)
return org
@method_decorator(csrf_exempt, name='dispatch')
class VerifyView(View):
def get(self, request, *args, **kwargs):
org = self.validate(request)
presentation_definition = json.dumps(settings.SUPPORTED_CREDENTIALS)
authorization = Authorization(
organization=org,
presentation_definition=presentation_definition
)
authorization.save()
res = json.dumps({"redirect_uri": authorization.authorize()})
return HttpResponse(res)
def post(self, request, *args, **kwargs):
code = self.request.POST.get("code")
vp_tk = self.request.POST.get("vp_token")
if not vp_tk or not code:
raise Http404("Page not Found!")
org = self.validate(request)
vp_token = OAuth2VPToken(
vp_token = vp_tk,
organization=org,
code=code
)
if not vp_token.authorization:
raise Http404("Page not Found!")
vp_token.verifing()
response = vp_token.get_response_verify()
vp_token.save()
if not vp_token.authorization.promotions.exists():
response["redirect_uri"] = ""
response["response"] = "Validation Code {}".format(code)
return JsonResponse(response)
def validate(self, request):
auth_header = request.headers.get('Authorization', b'')
auth_data = auth_header.split()
if len(auth_data) == 2 and auth_data[0].lower() == 'basic':
decoded_auth = base64.b64decode(auth_data[1]).decode('utf-8')
client_id, client_secret = decoded_auth.split(':', 1)
org_url = request.GET.get('demand_uri')
org = get_object_or_404(
Organization,
client_id=client_id,
client_secret=client_secret
)
return org
raise Http404("Page not Found!")
class AllowCodeView(View):
def get(self, request, *args, **kwargs):
code = self.request.GET.get("code")
if not code:
raise Http404("Page not Found!")
self.authorization = get_object_or_404(
Authorization,
code=code,
code_used=False
)
if not self.authorization.promotions.exists():
raise Http404("Page not Found!")
promotion = self.authorization.promotions.all()[0]
return redirect(promotion.get_url(code))

0
promotion/__init__.py Normal file
View file

3
promotion/admin.py Normal file
View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
promotion/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class PromotionConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'promotion'

65
promotion/forms.py Normal file
View file

@ -0,0 +1,65 @@
import json
import requests
from django import forms
from django.conf import settings
from django.template.loader import get_template
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
from utils.idhub_ssikit import create_verifiable_presentation
from oidc4vp.models import Organization, Authorization
from promotion.models import Promotion
class WalletForm(forms.Form):
organization = forms.ChoiceField(choices=[])
def __init__(self, *args, **kwargs):
self.presentation_definition = kwargs.pop('presentation_definition', [])
super().__init__(*args, **kwargs)
self.fields['organization'].choices = [
(x.id, x.name) for x in Organization.objects.filter()
if x.response_uri != settings.RESPONSE_URI
]
def save(self, commit=True):
self.org = Organization.objects.filter(
id=self.data['organization']
)
if not self.org.exists():
return
self.org = self.org[0]
self.authorization = Authorization(
organization=self.org,
presentation_definition=self.presentation_definition,
)
self.promotion = Promotion(
discount = Promotion.Types.VULNERABLE.value,
authorize = self.authorization
)
if commit:
self.authorization.save()
self.promotion.save()
return self.authorization.authorize()
return
class ContractForm(forms.Form):
nif = forms.CharField()
name = forms.CharField()
first_last_name = forms.CharField()
second_last_name = forms.CharField()
email = forms.CharField()
email_repeat = forms.CharField()
telephone = forms.CharField()
birthday = forms.CharField()
gen = forms.CharField()
lang = forms.CharField()

View file

@ -0,0 +1,45 @@
# Generated by Django 4.2.5 on 2023-12-11 08:35
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('oidc4vp', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Promotion',
fields=[
(
'id',
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID',
),
),
('name', models.CharField(max_length=250)),
(
'discount',
models.PositiveSmallIntegerField(
choices=[(1, 'Financial vulnerability')]
),
),
(
'authorize',
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='promotions',
to='oidc4vp.authorization',
),
),
],
),
]

View file

31
promotion/models.py Normal file
View file

@ -0,0 +1,31 @@
from django.db import models
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from oidc4vp.models import Authorization
class Promotion(models.Model):
class Types(models.IntegerChoices):
VULNERABLE = 1, _("Financial vulnerability")
name = models.CharField(max_length=250)
discount = models.PositiveSmallIntegerField(
choices=Types.choices,
)
authorize = models.ForeignKey(
Authorization,
on_delete=models.CASCADE,
related_name='promotions',
null=True,
)
def get_url(self, code):
url = "{}?code={}".format(
reverse_lazy("promotion:contract"),
code
)
return url
def get_discount(self, price):
return price - price*0.25

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3
promotion/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

18
promotion/urls.py Normal file
View file

@ -0,0 +1,18 @@
from django.urls import path, reverse_lazy
from promotion import views
app_name = 'promotion'
urlpatterns = [
path('', views.PromotionView.as_view(),
name="show_promotion"),
path('select_wallet', views.SelectWalletView.as_view(),
name="select_wallet"),
path('contract', views.ContractView.as_view(),
name="contract"),
path('contract/1', views.ThanksView.as_view(),
name="thanks"),
]

125
promotion/views.py Normal file
View file

@ -0,0 +1,125 @@
import json
from django.views.generic.edit import View, FormView
from django.shortcuts import redirect
from django.template.loader import get_template
from django.urls import reverse_lazy
from django.http import HttpResponse
from oidc4vp.models import Authorization
from promotion.forms import WalletForm, ContractForm
class PromotionView(View):
template_name = "somconnexio_tarifes_mobil.html"
def get(self, request, *args, **kwargs):
self.context = {}
template = get_template(
self.template_name,
).render()
return HttpResponse(template)
class ThanksView(View):
template_name = "somconnexio_thanks.html"
def get(self, request, *args, **kwargs):
self.context = {}
template = get_template(
self.template_name,
).render()
return HttpResponse(template)
class ContractView(FormView):
template_name = "somconnexio_contract.html"
promotion = None
vp_token = None
authorization = None
form_class = ContractForm
success_url = reverse_lazy('promotion:thanks')
def get_context_data(self, **kwargs):
self.context = super().get_context_data(**kwargs)
code = self.request.GET.get("code")
self.get_discount(code)
self.context.update({
"promotion": self.promotion,
"verificable_presentation": self.vp_token,
"sim": 10.0,
"mensual": 15.0,
"total": 25.0
})
if self.promotion:
self.context['sim'] = self.promotion.get_discount(self.context["sim"])
self.context['mensual'] = self.promotion.get_discount(self.context["mensual"])
self.context['total'] = self.promotion.get_discount(self.context["total"])
if self.vp_token:
self.context['verificable_presentation'] = self.vp_token
return self.context
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
code = self.request.GET.get("code")
self.get_discount(code)
if not self.vp_token:
return kwargs
self.vp_token.get_user_info()
kwargs['initial']["nif"] = self.vp_token.user_info.get("nif", '')
kwargs['initial']["name"] = self.vp_token.user_info.get("name", '')
kwargs['initial']["first_last_name"] = self.vp_token.user_info.get("first_last_name", '')
kwargs['initial']["second_last_name"] = self.vp_token.user_info.get("second_last_name", '')
kwargs['initial']["email"] = self.vp_token.user_info.get("email", '')
kwargs['initial']["email_repeat"] = self.vp_token.user_info.get("email", '')
kwargs['initial']["telephone"] = self.vp_token.user_info.get("telephone", '')
kwargs['initial']["birthday"] = self.vp_token.user_info.get("birthday", '')
kwargs['initial']["gen"] = self.vp_token.user_info.get("gen", '')
kwargs['initial']["lang"] = self.vp_token.user_info.get("lang", '')
return kwargs
def form_valid(self, form):
return super().form_valid(form)
def get_discount(self, code):
if not code:
return
if self.authorization:
return
self.authorization = Authorization.objects.filter(
code=code,
code_used=False
).first()
if self.authorization:
if self.authorization.promotions.exists():
self.promotion = self.authorization.promotions.all()[0]
if self.authorization.vp_tokens.exists():
self.vp_token = self.authorization.vp_tokens.all()[0]
class SelectWalletView(FormView):
template_name = "select_wallet.html"
form_class = WalletForm
success_url = reverse_lazy('promotion:select_wallet')
# def get(self, request, *args, **kwargs):
# self.context = {'form': fo}
# template = get_template(
# self.template_name,
# # context
# ).render()
# return HttpResponse(template)
# def post(self, request, *args, **kwargs):
# super().post(request, *args, **kwargs)
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['presentation_definition'] = json.dumps(["MemberShipCard"])
return kwargs
def form_valid(self, form):
url = form.save()
return redirect(url)

View file

@ -12,3 +12,5 @@ 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

View file

@ -1,21 +0,0 @@
{
"$id": "https://pangea.org/schemas/member-credential-schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"name": "MemberCredential",
"description": "MemberCredential using JsonSchemaCredential",
"type": "object",
"properties": {
"name": {
"type": "string"
},
"email": {
"type": "string",
"format": "email"
},
"membershipType": {
"type": "string",
"enum": ["individual", "organization"]
}
},
"required": ["name", "email", "membershipType"]
}

View file

@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/4.2/ref/settings/
import os
from ast import literal_eval
from dj_database_url import parse as db_url
from pathlib import Path
from django.contrib.messages import constants as messages
@ -72,13 +73,16 @@ INSTALLED_APPS = [
'django_bootstrap5',
'django_tables2',
'idhub_auth',
'idhub'
'oidc4vp',
'idhub',
'promotion'
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
@ -110,10 +114,15 @@ WSGI_APPLICATION = 'trustchain_idhub.wsgi.application'
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
# 'default': {
# 'ENGINE': 'django.db.backends.sqlite3',
# 'NAME': BASE_DIR / 'db.sqlite3',
# }
'default': config(
'DATABASE_URL',
default='sqlite:///' + os.path.join(BASE_DIR, 'db.sqlite3'),
cast=db_url
)
# 'default': config(
# 'DATABASE_URL',
# default='sqlite:///' + os.path.join(BASE_DIR, 'db.sqlite3'),
@ -178,9 +187,38 @@ MESSAGE_TAGS = {
LOCALE_PATHS = [
os.path.join(BASE_DIR, 'locale'),
]
LANGUAGE_CODE="en"
# LANGUAGE_CODE="en"
# LANGUAGE_CODE="es"
LANGUAGE_CODE="ca"
gettext = lambda s: s
LANGUAGES = (
('de', gettext('German')),
('en', gettext('English')),
('ca', gettext('Catalan')),
)
USE_I18N = True
USE_L10N = True
AUTH_USER_MODEL = 'idhub_auth.User'
RESPONSE_URI = config('RESPONSE_URI', default="")
ALLOW_CODE_URI= config('ALLOW_CODE_URI', default="")
SUPPORTED_CREDENTIALS = config(
'SUPPORTED_CREDENTIALS',
default='[]',
cast=literal_eval
)
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {"class": "logging.StreamHandler"},
},
"loggers": {
"django": {
"handlers": ["console"],
"level": "INFO",
},
}
}

View file

@ -24,4 +24,6 @@ from django.contrib.auth import views as auth_views
urlpatterns = [
# path('django-admin/', admin.site.urls),
path('', include('idhub.urls')),
path('oidc4vp/', include('oidc4vp.urls')),
path('promotion/', include('promotion.urls')),
]

View file

@ -0,0 +1,73 @@
# Helper routines to manage DIDs/VC/VPs
This module is a wrapper around the functions exported by SpruceID's `DIDKit` framework.
## DID generation and storage
For now DIDs are of the kind `did:key`, with planned support for `did:web` in the near future.
Creation of a DID involves two steps:
* Generate a unique DID controller key
* Derive a `did:key` type from the key
Both must be stored in the IdHub database and linked to a `User` for later retrieval.
```python
# Use case: generate and link a new DID for an existing user
user = request.user # ...
controller_key = idhub_ssikit.generate_did_controller_key()
did_string = idhub_ssikit.keydid_from_controller_key(controller_key)
did = idhub.models.DID(
did = did_string,
user = user
)
did_controller_key = idhub.models.DIDControllerKey(
key_material = controller_key,
owner_did = did
)
did.save()
did_controller_key.save()
```
## Verifiable Credential issuance
Verifiable Credential templates are stored as Jinja2 (TBD) templates in `/schemas` folder. Please examine each template to see what data must be passed to it in order to render.
The data passed to the template must at a minimum include:
* issuer_did
* subject_did
* vc_id
For example, in order to render `/schemas/member-credential.json`:
```python
from jinja2 import Environment, FileSystemLoader, select_autoescape
import idhub_ssikit
env = Environment(
loader=FileSystemLoader("vc_templates"),
autoescape=select_autoescape()
)
unsigned_vc_template = env.get_template("member-credential.json")
issuer_user = request.user
issuer_did = user.dids[0] # TODO: Django ORM pseudocode
issuer_did_controller_key = did.keys[0] # TODO: Django ORM pseudocode
data = {
"vc_id": "http://pangea.org/credentials/3731",
"issuer_did": issuer_did,
"subject_did": "did:web:[...]",
"issuance_date": "2020-08-19T21:41:50Z",
"subject_is_member_of": "Pangea"
}
signed_credential = idhub_ssikit.render_and_sign_credential(
unsigned_vc_template,
issuer_did_controller_key,
data
)
```

View file

@ -4,6 +4,7 @@ import didkit
import json
import jinja2
from django.template.backends.django import Template
from django.template.loader import get_template
def generate_did_controller_key():
@ -49,7 +50,8 @@ def render_and_sign_credential(vc_template: jinja2.Template, jwk_issuer, vc_data
def sign_credential(unsigned_vc: str, jwk_issuer):
"""
Signs the and unsigned credential with the provided key.
Signs the unsigned credential with the provided key.
The credential template must be rendered with all user data.
"""
async def inner():
signed_vc = await didkit.issue_credential(
@ -62,13 +64,57 @@ def sign_credential(unsigned_vc: str, jwk_issuer):
return asyncio.run(inner())
def verify_credential(vc, proof_options):
def verify_credential(vc):
"""
Returns a (bool, str) tuple indicating whether the credential is valid.
If the boolean is true, the credential is valid and the second argument can be ignored.
If it is false, the VC is invalid and the second argument contains a JSON object with further information.
"""
async def inner():
return didkit.verify_credential(vc, proof_options)
return await didkit.verify_credential(vc, '{"proofFormat": "ldp"}')
return asyncio.run(inner())
def issue_verifiable_presentation(vp_template: Template, vc_list: list[str], jwk_holder: str, holder_did: str) -> str:
async def inner():
unsigned_vp = vp_template.render(data)
signed_vp = await didkit.issue_presentation(
unsigned_vp,
'{"proofFormat": "ldp"}',
jwk_holder
)
return signed_vp
data = {
"holder_did": holder_did,
"verifiable_credential_list": "[" + ",".join(vc_list) + "]"
}
return asyncio.run(inner())
def create_verifiable_presentation(jwk_holder: str, unsigned_vp: str) -> str:
async def inner():
signed_vp = await didkit.issue_presentation(
unsigned_vp,
'{"proofFormat": "ldp"}',
jwk_holder
)
return signed_vp
return asyncio.run(inner())
def verify_presentation(vp):
"""
Returns a (bool, str) tuple indicating whether the credential is valid.
If the boolean is true, the credential is valid and the second argument can be ignored.
If it is false, the VC is invalid and the second argument contains a JSON object with further information.
"""
async def inner():
proof_options = '{"proofFormat": "ldp"}'
return await didkit.verify_presentation(vp, proof_options)
return asyncio.run(inner())