diff --git a/authentik/recovery/lib.py b/authentik/recovery/lib.py new file mode 100644 index 000000000..8a97d1c07 --- /dev/null +++ b/authentik/recovery/lib.py @@ -0,0 +1,35 @@ +from datetime import datetime + +from django.urls import reverse +from django.utils.text import slugify +from django.utils.timezone import now +from rest_framework.request import Request + +from authentik.core.models import Group, Token, TokenIntents, User + + +def create_admin_group(user: User) -> Group: + """Create admin group and add user to it.""" + group, _ = Group.objects.update_or_create( + name="authentik Admins", + defaults={ + "is_superuser": True, + }, + ) + group.users.add(user) + + return group + + +def create_recovery_token(user: User, expiry: datetime, generated_from: str) -> (Token, str): + """Create recovery token and associated link""" + _now = now() + token = Token.objects.create( + expires=expiry, + user=user, + intent=TokenIntents.INTENT_RECOVERY, + description=f"Recovery Token generated by {generated_from} on {_now}", + identifier=slugify(f"ak-recovery-{user}-{_now}"), + ) + url = reverse("authentik_recovery:use-token", kwargs={"key": str(token.key)}) + return token, url diff --git a/authentik/recovery/management/commands/create_admin_group.py b/authentik/recovery/management/commands/create_admin_group.py index 7605eaceb..cb2b1fe11 100644 --- a/authentik/recovery/management/commands/create_admin_group.py +++ b/authentik/recovery/management/commands/create_admin_group.py @@ -1,7 +1,8 @@ """authentik recovery create_admin_group""" from django.utils.translation import gettext as _ -from authentik.core.models import Group, User +from authentik.core.models import User +from authentik.recovery.lib import create_admin_group from authentik.tenants.management import TenantCommand @@ -20,11 +21,5 @@ class Command(TenantCommand): if not user: self.stderr.write(f"User '{username}' not found.") return - group, _ = Group.objects.update_or_create( - name="authentik Admins", - defaults={ - "is_superuser": True, - }, - ) - group.users.add(user) - self.stdout.write(f"User '{username}' successfully added to the group 'authentik Admins'.") + group = create_admin_group(user) + self.stdout.write(f"User '{username}' successfully added to the group '{group.name}'.") diff --git a/authentik/recovery/management/commands/create_recovery_key.py b/authentik/recovery/management/commands/create_recovery_key.py index 93663dfde..9e41ba944 100644 --- a/authentik/recovery/management/commands/create_recovery_key.py +++ b/authentik/recovery/management/commands/create_recovery_key.py @@ -2,12 +2,11 @@ from datetime import timedelta from getpass import getuser -from django.urls import reverse -from django.utils.text import slugify from django.utils.timezone import now from django.utils.translation import gettext as _ -from authentik.core.models import Token, TokenIntents, User +from authentik.core.models import User +from authentik.recovery.lib import create_recovery_token from authentik.tenants.management import TenantCommand @@ -25,28 +24,16 @@ class Command(TenantCommand): ) parser.add_argument("user", action="store", help="Which user the Token gives access to.") - def get_url(self, token: Token) -> str: - """Get full recovery link""" - return reverse("authentik_recovery:use-token", kwargs={"key": str(token.key)}) - def handle_per_tenant(self, *args, **options): """Create Token used to recover access""" duration = int(options.get("duration", 1)) - _now = now() - expiry = _now + timedelta(days=duration * 365.2425) - users = User.objects.filter(username=options.get("user")) - if not users.exists(): + expiry = now() + timedelta(days=duration * 365.2425) + user = User.objects.filter(username=options.get("user")).first() + if not user: self.stderr.write(f"User '{options.get('user')}' not found.") return - user = users.first() - token = Token.objects.create( - expires=expiry, - user=user, - intent=TokenIntents.INTENT_RECOVERY, - description=f"Recovery Token generated by {getuser()} on {_now}", - identifier=slugify(f"ak-recovery-{user}-{_now}"), - ) + _, url = create_recovery_token(user, expiry, getuser()) self.stdout.write( f"Store this link safely, as it will allow anyone to access authentik as {user}." ) - self.stdout.write(self.get_url(token)) + self.stdout.write(url) diff --git a/authentik/tenants/api.py b/authentik/tenants/api.py index 1f81e7f2e..70b273e02 100644 --- a/authentik/tenants/api.py +++ b/authentik/tenants/api.py @@ -1,20 +1,30 @@ """Serializer for tenants models""" +from datetime import timedelta from hmac import compare_digest from django.http import HttpResponseNotFound +from django.http.request import urljoin +from django.utils.timezone import now +from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework import permissions from rest_framework.authentication import get_authorization_header +from rest_framework.decorators import action +from rest_framework.fields import CharField, IntegerField from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.generics import RetrieveUpdateAPIView from rest_framework.permissions import SAFE_METHODS from rest_framework.request import Request -from rest_framework.serializers import ModelSerializer +from rest_framework.response import Response +from rest_framework.serializers import DateTimeField, ModelSerializer from rest_framework.views import View from rest_framework.viewsets import ModelViewSet from authentik.api.authentication import validate_auth +from authentik.core.api.utils import PassiveSerializer +from authentik.core.models import User from authentik.lib.config import CONFIG from authentik.rbac.permissions import HasPermission +from authentik.recovery.lib import create_admin_group, create_recovery_token from authentik.tenants.models import Domain, Tenant @@ -44,6 +54,23 @@ class TenantSerializer(ModelSerializer): ] +class TenantAdminGroupRequestSerializer(PassiveSerializer): + """Tenant admin group creation request serializer""" + user = CharField() + + +class TenantRecoveryKeyRequestSerializer(PassiveSerializer): + """Tenant recovery key creation request serializer""" + user = CharField() + duration_days = IntegerField(initial=365) + + +class TenantRecoveryKeyResponseSerializer(PassiveSerializer): + """Tenant recovery key creation response serializer""" + expiry = DateTimeField() + url = CharField() + + class TenantViewSet(ModelViewSet): """Tenant Viewset""" @@ -65,6 +92,60 @@ class TenantViewSet(ModelViewSet): return HttpResponseNotFound() return super().dispatch(request, *args, **kwargs) + @extend_schema( + request=TenantAdminGroupRequestSerializer(), + responses={ + 204: OpenApiResponse(description="Group created successfully."), + 400: OpenApiResponse(description="Bad request"), + 404: OpenApiResponse(description="User not found"), + }, + ) + @action(detail=True, pagination_class=None, methods=["POST"]) + def create_admin_group(self, request: Request, pk: str) -> Response: + """Create admin group and add user to it.""" + tenant = self.get_object() + with tenant: + data = TenantAdminGroupRequestSerializer(data=request.data) + if not data.is_valid(): + return Response(data.errors, status=400) + user = User.objects.filter(username=data.validated_data.get("user")).first() + if not user: + return Response(status=404) + _ = create_admin_group(user) + return Response(status=200) + + @extend_schema( + request=TenantRecoveryKeyRequestSerializer(), + responses={ + 200: TenantRecoveryKeyResponseSerializer(), + 400: OpenApiResponse(description="Bad request"), + 404: OpenApiResponse(description="User not found"), + }, + ) + @action(detail=True, pagination_class=None, methods=["POST"]) + def create_recovery_key(self, request: Request, pk: str) -> Response: + """Create recovery key for user.""" + tenant = self.get_object() + with tenant: + data = TenantRecoveryKeyRequestSerializer(data=request.data) + if not data.is_valid(): + return Response(data.errors, status=400) + user = User.objects.filter(username=data.validated_data.get("user")).first() + if not user: + return Response(status=404) + + expiry = now() + timedelta(days=data.validated_data.get("duration_days")) + + token, url = create_recovery_token(user, expiry, "tenants API") + + domain = tenant.get_primary_domain() + host = domain.domain if domain else request.get_host() + + url = urljoin(f"{request.scheme}://{host}", url) + + serializer = TenantRecoveryKeyResponseSerializer({"expiry": token.expires, "url": url}) + return Response(serializer.data) + class DomainSerializer(ModelSerializer): """Domain Serializer""" diff --git a/schema.yml b/schema.yml index 73986c451..e309976d0 100644 --- a/schema.yml +++ b/schema.yml @@ -28371,6 +28371,76 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /tenants/tenants/{tenant_uuid}/create_admin_group/: + post: + operationId: tenants_tenants_create_admin_group_create + description: Create admin group and add user to it. + parameters: + - in: path + name: tenant_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Tenant. + required: true + tags: + - tenants + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TenantAdminGroupRequestRequest' + required: true + responses: + '204': + description: Group created successfully. + '400': + description: Bad request + '404': + description: User not found + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /tenants/tenants/{tenant_uuid}/create_recovery_key/: + post: + operationId: tenants_tenants_create_recovery_key_create + description: Create recovery key for user. + parameters: + - in: path + name: tenant_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Tenant. + required: true + tags: + - tenants + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TenantRecoveryKeyRequestRequest' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TenantRecoveryKeyResponse' + description: '' + '400': + description: Bad request + '404': + description: User not found + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' components: schemas: AccessDeniedChallenge: @@ -42275,6 +42345,39 @@ components: - name - schema_name - tenant_uuid + TenantAdminGroupRequestRequest: + type: object + description: Tenant admin group creation request serializer + properties: + user: + type: string + minLength: 1 + required: + - user + TenantRecoveryKeyRequestRequest: + type: object + description: Tenant recovery key creation request serializer + properties: + user: + type: string + minLength: 1 + duration_days: + type: integer + required: + - duration_days + - user + TenantRecoveryKeyResponse: + type: object + description: Tenant recovery key creation response serializer + properties: + expiry: + type: string + format: date-time + url: + type: string + required: + - expiry + - url TenantRequest: type: object description: Tenant Serializer