From ebb4667af11e381b2d51226c67902adedc9d8eb2 Mon Sep 17 00:00:00 2001 From: Johannes Roos Date: Tue, 2 Apr 2024 17:54:09 +0200 Subject: [PATCH] added redeem logic --- contrib/builders/arkitekt.py | 2 +- fakts/admin.py | 1 + fakts/base_models.py | 7 ++ fakts/builders.py | 8 +- fakts/graphql/queries/client.py | 4 +- fakts/logic.py | 7 +- fakts/management/commands/ensuretokens.py | 53 +++++++++++ ...ter_devicecode_staging_kind_redeemtoken.py | 69 ++++++++++++++ .../0008_alter_redeemtoken_client.py | 23 +++++ fakts/models.py | 19 +++- fakts/urls.py | 1 + fakts/views.py | 93 +++++++++++++++++++ komment/enums.py | 2 +- lok/settings.py | 3 + run.sh | 3 + 15 files changed, 284 insertions(+), 11 deletions(-) create mode 100644 fakts/management/commands/ensuretokens.py create mode 100644 fakts/migrations/0007_alter_devicecode_staging_kind_redeemtoken.py create mode 100644 fakts/migrations/0008_alter_redeemtoken_client.py diff --git a/contrib/builders/arkitekt.py b/contrib/builders/arkitekt.py index e4f195f..a4ff170 100644 --- a/contrib/builders/arkitekt.py +++ b/contrib/builders/arkitekt.py @@ -50,7 +50,7 @@ def lok(self: "SelfServiceDescriptor", context: "LinkingContext", descriptor: "D - return { "base_url": base_url + "/o", "userinfo_url ": f"{base_url}/o/userinfo", "token_url": f"{base_url}/o/token", "authorization_url": f"{base_url}/o/authorize", "client_id": context.client.client_id, "client_secret": context.client.client_secret, "client_type": context.client.client_type, "authorization_grant_type": context.client.authorization_grant_type, "name": context.client.name, "scopes": context.manifest.scopes, "__service": "live.arkitekt.lok"} | generic(self, context, descriptor) + return { "base_url": base_url + "/o", "userinfo_url ": f"{base_url}/o/userinfo", "token_url": f"{base_url}/o/token", "authorization_url": f"{base_url}/o/authorize", "client_id": context.client.client_id, "client_secret": context.client.client_secret, "client_type": context.client.client_type, "grant_type": context.client.authorization_grant_type, "name": context.client.name, "scopes": context.manifest.scopes, "__service": "live.arkitekt.lok"} | generic(self, context, descriptor) def generic(self: "SelfServiceDescriptor", context: "LinkingContext", descriptor: "DockerServiceDescriptor"): diff --git a/fakts/admin.py b/fakts/admin.py index c48587d..3eefd3d 100644 --- a/fakts/admin.py +++ b/fakts/admin.py @@ -10,3 +10,4 @@ admin.site.register(Client) admin.site.register(DeviceCode) admin.site.register(ServiceInstanceMapping) +admin.site.register(RedeemToken) \ No newline at end of file diff --git a/fakts/base_models.py b/fakts/base_models.py index 3baddc8..b38530b 100644 --- a/fakts/base_models.py +++ b/fakts/base_models.py @@ -72,6 +72,13 @@ class DeviceCodeStartRequest(BaseModel): request_public: bool = False + +class ReedeemTokenRequest(BaseModel): + """ A RedeemTokenRequest is used to redeem a token for a development client. It only contains the token.""" + token: str + manifest: Manifest + + class DeviceCodeChallengeRequest(BaseModel): """ A DeviceCodeChallengeRequest is used to start the device code flow. It only contains the device code.""" diff --git a/fakts/builders.py b/fakts/builders.py index 0a700d2..82212ec 100644 --- a/fakts/builders.py +++ b/fakts/builders.py @@ -181,8 +181,12 @@ def create_client( ): from .utils import download_logo - - logo = download_logo(manifest.logo) if manifest.logo else None + try: + logo = download_logo(manifest.logo) if manifest.logo else None + except Exception as e: + raise ValueError(f"Could not download logo {e}") + + app, _ = models.App.objects.get_or_create(identifier=manifest.identifier) release, _ = models.Release.objects.update_or_create(app=app, version=manifest.version, defaults={ "logo": logo, diff --git a/fakts/graphql/queries/client.py b/fakts/graphql/queries/client.py index 316f432..b89693b 100644 --- a/fakts/graphql/queries/client.py +++ b/fakts/graphql/queries/client.py @@ -5,8 +5,8 @@ from fakts import enums, inputs, models, scalars, types -def client(info: Info, id: strawberry.ID | None, client_id: strawberry.ID | None) -> types.Client: - return models.Client.get(id=id) +def client(info: Info, id: strawberry.ID | None, client_id: strawberry.ID | None = None) -> types.Client: + return models.Client.objects.get(id=id) diff --git a/fakts/logic.py b/fakts/logic.py index c38ae86..7c5561a 100644 --- a/fakts/logic.py +++ b/fakts/logic.py @@ -93,7 +93,7 @@ def auto_create_composition(manifest: base_models.Manifest) -> models.Compositio except Exception as e: if req.optional: - continue + warnings.append(str(e)) else: errors.append(str(e)) @@ -122,14 +122,13 @@ def check_compability(manifest: base_models.Manifest) -> list[str] | list[str]: try: service = models.Service.objects.get(identifier=req.service) except models.Service.DoesNotExist: - errors.append(f"Service {req.service} not found on this server. Please contact the administrator.") - continue + raise Exception(f"Service {req.service} not found on this server. Please contact the administrator.") instance = find_instance_for_requirement(service, req) except Exception as e: if req.optional: - continue + warnings.append(str(e)) else: errors.append(str(e)) diff --git a/fakts/management/commands/ensuretokens.py b/fakts/management/commands/ensuretokens.py new file mode 100644 index 0000000..b975b21 --- /dev/null +++ b/fakts/management/commands/ensuretokens.py @@ -0,0 +1,53 @@ +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand +from django.conf import settings +import os +from fakts import models, base_models +import yaml +from fakts.scan import scan +from fakts.backends.instances import registry + + +# import required module +from pathlib import Path + + +# assign directory +directory = "files" + +# iterate over files in +# that directory + + +class Command(BaseCommand): + help = "Creates an admin user non-interactively if it doesn't exist" + + def handle(self, *args, **kwargs): + + TOKENS = settings.REDEEM_TOKENS + + for token in TOKENS: + + user = get_user_model().objects.get(username=token["user"]) + + token, _ = models.RedeemToken.objects.update_or_create( + token=token["token"], + defaults={ + "user": user, + } + ) + + print(f"Token {token} created for user {user}") + + + + + + + + + + + + + diff --git a/fakts/migrations/0007_alter_devicecode_staging_kind_redeemtoken.py b/fakts/migrations/0007_alter_devicecode_staging_kind_redeemtoken.py new file mode 100644 index 0000000..4bf7997 --- /dev/null +++ b/fakts/migrations/0007_alter_devicecode_staging_kind_redeemtoken.py @@ -0,0 +1,69 @@ +# Generated by Django 4.2.5 on 2024-04-02 10:29 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_choices_field.fields +import fakts.enums +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("fakts", "0006_alter_release_requirements_instanceconfig_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="devicecode", + name="staging_kind", + field=django_choices_field.fields.TextChoicesField( + choices=[ + ("website", "WEBSITE (Value represent WEBSITE)"), + ("development", "DEVELOPMENT (Value represent DEVELOPMENT)"), + ("desktop", "DESKTOP (Value represent DESKTOP Aüü)"), + ], + choices_enum=fakts.enums.ClientKindChoices, + default="development", + help_text="The kind of staging client", + max_length=11, + ), + ), + migrations.CreateModel( + name="RedeemToken", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "token", + models.CharField(default=uuid.uuid4, max_length=1000, unique=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("expires_at", models.DateTimeField(null=True)), + ( + "client", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="redeemed_client", + to="fakts.client", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issued_tokens", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/fakts/migrations/0008_alter_redeemtoken_client.py b/fakts/migrations/0008_alter_redeemtoken_client.py new file mode 100644 index 0000000..213f3d3 --- /dev/null +++ b/fakts/migrations/0008_alter_redeemtoken_client.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.5 on 2024-04-02 10:30 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("fakts", "0007_alter_devicecode_staging_kind_redeemtoken"), + ] + + operations = [ + migrations.AlterField( + model_name="redeemtoken", + name="client", + field=models.OneToOneField( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="redeemed_client", + to="fakts.client", + ), + ), + ] diff --git a/fakts/models.py b/fakts/models.py index f4de6cb..b745723 100644 --- a/fakts/models.py +++ b/fakts/models.py @@ -109,10 +109,27 @@ class Meta: ] def __str__(self): - return f"{self.key}:{self.instance}@{self.composition}" + return f"{self.key}:{self.instance}@{self.composition}" + +class RedeemToken(models.Model): + """ A redeem token is a token that can be used to redeed the rights to create + a client. It is used to give the recipient the right to create a client. + + If the token is not redeemed within the expires_at time, it will be invalid. + If the token has been redeemed, but the manifest has changed, the token will be invalid. + + + """ + client = models.OneToOneField("Client", on_delete=models.CASCADE, related_name="redeemed_client", null=True) + token = models.CharField(max_length=1000, unique=True, default=uuid.uuid4) + created_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField(null=True) + user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name="issued_tokens") + + class DeviceCode(models.Model): created_at = models.DateTimeField(auto_now_add=True) code = models.CharField(max_length=100, unique=True) diff --git a/fakts/urls.py b/fakts/urls.py index d6e22a5..9a099d8 100644 --- a/fakts/urls.py +++ b/fakts/urls.py @@ -10,6 +10,7 @@ base_urlpatterns = [ re_path(r"^configure/$", views.ConfigureView.as_view(), name="configure"), re_path(r"^retrieve/$", views.RetrieveView.as_view(), name="retrieve"), + re_path(r"^redeem/$", views.RedeemView.as_view(), name="redeem"), re_path(r"^challenge/$", views.ChallengeView.as_view(), name="challenge"), re_path(r"^start/$", views.StartChallengeView.as_view(), name="start"), re_path(r"^device/$", views.DeviceView.as_view(), name="device"), diff --git a/fakts/views.py b/fakts/views.py index 5038189..de08c65 100644 --- a/fakts/views.py +++ b/fakts/views.py @@ -418,6 +418,10 @@ def post(self, request, *args, **kwargs): "message": "User has not verfied the challenge", } ) + + + + @method_decorator(csrf_exempt, name="dispatch") @@ -479,6 +483,95 @@ def post(self, request, *args, **kwargs): ) +@method_decorator(csrf_exempt, name="dispatch") +class RedeemView(View): + """ + Implements an endpoint that returns the faktsclaim for a given identifier and version + if the app was already configured and the app is marked as PUBLIC. While any app can + request a faktsclaim for any other app, redirect uris are set to predifined values + and the app will not be able to use the faktsclaim to get a configuration. + """ + + def post(self, request, *args, **kwargs): + json_data = json.loads(request.body) + try: + redeem_request = base_models.ReedeemTokenRequest(**json_data) + except Exception as e: + logger.error(e, exc_info=True) + return JsonResponse( + data={ + "status": "error", + "error": f"Malformed request: {str(e)}", + } + ) + + + manifest = redeem_request.manifest + token = redeem_request.token + + try: + valid_token = models.RedeemToken.objects.get(token=token) + except models.RedeemToken.DoesNotExist: + return JsonResponse( + data={ + "status": "error", + "message": "Invalid redeem token", + } + ) + if valid_token.expires_at: + if valid_token.expires_at < datetime.datetime.now(timezone.utc): + return JsonResponse( + data={ + "status": "error", + "message": "Redeem token expired", + } + ) + + if valid_token.client: + return JsonResponse( + data={ + "status": "granted", + "token": valid_token.client.token, + } + ) + + else: + try: + token = logic.create_api_token() + + config = None + + config = base_models.DevelopmentClientConfig( + kind=enums.ClientKindVanilla.DEVELOPMENT.value, + token=token, + user=valid_token.user.username, + tenant=valid_token.user.username, + ) + + + client = builders.create_client( + manifest=manifest, + config=config, + ) + + valid_token.client = client + valid_token.save() + + return JsonResponse( + data={ + "status": "granted", + "token": client.token, + } + ) + except Exception as e: + logger.error(e, exc_info=True) + return JsonResponse( + data={ + "status": "error", + "message": str(e), + } + ) + @method_decorator(csrf_exempt, name="dispatch") class ClaimView(View): diff --git a/komment/enums.py b/komment/enums.py index 5f80ceb..95d7302 100644 --- a/komment/enums.py +++ b/komment/enums.py @@ -15,5 +15,5 @@ class DescendantKind(str, Enum): LEAF = "LEAF" MENTION = "MENTION" - PARAGRAPH = "PARGRAPTH" + PARAGRAPH = "PARAGRAPH" diff --git a/lok/settings.py b/lok/settings.py index 192b64c..c544613 100644 --- a/lok/settings.py +++ b/lok/settings.py @@ -156,6 +156,9 @@ ASGI_APPLICATION = "lok.asgi.application" +REDEEM_TOKENS = conf.get("redeem_tokens", []) + + EKKE = { "PUBLIC_KEY": conf.lok.get("public_key", None), "PUBLIC_KEY_PEM_FILE": conf.lok.get("public_key_pem_file", None), diff --git a/run.sh b/run.sh index 0d7aac0..2fbb47b 100644 --- a/run.sh +++ b/run.sh @@ -14,6 +14,9 @@ python manage.py ensureusers echo "=> Ensuring Compositions..." python manage.py ensurecompositions +echo "=> Ensuring Redeem Tokens..." +python manage.py ensuretokens + echo "=> Ensuring Apps..." python manage.py ensureapps